diff --git a/rust_solid_cassandra/backend/.projectile b/rust_solid_cassandra/backend/.projectile new file mode 100644 index 0000000..e69de29 diff --git a/rust_solid_cassandra/backend/Cargo.lock b/rust_solid_cassandra/backend/Cargo.lock index a5f7826..36c3963 100644 --- a/rust_solid_cassandra/backend/Cargo.lock +++ b/rust_solid_cassandra/backend/Cargo.lock @@ -56,6 +56,22 @@ dependencies = [ "zstd", ] +[[package]] +name = "actix-identity" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1224c9f9593dc27c9077b233ce04adedc1d7febcfc35ee9f53ea3c24df180bec" +dependencies = [ + "actix-service", + "actix-session", + "actix-utils", + "actix-web", + "anyhow", + "futures-core", + "serde", + "tracing", +] + [[package]] name = "actix-macros" version = "0.2.3" @@ -321,11 +337,14 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" name = "backend" version = "0.1.0" dependencies = [ + "actix-identity", "actix-session", "actix-web", "cassandra-cpp", "env_logger", "log", + "serde", + "serde_json", "uuid", ] @@ -1072,6 +1091,20 @@ name = "serde" version = "1.0.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "serde_json" @@ -1335,6 +1368,7 @@ checksum = "422ee0de9031b5b948b97a8fc04e3aa35230001a722ddd27943e0be31564ce4c" dependencies = [ "getrandom", "rand", + "serde", "uuid-macro-internal", ] diff --git a/rust_solid_cassandra/backend/Cargo.toml b/rust_solid_cassandra/backend/Cargo.toml index 2066f96..690ed2d 100644 --- a/rust_solid_cassandra/backend/Cargo.toml +++ b/rust_solid_cassandra/backend/Cargo.toml @@ -8,7 +8,10 @@ edition = "2021" [dependencies] actix-web = "4" # Webserver itself actix-session = { version = "0.7", features = ["cookie-session"] } # Session middleware +actix-identity = "0.5.2" env_logger = "0.9" # Logger itself log = "0.4" # Lightweight logging facade (Logging API) -uuid = { version = "1.2.2", features = ["v4", "fast-rng", "macro-diagnostics" ]} -cassandra-cpp = "1.2" \ No newline at end of file +uuid = { version = "1.2.2", features = ["v4", "fast-rng", "macro-diagnostics", "serde"]} +cassandra-cpp = "1.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" \ No newline at end of file diff --git a/rust_solid_cassandra/backend/src/main.rs b/rust_solid_cassandra/backend/src/main.rs index 235511b..2374da3 100644 --- a/rust_solid_cassandra/backend/src/main.rs +++ b/rust_solid_cassandra/backend/src/main.rs @@ -1,79 +1,184 @@ -use std::{any::Any, io}; +use std::{io, sync::Arc}; -use actix_session::{storage::CookieSessionStore, Session, SessionMiddleware}; +use actix_identity::{Identity, IdentityMiddleware}; +use actix_session::{ + config::PersistentSession, storage::CookieSessionStore, Session, SessionMiddleware, +}; use actix_web::{ - get, + cookie::{time::Duration, Key}, + delete, get, http::{header::ContentType, Method}, - middleware, + middleware, post, put, web::{self}, - App, HttpRequest, HttpResponse, HttpServer, + App, HttpMessage, HttpRequest, HttpResponse, HttpServer, Responder, }; -// Define our model module and use our models +// Define our model module mod model; +use model::todo::{Priority, Status, Todo}; use model::user::User; -use model::todo::{Todo, Priority, Status}; +// Define our repo module mod repo; +// use repo::todo_repository::TodoRepository; +use repo::user_repository::UserRepository; -async fn index(method: Method) -> HttpResponse { +async fn index(id: Option, method: Method) -> impl Responder { match method { - Method::GET => HttpResponse::Ok() - .content_type(ContentType::plaintext()) - .body(format!("Welcome")), + Method::GET => match id { + Some(id) => HttpResponse::Ok() + .content_type(ContentType::plaintext()) + .body(format!( + "You are logged in. Welcome! ({:?})", + id.id().unwrap() + )), + None => HttpResponse::Unauthorized().finish(), + }, _ => HttpResponse::Forbidden().finish(), } } -#[get("/counter")] -async fn get_counter(req: HttpRequest, session: Session) -> actix_web::Result { - // log::info!("Request: {req:?}"); +#[get("/user")] +async fn get_user(id: Identity, repo: web::Data) -> impl Responder { + match repo.read_all() { + Ok(users) => { + log::info!("{users:?}"); + HttpResponse::Ok().body(format!("{users:?}")) + } + Err(err) => { + log::error!("Could not read from user repo: {err}"); + HttpResponse::InternalServerError() + .body(format!("Could not read from user repo: {err}")) + } + } +} - let mut counter = 1; - if let Some(count) = session.get::("counter")? { - log::info!("Session counter: {count}"); - counter = count + 1; +// TODO: Guard for login +#[put("/user")] +async fn put_user( + id: Identity, + payload: web::Json, + session: Session, + repo: web::Data, +) -> impl Responder { + log::debug!("Received {payload:?}"); + match repo.update(&payload.0) { + Ok(_) => HttpResponse::Ok().finish(), + Err(_) => { + let msg = format!("No user with that id: {payload:?}"); + log::debug!("{}", msg); + HttpResponse::InternalServerError().body(msg) + } } +} + +// TODO: For a real app, impl smth like a registration-secret or email verification +#[post("/user")] +async fn post_user(payload: web::Json, repo: web::Data) -> impl Responder { + log::debug!("Received {payload:?}"); + let user = User::new(payload.login(), payload.hash(), payload.salt()); + match repo.create(&user) { + Ok(_) => { + log::debug!("Successfully created {user:?}"); + HttpResponse::Created().finish() + } + Err(err) => { + log::debug!("{err}"); + HttpResponse::BadRequest().body(format!("{user:?}")) + } + } +} - session.insert("counter", counter)?; +#[post("/login")] +async fn post_login( + req: HttpRequest, + payload: web::Json, + repo: web::Data, + session: Session, +) -> impl Responder { + log::debug!("Received {payload:?}"); + match repo.read(&payload.id()) { + Ok(Some(user)) => { + if payload.salt() == "" { + log::debug!("Initial login request with empty salt: {payload:?}"); + HttpResponse::Ok().json(format!("{{ 'salt': '{}' }}", user.salt())) + } else if payload.hash() == user.hash() { + log::debug!("User successfully logged in: {payload:?} == {user:?}"); + // TODO: Mayb handle more gracefully + Identity::login(&req.extensions(), format!("{}", payload.id())) + .expect("Log the user in"); - Ok(HttpResponse::Ok().finish()) + // TODO: Mayb handle more gracefully + session + .insert("user", payload.0) + .expect("Insert user into session"); + HttpResponse::Ok().finish() + } else { + log::debug!("Wrong password hash for user: {payload:?} != {user:?}"); + HttpResponse::Unauthorized().finish() + } + } + Ok(None) => { + log::debug!("User not found: {payload:?}"); + HttpResponse::Unauthorized().finish() + } + Err(_) => { + let msg = format!("Could not create user: {payload:?}"); + log::debug!("{}", msg); + HttpResponse::InternalServerError().body(msg) + } + } +} + +#[delete("/logout")] +async fn delete_logout(id: Identity) -> impl Responder { + id.logout(); + HttpResponse::Ok() } #[actix_web::main] async fn main() -> io::Result<()> { - env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + env_logger::init_from_env(env_logger::Env::new().default_filter_or("debug")); - let user = User::new( - "phga".to_string(), - "onetuhoneuth".to_string(), - "salt".to_string(), - ); + let user = User::new("admin", "init_pw_hash", "init_salt"); log::info!("{user:#?}"); let todo = Todo::new( user.id().clone(), - "Mein todo".to_string(), - "Es hat viele Aufgaben".to_string(), + "Mein todo", + "Es hat viele Aufgaben", Priority::Normal, Status::Todo, ); log::info!("{todo:#?}"); - // Create a better session key for production - // This one is only 0's -> 64 byte "random" string - let key: &[u8] = &[0; 64]; - let key = actix_web::cookie::Key::from(key); + + let cassandra_session = Arc::new(repo::init()); + + let user_repo = web::Data::new(UserRepository::new(Arc::clone(&cassandra_session))); + if let Err(err) = user_repo.create(&user) { + log::debug!("Default user already exists: {err}"); + } + let key = Key::generate(); log::info!("Starting HTTP server: http://127.0.0.1:6969"); HttpServer::new(move || { App::new() .wrap(middleware::Compress::default()) + .wrap(IdentityMiddleware::default()) .wrap( SessionMiddleware::builder(CookieSessionStore::default(), key.clone()) .cookie_secure(false) + // Session lifetime + .session_lifecycle(PersistentSession::default().session_ttl(Duration::days(7))) .build(), ) .wrap(middleware::Logger::default()) - .service(get_counter) + .app_data(user_repo.clone()) + // .service(get_counter) + .service(get_user) + .service(put_user) + .service(post_user) + .service(post_login) + .service(delete_logout) .default_service(web::to(index)) }) .bind(("127.0.0.1", 6969))? diff --git a/rust_solid_cassandra/backend/src/model/todo.rs b/rust_solid_cassandra/backend/src/model/todo.rs index f315547..6e320e3 100644 --- a/rust_solid_cassandra/backend/src/model/todo.rs +++ b/rust_solid_cassandra/backend/src/model/todo.rs @@ -27,16 +27,16 @@ pub struct Todo { impl Todo { pub fn new( user_id: Uuid, - title: String, - description: String, + title: &str, + description: &str, priority: Priority, status: Status, ) -> Todo { Todo { id: Uuid::new_v4(), user_id, - title, - description, + title: String::from(title), + description: String::from(description), priority, status, } diff --git a/rust_solid_cassandra/backend/src/model/user.rs b/rust_solid_cassandra/backend/src/model/user.rs index f7d26c2..1b8df87 100644 --- a/rust_solid_cassandra/backend/src/model/user.rs +++ b/rust_solid_cassandra/backend/src/model/user.rs @@ -1,6 +1,7 @@ +use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug)] +#[derive(Debug, Deserialize, Serialize)] pub struct User { id: Uuid, login: String, @@ -9,15 +10,20 @@ pub struct User { } impl User { - pub fn new(login: String, hash: String, salt: String) -> User { + pub fn new(login: &str, hash: &str, salt: &str) -> User { User { id: Uuid::new_v4(), - login, - hash, - salt, + login: String::from(login), + hash: String::from(hash), + salt: String::from(salt), } } + pub fn from_json(json: &str) -> User { + log::debug!("{json}"); + serde_json::from_str::(json).expect("Deserialized User object") + } + pub fn id(&self) -> &Uuid { &self.id } diff --git a/rust_solid_cassandra/backend/src/repo.rs b/rust_solid_cassandra/backend/src/repo.rs index f30d801..b597066 100644 --- a/rust_solid_cassandra/backend/src/repo.rs +++ b/rust_solid_cassandra/backend/src/repo.rs @@ -1,17 +1,27 @@ -use cassandra_cpp::{Cluster, Session, stmt}; +use std::{env, thread::sleep, time::Duration}; + +use cassandra_cpp::{stmt, Cluster, Session}; pub mod todo_repository; pub mod user_repository; -// Ideally read this from config -const KEYSPACE_NAME: &str = "rust_solid_cassandra"; +static DEFAULT_KEYSPACE_NAME: &str = "rust_solidjs_cassandra"; + +pub fn init() -> Session { + let keyspace_name = env::var("KEYSPACE_NAME").unwrap_or(DEFAULT_KEYSPACE_NAME.to_string()); + // Definitely set it so other modules can use it + env::set_var("KEYSPACE_NAME", &keyspace_name); -fn init() -> Session { let mut cluster = Cluster::default(); cluster.set_contact_points("127.0.0.1").unwrap(); // Panic if not successful - let session = cluster.connect().unwrap(); // Session is used to exec queries + let session = loop { + match cluster.connect() { + Ok(session) => break session, + Err(_) => sleep(Duration::new(1, 0)), + } + }; let query = stmt!(&format!( - "CREATE KEYSPACE IF NOT EXISTS {KEYSPACE_NAME} + "CREATE KEYSPACE IF NOT EXISTS {keyspace_name} WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': 1}};" )); @@ -22,56 +32,3 @@ fn init() -> Session { session } - -#[cfg(test)] -mod tests { - use cassandra_cpp::{stmt, Cluster}; - - use crate::model::user::User; - - use super::*; - #[test] - fn init_user_repo() { - let session = init(); - let ur = user_repository::UserRepository::new(&session); - } - - #[test] - fn user_repo_create() { - let session = init(); - let ur = user_repository::UserRepository::new(&session); - let u = User::new("phga".to_string(), "1337".to_string(), "salzig".to_string()); - if let Err(err) = ur.create(&u) { - panic!("Creating a user failed {err}"); - } - } - - #[test] - fn user_repo_read_all() { - let session = init(); - let ur = user_repository::UserRepository::new(&session); - match ur.read_all() { - Ok(rows) => println!("{rows}"), - Err(err) => panic!("Reading from user table failed: {err}"), - }; - } - - #[test] - fn initialize_database() { - let create_query = stmt!( - "CREATE KEYSPACE IF NOT EXISTS test1337 - WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};" - ); - let check_query = stmt!( - "SELECT keyspace_name FROM system_schema.keyspaces - WHERE keyspace_name = 'test1337';" - ); - let mut cluster = Cluster::default(); - cluster.set_contact_points("127.0.0.1").unwrap(); - let session = cluster.connect().unwrap(); // Session is used to exec queries - let result = session.execute(&create_query).wait().unwrap(); - println!("CREATE: {}", result); - let result = session.execute(&check_query).wait().unwrap(); - println!("CHECK: {}", result); - } -} diff --git a/rust_solid_cassandra/backend/src/repo/user_repository.rs b/rust_solid_cassandra/backend/src/repo/user_repository.rs index c41c500..9dd3b59 100644 --- a/rust_solid_cassandra/backend/src/repo/user_repository.rs +++ b/rust_solid_cassandra/backend/src/repo/user_repository.rs @@ -1,18 +1,19 @@ -use std::fmt::Error; +use std::{env, sync::Arc}; -use cassandra_cpp::{stmt, CassResult, Session}; +use cassandra_cpp::{stmt, ErrorKind, Result, Session}; use crate::model::user::User; -use super::KEYSPACE_NAME; -pub struct UserRepository<'a> { - session: &'a Session, +pub struct UserRepository { + session: Arc, table: String, } -impl<'a> UserRepository<'a> { - pub fn new(session: &'a Session) -> UserRepository<'a> { - let table = format!("{KEYSPACE_NAME}.user"); +impl UserRepository { + pub fn new(session: Arc) -> UserRepository { + let keyspace_name = + env::var("KEYSPACE_NAME").expect("Value should be definitely set in init"); + let table = format!("{keyspace_name}.user"); let query = stmt!(&format!( "CREATE TABLE IF NOT EXISTS {table} ( id uuid, @@ -28,14 +29,29 @@ impl<'a> UserRepository<'a> { .wait() .expect("Should create user keyspace if not exists"); + let query = stmt!(&format!("CREATE INDEX IF NOT EXISTS ON {table} (login);")); + + session + .execute(&query) + .wait() + .expect("Should create secondary index for login column"); + UserRepository { session, table } } - pub fn create(&self, user: &User) -> Result<(), cassandra_cpp::Error> { + pub fn create(&self, user: &User) -> Result<()> { + if let Some(u) = self.read_by_login(user.login())? { + return Err(cassandra_cpp::Error::from(ErrorKind::Msg(format!( + "Creation of {u:?} failed. User already exists." + )))); + } + let mut query = stmt!(&format!( "INSERT INTO {} (id, login, hash, salt) - VALUES (?, ?, ?, ?);", self.table + VALUES (?, ?, ?, ?);", + self.table )); + let uuid = cassandra_cpp::Uuid::from(user.id().clone()); query.bind_uuid(0, uuid).expect("Binds the id"); query.bind_string(1, user.login()).expect("Binds the login"); @@ -45,9 +61,63 @@ impl<'a> UserRepository<'a> { Ok(()) } - pub fn read_all(&self) -> Result { - let query = stmt!(&format!("SELECT * FROM {};", self.table)); - let res = self.session.execute(&query).wait()?; - Ok(res) + pub fn update(&self, user: &User) -> Result<()> { + match self.read(&user.id())? { + Some(u) => { + log::info!("Modifying {u:?} to represent {user:?}"); + + let mut query = stmt!(&format!( + "UPDATE {} SET login = ?, hash = ?, salt = ? + WHERE id = ?;", + self.table + )); + + let uuid = cassandra_cpp::Uuid::from(user.id().clone()); + query.bind_string(0, user.login()).expect("Binds the login"); + query.bind_string(1, user.hash()).expect("Binds the hash"); + query.bind_string(2, user.salt()).expect("Binds the salt"); + query.bind_uuid(3, uuid).expect("Binds the id"); + self.session.execute(&query).wait()?; + Ok(()) + } + None => Err(cassandra_cpp::Error::from(ErrorKind::Msg(format!( + "User {user:?} does not exist in the database" + )))), + } + } + + fn read_by_login(&self, login: &str) -> Result> { + let mut query = stmt!(&format!( + "SELECT JSON * FROM {} WHERE login = ?", + self.table + )); + query.bind_string(0, login).expect("Binds login"); + Ok(match self.session.execute(&query).wait()?.first_row() { + Some(row) => Some(User::from_json(&format!("{row}"))), + None => None, + }) + } + + pub fn read(&self, id: &uuid::Uuid) -> Result> { + let uuid = cassandra_cpp::Uuid::from(id.clone()); + // There is no OR in cassandra statements + let mut query = stmt!(&format!("SELECT JSON * FROM {} WHERE id = ?", self.table)); + query.bind_uuid(0, uuid).expect("Binds login"); + + Ok(match self.session.execute(&query).wait()?.first_row() { + Some(row) => Some(User::from_json(&format!("{row}"))), + None => None, + }) + } + + pub fn read_all(&self) -> Result> { + let query = stmt!(&format!("SELECT JSON * FROM {};", self.table)); + Ok(self + .session + .execute(&query) + .wait()? + .iter() + .map(|json| User::from_json(&format!("{json}"))) + .collect()) } }