diff --git a/api/src/guards/request/auth/admin.rs b/api/src/guards/request/auth/admin.rs new file mode 100644 index 0000000..b22a145 --- /dev/null +++ b/api/src/guards/request/auth/admin.rs @@ -0,0 +1,40 @@ +use entity::candidate::Model as Admin; +use portfolio_core::sea_orm::prelude::Uuid; +use portfolio_core::services::admin_service::AdminService; +use rocket::http::Status; +use rocket::outcome::Outcome; +use rocket::request::{FromRequest, Request}; + +use crate::pool::Db; + +pub struct AdminAuth(Admin); + +impl Into for AdminAuth { + fn into(self) -> Admin { + self.0 + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AdminAuth { + type Error = Option; + async fn from_request(req: &'r Request<'_>) -> Outcome { + let session_id = req.cookies().get("id").unwrap().name_value().1; + let conn = &req.rocket().state::().unwrap().conn; + + let uuid = match Uuid::parse_str(&session_id) { + Ok(uuid) => uuid, + Err(_) => return Outcome::Failure((Status::BadRequest, None)), + }; + + let session = AdminService::auth(conn, uuid).await; + + match session { + Ok(model) => Outcome::Success(AdminAuth(model)), + Err(e) => Outcome::Failure( + (Status::from_code(e.code()).unwrap_or(Status::InternalServerError), None) + ), + } + + } +} \ No newline at end of file diff --git a/api/src/guards/request/auth/candidate.rs b/api/src/guards/request/auth/candidate.rs new file mode 100644 index 0000000..5b3200c --- /dev/null +++ b/api/src/guards/request/auth/candidate.rs @@ -0,0 +1,38 @@ +use entity::candidate::Model as Candidate; +use portfolio_core::sea_orm::prelude::Uuid; +use portfolio_core::services::candidate_service::CandidateService; +use rocket::http::Status; +use rocket::outcome::Outcome; +use rocket::request::{FromRequest, Request}; + +use crate::pool::Db; + +pub struct CandidateAuth(Candidate); + +impl Into for CandidateAuth { + fn into(self) -> Candidate { + self.0 + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for CandidateAuth { + type Error = Option; + async fn from_request(req: &'r Request<'_>) -> Outcome { + let session_id = req.cookies().get("id").unwrap().name_value().1; + let conn = &req.rocket().state::().unwrap().conn; + + let uuid = match Uuid::parse_str(&session_id) { + Ok(uuid) => uuid, + Err(_) => return Outcome::Failure((Status::BadRequest, None)), + }; + + let session = CandidateService::auth(conn, uuid).await; + + match session { + Ok(model) => Outcome::Success(CandidateAuth(model)), + Err(_) => Outcome::Failure((Status::Unauthorized, None)), + } + + } +} \ No newline at end of file diff --git a/api/src/guards/request/auth/mod.rs b/api/src/guards/request/auth/mod.rs new file mode 100644 index 0000000..e4cf6a5 --- /dev/null +++ b/api/src/guards/request/auth/mod.rs @@ -0,0 +1,5 @@ +pub mod admin; +pub mod candidate; + +pub use admin::*; +pub use candidate::*; diff --git a/api/src/guards/request/mod.rs b/api/src/guards/request/mod.rs index 066d9a7..5696e21 100644 --- a/api/src/guards/request/mod.rs +++ b/api/src/guards/request/mod.rs @@ -1 +1 @@ -pub mod session_auth; \ No newline at end of file +pub mod auth; \ No newline at end of file diff --git a/api/src/guards/request/session_auth.rs b/api/src/guards/request/session_auth.rs index 273c360..44063d4 100644 --- a/api/src/guards/request/session_auth.rs +++ b/api/src/guards/request/session_auth.rs @@ -1,5 +1,6 @@ use entity::candidate::Model as Candidate; use portfolio_core::sea_orm::prelude::Uuid; +use portfolio_core::services::admin_service::AdminService; use portfolio_core::services::candidate_service::CandidateService; use rocket::http::Status; use rocket::outcome::Outcome; @@ -7,18 +8,18 @@ use rocket::request::{FromRequest, Request}; use crate::pool::Db; -pub struct SessionAuth(Candidate); +pub struct CandidateAuth(Candidate); -impl Into for SessionAuth { +impl Into for CandidateAuth { fn into(self) -> Candidate { self.0 } } #[rocket::async_trait] -impl<'r> FromRequest<'r> for SessionAuth { +impl<'r> FromRequest<'r> for CandidateAuth { type Error = Option; - async fn from_request(req: &'r Request<'_>) -> Outcome { + async fn from_request(req: &'r Request<'_>) -> Outcome { let session_id = req.cookies().get("id").unwrap().name_value().1; let conn = &req.rocket().state::().unwrap().conn; @@ -27,12 +28,44 @@ impl<'r> FromRequest<'r> for SessionAuth { Err(_) => return Outcome::Failure((Status::BadRequest, None)), }; - let session = CandidateService::auth_user_session(conn, uuid).await; + let session = CandidateService::auth(conn, uuid).await; match session { - Ok(model) => Outcome::Success(SessionAuth(model)), + Ok(model) => Outcome::Success(CandidateAuth(model)), Err(_) => Outcome::Failure((Status::Unauthorized, None)), } } } + +pub struct AdminAuth(Candidate); + +impl Into for AdminAuth { + fn into(self) -> Candidate { + self.0 + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AdminAuth { + type Error = Option; + async fn from_request(req: &'r Request<'_>) -> Outcome { + let session_id = req.cookies().get("id").unwrap().name_value().1; + let conn = &req.rocket().state::().unwrap().conn; + + let uuid = match Uuid::parse_str(&session_id) { + Ok(uuid) => uuid, + Err(_) => return Outcome::Failure((Status::BadRequest, None)), + }; + + let session = AdminService::auth(conn, uuid).await; + + match session { + Ok(model) => Outcome::Success(AdminAuth(model)), + Err(e) => Outcome::Failure( + (Status::from_code(e.code()).unwrap_or(Status::InternalServerError), None) + ), + } + + } +} \ No newline at end of file diff --git a/api/src/lib.rs b/api/src/lib.rs index 47e615a..14f5ebb 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -3,8 +3,7 @@ extern crate rocket; use std::net::SocketAddr; -use guards::request::session_auth::SessionAuth; -use portfolio_core::error::ServiceError; +use guards::request::auth::{CandidateAuth, AdminAuth}; use portfolio_core::services::candidate_service::CandidateService; use requests::{LoginRequest, RegisterRequest}; use rocket::http::Status; @@ -12,7 +11,6 @@ use rocket::{Rocket, Build}; use rocket::serde::json::Json; use rocket::fairing::{self, AdHoc}; use rocket::response::status::Custom; -use portfolio_core::{Mutation}; use migration::{MigratorTrait}; use sea_orm_rocket::{Connection, Database}; @@ -30,10 +28,6 @@ pub use entity::candidate::Entity as Candidate; use portfolio_core::crypto::random_8_char_string; -fn custom_err_from_service_err(service_err: ServiceError) -> Custom { - Custom(Status::from_code(service_err.0.code).unwrap_or_default(), service_err.1.to_string()) -} - #[post("/", data = "")] async fn create(conn: Connection<'_, Db>, post_form: Json) -> Result> { let db = conn.into_inner(); @@ -41,39 +35,42 @@ async fn create(conn: Connection<'_, Db>, post_form: Json) -> R let plain_text_password = random_8_char_string(); - Mutation::create_candidate(db, form.application_id, &plain_text_password, form.personal_id_number) - .await - .expect("Could not insert candidate"); + let candidate = CandidateService::create(db, form.application_id, &plain_text_password, form.personal_id_number) + .await; + + if candidate.is_err() { // TODO cleanup + let e = candidate.err().unwrap(); + return Err(Custom(Status::from_code(e.code()).unwrap_or_default(), e.message())); + } - Ok(plain_text_password) + Ok(plain_text_password) } #[get("/whoami")] -async fn validate(session: SessionAuth) -> Result> { +async fn validate(session: CandidateAuth) -> Result> { let candidate: entity::candidate::Model = session.into(); Ok(candidate.application.to_string()) } +#[get("/admin")] +async fn admin(session: AdminAuth) -> Result> { + Ok("Hello admin".to_string()) +} + #[post("/login", data = "")] async fn login(conn: Connection<'_, Db>, login_form: Json, ip_addr: SocketAddr) -> Result> { let db = conn.into_inner(); println!("{} {}", login_form.application_id, login_form.password); - let session_token = CandidateService::new_session(db, - login_form.application_id, - login_form.password.to_string(), - ip_addr.ip().to_string() - ).await; + let session_token = CandidateService::login( + db, + login_form.application_id, + login_form.password.to_string(), + ip_addr.ip().to_string() + ) + .await; - if session_token.is_ok() { - return Ok( - session_token.ok().unwrap() - ); - } else { - return Err( - custom_err_from_service_err(session_token.err().unwrap()) - ) - } + session_token.map_err(|e| Custom(Status::from_code(e.code()).unwrap_or_default(), e.message())) } #[get("/hello")] @@ -93,7 +90,7 @@ async fn start() -> Result<(), rocket::Error> { .attach(Db::init()) .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) //.mount("/", FileServer::from(relative!("/static"))) - .mount("/", routes![create, login, hello, validate]) + .mount("/", routes![create, login, hello, validate, admin]) .register("/", catchers![]) .launch() .await diff --git a/core/src/database/mutation/candidate.rs b/core/src/database/mutation/candidate.rs index 55d870c..f6b80f0 100644 --- a/core/src/database/mutation/candidate.rs +++ b/core/src/database/mutation/candidate.rs @@ -1,27 +1,17 @@ use crate::Mutation; -use std::vec; use ::entity::candidate; use sea_orm::{*}; -use crate::{crypto::{hash_password, self}}; impl Mutation { pub async fn create_candidate( db: &DbConn, application_id: i32, - plain_text_password: &String, - personal_id_number: String, + hashed_password: String, + encrypted_personal_id_number: String, + pubkey: String, + encrypted_priv_key: String ) -> Result { - // TODO: unwrap pro testing.. - let hashed_password = hash_password(plain_text_password.to_string()).await.unwrap(); - let (pubkey, priv_key_plain_text) = crypto::create_identity(); - let encrypted_priv_key = crypto::encrypt_password(priv_key_plain_text, plain_text_password.to_string()).await.unwrap(); - - let encrypted_personal_id_number = crypto::encrypt_password_with_recipients( - &personal_id_number, vec![&pubkey] - ).await.unwrap(); - - candidate::ActiveModel { application: Set(application_id), personal_identification_number: Set(encrypted_personal_id_number), @@ -35,49 +25,4 @@ impl Mutation { .insert(db) .await } -} - - -#[cfg(test)] -mod tests { - use sea_orm::{Database, DbConn}; - - use crate::{Mutation, crypto}; - - #[cfg(test)] - async fn get_memory_sqlite_connection() -> DbConn { - use entity::candidate; - use sea_orm::{DbBackend, sea_query::TableCreateStatement, ConnectionTrait}; - use sea_orm::Schema; - - - let base_url = "sqlite::memory:"; - let db: DbConn = Database::connect(base_url).await.unwrap(); - - let schema = Schema::new(DbBackend::Sqlite); - let stmt: TableCreateStatement = schema.create_table_from_entity(candidate::Entity); - db.execute(db.get_database_backend().build(&stmt)).await.unwrap(); - db - } - - #[tokio::test] - async fn test_encrypt_decrypt_private_key_with_passphrase() { - let db = get_memory_sqlite_connection().await; - - let plain_text_password = "test".to_string(); - - let secret_message = "trnka".to_string(); - - - let candidate = Mutation::create_candidate(&db, 5555555, &plain_text_password, "".to_string()).await.unwrap(); - - let encrypted_message = crypto::encrypt_password_with_recipients(&secret_message, vec![&candidate.public_key]).await.unwrap(); - - let private_key_plain_text = crypto::decrypt_password(candidate.private_key, plain_text_password).await.unwrap(); - - let decrypted_message = crypto::decrypt_password_with_private_key(&encrypted_message, &private_key_plain_text).await.unwrap(); - - assert_eq!(secret_message, decrypted_message); - - } } \ No newline at end of file diff --git a/core/src/error.rs b/core/src/error.rs index d8afda9..1923f34 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -1,24 +1,45 @@ -pub struct Status { - pub code: u16, +pub enum ServiceError { + InvalidCredentials, + Forbidden, + ExpiredSession, + JwtError, + UserNotFound, + DbError, + UserNotFoundByJwtId, + UserNotFoundBySessionId, } -pub const INVALID_CREDENTIALS_ERROR: ServiceError = ServiceError(Status { code: 401 }, - "Invalid credentials"); -pub const EXPIRED_SESSION_ERROR: ServiceError = ServiceError(Status { code: 401 }, - "Session expired, please login again"); +impl ServiceError { + fn code_and_message(&self) -> (u16, String) { + match self { + ServiceError::InvalidCredentials => (401, "Invalid credentials".to_string()), + ServiceError::Forbidden => (403, "Forbidden".to_string()), + ServiceError::ExpiredSession => (401, "Session expired, please login again".to_string()), + ServiceError::JwtError => (500, "Error while encoding JWT".to_string()), + ServiceError::UserNotFound => (404, "User not found".to_string()), + ServiceError::DbError => (500, "Database error".to_string()), + ServiceError::UserNotFoundByJwtId => (500, "User not found, please contact technical support".to_string()), + ServiceError::UserNotFoundBySessionId => (500, "User not found, please contact technical support".to_string()), + } + } -pub const JWT_ERROR: ServiceError = ServiceError(Status { code: 500 }, - "Error while encoding JWT"); + pub fn code(&self) -> u16 { + self.code_and_message().0 + } -pub const USER_NOT_FOUND_ERROR: ServiceError = ServiceError(Status { code: 404 }, - "User not found"); + pub fn message(&self) -> String { + self.code_and_message().1 + } +} -pub const DB_ERROR: ServiceError = ServiceError(Status { code: 500 }, - "Database error"); +impl std::fmt::Debug for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ServiceError {{ code: {}, message: {} }}", self.code(), self.message()) + } +} -pub const USER_NOT_FOUND_BY_JWT_ID: ServiceError = ServiceError(Status { code: 500 }, // User got somehow deleted - "User not found, please contact technical support"); // Shouldn't ever happen - -pub const USER_NOT_FOUND_BY_SESSION_ID: ServiceError = ServiceError(Status { code: 500 }, // User got somehow deleted - "User not found, please contact technical support"); // Shouldn't ever happen -pub struct ServiceError<'a>(pub Status, pub &'a str); \ No newline at end of file +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ServiceError {{ code: {}, message: {} }}", self.code(), self.message()) + } +} \ No newline at end of file diff --git a/core/src/services/admin_service.rs b/core/src/services/admin_service.rs new file mode 100644 index 0000000..fdadbe4 --- /dev/null +++ b/core/src/services/admin_service.rs @@ -0,0 +1,35 @@ +use entity::candidate; +use sea_orm::{DbConn, prelude::Uuid}; + +use crate::error::ServiceError; + +use super::session_service::SessionService; + +pub struct AdminService; + +impl AdminService { + pub async fn login( + db: &DbConn, + user_id: i32, + password: String, + ip_addr: String + ) -> Result { + SessionService::new_session(db, user_id, password, ip_addr).await + } + + pub async fn auth( + db: &DbConn, + session_uuid: Uuid, + ) -> Result { + match SessionService::auth_user_session(db, session_uuid).await { + Ok(user) => { + if user.is_admin { + Ok(user) + } else { + Err(ServiceError::Forbidden) + } + }, + Err(e) => Err(e) + } + } +} \ No newline at end of file diff --git a/core/src/services/candidate_service.rs b/core/src/services/candidate_service.rs index 3058a4a..f789418 100644 --- a/core/src/services/candidate_service.rs +++ b/core/src/services/candidate_service.rs @@ -1,162 +1,98 @@ -use std::cmp::min; - use entity::candidate; -use sea_orm::{DatabaseConnection, prelude::Uuid, ModelTrait}; +use sea_orm::{DbConn, prelude::Uuid}; -use crate::{crypto::{self}, Query, error::{ServiceError, USER_NOT_FOUND_ERROR, INVALID_CREDENTIALS_ERROR, DB_ERROR, USER_NOT_FOUND_BY_JWT_ID, USER_NOT_FOUND_BY_SESSION_ID, EXPIRED_SESSION_ERROR}, Mutation}; +use crate::{Mutation, crypto::{hash_password, self}, error::{ServiceError}}; + +use super::session_service::SessionService; pub struct CandidateService; impl CandidateService { - /// Delete n old sessions for user - async fn delete_old_sessions(db: &DatabaseConnection, user_id: i32, keep_n_recent: usize) -> Result<(), ServiceError> { - let mut sessions = Query::find_sessions_by_user_id(db, user_id).await.unwrap(); - - sessions.sort_by_key(|s| s.created_at); + pub async fn create( + db: &DbConn, + application_id: i32, + plain_text_password: &String, + personal_id_number: String + ) -> Result{ + // TODO: unwrap pro testing.. + let hashed_password = hash_password(plain_text_password.to_string()).await.unwrap(); + let (pubkey, priv_key_plain_text) = crypto::create_identity(); + let encrypted_priv_key = crypto::encrypt_password(priv_key_plain_text, plain_text_password.to_string()).await.unwrap(); - - for session in sessions.iter().take(sessions.len() - min(sessions.len(), keep_n_recent)) { - Mutation::delete_session(db, session.id).await.unwrap(); - } + let encrypted_personal_id_number = crypto::encrypt_password_with_recipients( + &personal_id_number, vec![&pubkey] + ).await.unwrap(); - Ok(()) + Mutation::create_candidate( + db, + application_id, + hashed_password, + encrypted_personal_id_number, + pubkey, + encrypted_priv_key + ) + .await + .map_err(|_| ServiceError::DbError) } - /// Authenticate user by application id and password and generate a new session - pub async fn new_session(db: &DatabaseConnection, user_id: i32, password: String, ip_addr: String) -> Result { - let candidate = match Query::find_candidate_by_id(db, user_id).await { - Ok(candidate) => match candidate { - Some(candidate) => candidate, - None => return Err(USER_NOT_FOUND_ERROR) - }, - Err(_) => {return Err(DB_ERROR)} - }; - - // compare passwords - match crypto::verify_password(password,candidate.code.clone()).await { - Ok(valid) => { - if !valid { - return Err(INVALID_CREDENTIALS_ERROR) - } - }, - Err(_) => {return Err(INVALID_CREDENTIALS_ERROR)} - } - - - // user is authenticated, generate a new session - let random_uuid: Uuid = Uuid::new_v4(); - - let session = match Mutation::insert_session(db, user_id, random_uuid, ip_addr).await { - Ok(session) => session, - Err(_) => return Err(DB_ERROR) - }; - - // delete old sessions - CandidateService::delete_old_sessions(db, candidate.application, 3).await.ok(); // TODO move to dotenv - - Ok(session.id.to_string()) + pub async fn login( + db: &DbConn, + user_id: i32, + password: String, + ip_addr: String + ) -> Result { + SessionService::new_session(db, user_id, password, ip_addr).await } - /// Authenticate user by session id - /// Return user model if session is valid - pub async fn auth_user_session(db: &DatabaseConnection, uuid: Uuid) -> Result { - let session = match Query::find_session_by_uuid(db, uuid).await { - Ok(session) => match session { - Some(session) => session, - None => return Err(USER_NOT_FOUND_BY_SESSION_ID) - }, - Err(_) => {return Err(DB_ERROR)} - }; - - let now = chrono::Utc::now().naive_utc(); - // check if session is expired - if now > session.expires_at { - // delete session - Mutation::delete_session(db, session.id).await.unwrap(); - return Err(EXPIRED_SESSION_ERROR) - } - - let candidate = match session.find_related(candidate::Entity).one(db).await { - Ok(candidate) => match candidate { - Some(candidate) => candidate, - None => return Err(USER_NOT_FOUND_BY_JWT_ID) - }, - Err(_) => {return Err(DB_ERROR)} - }; - - Ok(candidate) + pub async fn auth( + db: &DbConn, + session_uuid: Uuid, + ) -> Result { + SessionService::auth_user_session(db, session_uuid).await } } - #[cfg(test)] mod tests { - use entity::candidate; - use sea_orm::{DbConn, Database, sea_query::TableCreateStatement, DbBackend, Schema, ConnectionTrait, prelude::Uuid}; + use sea_orm::{Database, DbConn}; - use crate::{crypto, Mutation, services::candidate_service::CandidateService}; + use crate::{crypto, services::candidate_service::CandidateService}; #[cfg(test)] async fn get_memory_sqlite_connection() -> DbConn { - use entity::session; + use entity::candidate; + use sea_orm::{DbBackend, sea_query::TableCreateStatement, ConnectionTrait}; + use sea_orm::Schema; + let base_url = "sqlite::memory:"; let db: DbConn = Database::connect(base_url).await.unwrap(); let schema = Schema::new(DbBackend::Sqlite); let stmt: TableCreateStatement = schema.create_table_from_entity(candidate::Entity); - let stmt2: TableCreateStatement = schema.create_table_from_entity(session::Entity); db.execute(db.get_database_backend().build(&stmt)).await.unwrap(); - db.execute(db.get_database_backend().build(&stmt2)).await.unwrap(); db } - - #[tokio::test] - async fn test_create_candidate() { - const SECRET: &str = "Tajny_kod"; + #[tokio::test] + async fn test_encrypt_decrypt_private_key_with_passphrase() { let db = get_memory_sqlite_connection().await; - - let candidate = Mutation::create_candidate(&db, 5555555, &SECRET.to_string(), "".to_string()).await.unwrap(); - - assert_eq!(candidate.application, 5555555); - assert_ne!(candidate.code, SECRET.to_string()); - assert!(crypto::verify_password(SECRET.to_string(), candidate.code).await.ok().unwrap()); - } - #[tokio::test] - async fn test_candidate_session_correct_password() { - let db = &get_memory_sqlite_connection().await; + let plain_text_password = "test".to_string(); - Mutation::create_candidate(&db, 5555555, &"Tajny_kod".to_string(), "".to_string()).await.unwrap(); + let secret_message = "trnka".to_string(); - // correct password - let session = CandidateService::new_session( - db, - 5555555, - "Tajny_kod".to_string(), - "127.0.0.1".to_string(), - ) - .await.ok().unwrap(); - // println!("{}", session.err().unwrap().1); + + let candidate = CandidateService::create(&db, 5555555, &plain_text_password, "".to_string()).await.ok().unwrap(); - assert!( - CandidateService::auth_user_session(db, Uuid::parse_str(&session).unwrap()) - .await - .is_ok() - ); - } + let encrypted_message = crypto::encrypt_password_with_recipients(&secret_message, vec![&candidate.public_key]).await.unwrap(); - #[tokio::test] - async fn test_candidate_session_incorrect_password() { - let db = &get_memory_sqlite_connection().await; + let private_key_plain_text = crypto::decrypt_password(candidate.private_key, plain_text_password).await.unwrap(); - let candidate_form = Mutation::create_candidate(&db, 5555555, &"Tajny_kod".to_string(), "".to_string()).await.unwrap(); + let decrypted_message = crypto::decrypt_password_with_private_key(&encrypted_message, &private_key_plain_text).await.unwrap(); + + assert_eq!(secret_message, decrypted_message); - // incorrect password - assert!( - CandidateService::new_session(db, candidate_form.application, "Spatny_kod".to_string(), "127.0.0.1".to_string()).await.is_err() - ); } } \ No newline at end of file diff --git a/core/src/services/mod.rs b/core/src/services/mod.rs index 1992ce2..d6cd137 100644 --- a/core/src/services/mod.rs +++ b/core/src/services/mod.rs @@ -1 +1,3 @@ -pub mod candidate_service; \ No newline at end of file +pub mod session_service; +pub mod candidate_service; +pub mod admin_service; \ No newline at end of file diff --git a/core/src/services/session_service.rs b/core/src/services/session_service.rs new file mode 100644 index 0000000..b8ef57a --- /dev/null +++ b/core/src/services/session_service.rs @@ -0,0 +1,163 @@ +use std::cmp::min; + +use entity::candidate; +use sea_orm::{DatabaseConnection, prelude::Uuid, ModelTrait}; + +use crate::{crypto::{self}, Query, error::{ServiceError}, Mutation}; + +// TODO: generics +pub(in crate::services) struct SessionService; + +impl SessionService { + /// Delete n old sessions for user + async fn delete_old_sessions(db: &DatabaseConnection, user_id: i32, keep_n_recent: usize) -> Result<(), ServiceError> { + let mut sessions = Query::find_sessions_by_user_id(db, user_id).await.unwrap(); + + sessions.sort_by_key(|s| s.created_at); + + + for session in sessions.iter().take(sessions.len() - min(sessions.len(), keep_n_recent)) { + Mutation::delete_session(db, session.id).await.unwrap(); + } + + Ok(()) + } + + /// Authenticate user by application id and password and generate a new session + pub async fn new_session(db: &DatabaseConnection, user_id: i32, password: String, ip_addr: String) -> Result { + let candidate = match Query::find_candidate_by_id(db, user_id).await { + Ok(candidate) => match candidate { + Some(candidate) => candidate, + None => return Err(ServiceError::UserNotFound) + }, + Err(_) => {return Err(ServiceError::DbError)} + }; + + // compare passwords + match crypto::verify_password(password,candidate.code.clone()).await { + Ok(valid) => { + if !valid { + return Err(ServiceError::InvalidCredentials) + } + }, + Err(_) => {return Err(ServiceError::InvalidCredentials)} + } + + + // user is authenticated, generate a new session + let random_uuid: Uuid = Uuid::new_v4(); + + let session = match Mutation::insert_session(db, user_id, random_uuid, ip_addr).await { + Ok(session) => session, + Err(_) => return Err(ServiceError::DbError) + }; + + // delete old sessions + SessionService::delete_old_sessions(db, candidate.application, 3).await.ok(); // TODO move to dotenv + + Ok(session.id.to_string()) + } + + /// Authenticate user by session id + /// Return user model if session is valid + pub async fn auth_user_session(db: &DatabaseConnection, uuid: Uuid) -> Result { + let session = match Query::find_session_by_uuid(db, uuid).await { + Ok(session) => match session { + Some(session) => session, + None => return Err(ServiceError::UserNotFoundBySessionId) + }, + Err(_) => {return Err(ServiceError::DbError)} + }; + + let now = chrono::Utc::now().naive_utc(); + // check if session is expired + if now > session.expires_at { + // delete session + Mutation::delete_session(db, session.id).await.unwrap(); + return Err(ServiceError::ExpiredSession) + } + + let candidate = match session.find_related(candidate::Entity).one(db).await { + Ok(candidate) => match candidate { + Some(candidate) => candidate, + None => return Err(ServiceError::UserNotFoundBySessionId) + }, + Err(_) => {return Err(ServiceError::DbError)} + }; + + Ok(candidate) + } +} + + + +#[cfg(test)] +mod tests { + use entity::{candidate}; + use sea_orm::{DbConn, Database, sea_query::TableCreateStatement, DbBackend, Schema, ConnectionTrait, prelude::Uuid}; + + use crate::{crypto, services::{session_service::SessionService, candidate_service::CandidateService}}; + + #[cfg(test)] + async fn get_memory_sqlite_connection() -> DbConn { + use entity::session; + + let base_url = "sqlite::memory:"; + let db: DbConn = Database::connect(base_url).await.unwrap(); + + let schema = Schema::new(DbBackend::Sqlite); + let stmt: TableCreateStatement = schema.create_table_from_entity(candidate::Entity); + let stmt2: TableCreateStatement = schema.create_table_from_entity(session::Entity); + db.execute(db.get_database_backend().build(&stmt)).await.unwrap(); + db.execute(db.get_database_backend().build(&stmt2)).await.unwrap(); + db + } + + #[tokio::test] + async fn test_create_candidate() { + const SECRET: &str = "Tajny_kod"; + + let db = get_memory_sqlite_connection().await; + + let candidate = CandidateService::create(&db, 5555555, &SECRET.to_string(), "".to_string()).await.ok().unwrap(); + + assert_eq!(candidate.application, 5555555); + assert_ne!(candidate.code, SECRET.to_string()); + assert!(crypto::verify_password(SECRET.to_string(), candidate.code).await.ok().unwrap()); + } + + #[tokio::test] + async fn test_candidate_session_correct_password() { + let db = &get_memory_sqlite_connection().await; + + CandidateService::create(&db, 5555555, &"Tajny_kod".to_string(), "".to_string()).await.ok().unwrap(); + + // correct password + let session = SessionService::new_session( + db, + 5555555, + "Tajny_kod".to_string(), + "127.0.0.1".to_string(), + ) + .await.ok().unwrap(); + // println!("{}", session.err().unwrap().1); + + assert!( + SessionService::auth_user_session(db, Uuid::parse_str(&session).unwrap()) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn test_candidate_session_incorrect_password() { + let db = &get_memory_sqlite_connection().await; + + let candidate_form = CandidateService::create(&db, 5555555, &"Tajny_kod".to_string(), "".to_string()).await.ok().unwrap(); + + // incorrect password + assert!( + SessionService::new_session(db, candidate_form.application, "Spatny_kod".to_string(), "127.0.0.1".to_string()).await.is_err() + ); + } +} \ No newline at end of file diff --git a/entity/src/candidate.rs b/entity/src/candidate.rs index fe2f725..2806e76 100644 --- a/entity/src/candidate.rs +++ b/entity/src/candidate.rs @@ -22,6 +22,8 @@ pub struct Model { pub personal_identification_number_hash: Option, pub public_key: String, pub private_key: String, + #[sea_orm(default_value = false)] + pub is_admin: bool, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index d8329d8..a7409c1 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -1,9 +1,8 @@ pub use sea_orm_migration::prelude::*; -mod m20221024_111310_create_admin; mod m20221024_121621_create_candidate; mod m20221024_124701_create_parent; -mod m20221024_134454_fill_admin; +mod m20221024_134454_insert_sample_admin; mod m20221025_154422_create_session; mod m20221027_194728_session_create_user_fk; mod m20221030_133428_parent_create_candidate_fk; @@ -13,10 +12,9 @@ pub struct Migrator; impl MigratorTrait for Migrator { fn migrations() -> Vec> { vec![ - Box::new(m20221024_111310_create_admin::Migration), Box::new(m20221024_121621_create_candidate::Migration), Box::new(m20221024_124701_create_parent::Migration), - Box::new(m20221024_134454_fill_admin::Migration::default()), + Box::new(m20221024_134454_insert_sample_admin::Migration::default()), Box::new(m20221025_154422_create_session::Migration), Box::new(m20221027_194728_session_create_user_fk::Migration), Box::new(m20221030_133428_parent_create_candidate_fk::Migration), diff --git a/migration/src/m20221024_111310_create_admin.rs b/migration/src/m20221024_111310_create_admin.rs deleted file mode 100644 index 50809d5..0000000 --- a/migration/src/m20221024_111310_create_admin.rs +++ /dev/null @@ -1,49 +0,0 @@ -use sea_orm_migration::prelude::*; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(Admin::Table) - .if_not_exists() - .col( - ColumnDef::new(Admin::Id) - .integer() - .not_null() - .auto_increment() - .primary_key(), - ) - .col(ColumnDef::new(Admin::Name).string().not_null()) - .col(ColumnDef::new(Admin::PublicKey).string().not_null()) - .col(ColumnDef::new(Admin::PrivateKeyHash).text().not_null()) - .col(ColumnDef::new(Admin::PasswordHash).string().not_null()) - .col(ColumnDef::new(Admin::CreatedAt).date_time().not_null()) - .col(ColumnDef::new(Admin::UpdatedAt).date_time().not_null()) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(Admin::Table).to_owned()) - .await - } -} - -#[derive(Iden)] -enum Admin { - Table, - Id, - Name, - PublicKey, - PrivateKeyHash, - PasswordHash, - CreatedAt, - UpdatedAt, -} diff --git a/migration/src/m20221024_121621_create_candidate.rs b/migration/src/m20221024_121621_create_candidate.rs index 5483c3a..8743428 100644 --- a/migration/src/m20221024_121621_create_candidate.rs +++ b/migration/src/m20221024_121621_create_candidate.rs @@ -34,6 +34,7 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(Candidate::PersonalIdentificationNumberHash).text()) .col(ColumnDef::new(Candidate::PublicKey).string().not_null()) .col(ColumnDef::new(Candidate::PrivateKey).string().not_null()) + .col(ColumnDef::new(Candidate::IsAdmin).boolean().not_null().default(false)) .col(ColumnDef::new(Candidate::CreatedAt).date_time().not_null()) .col(ColumnDef::new(Candidate::UpdatedAt).date_time().not_null()) .to_owned(), @@ -68,6 +69,7 @@ pub enum Candidate { PersonalIdentificationNumberHash, PublicKey, PrivateKey, + IsAdmin, CreatedAt, UpdatedAt, } diff --git a/migration/src/m20221024_134454_fill_admin.rs b/migration/src/m20221024_134454_insert_sample_admin.rs similarity index 57% rename from migration/src/m20221024_134454_fill_admin.rs rename to migration/src/m20221024_134454_insert_sample_admin.rs index 9250fd2..56f551c 100644 --- a/migration/src/m20221024_134454_fill_admin.rs +++ b/migration/src/m20221024_134454_insert_sample_admin.rs @@ -1,5 +1,5 @@ use chrono::Local; -use entity::admin; +use entity::{candidate}; use sea_orm_migration::{ prelude::*, sea_orm::{ActiveModelTrait, Set}, @@ -7,20 +7,22 @@ use sea_orm_migration::{ #[derive(DeriveMigrationName)] pub struct Migration { - admin: admin::ActiveModel, + candidate: candidate::ActiveModel, } impl Default for Migration { fn default() -> Self { Self { - admin: admin::ActiveModel { - id: Set(1), - name: Set("Administrátor Pepa".to_owned()), + candidate: candidate::ActiveModel { + application: Set(1), + name: Set(Some("Administrátor Pepa".to_owned())), public_key: Set("lorem ipsum".to_owned()), - private_key_hash: Set("lorem ipsum".to_owned()), - password_hash: Set("lorem ipsum".to_owned()), + private_key: Set("lorem ipsum".to_owned()), + code: Set("$argon2id$v=19$m=4096,t=3,p=1$V2M1eENXcnJvenhqTVF1Yw$xwriCZexpzF7Qtj9lwq0Sw".to_owned()), + personal_identification_number: Set("ADMIN".to_owned()), created_at: Set(Local::now().naive_local()), updated_at: Set(Local::now().naive_local()), + is_admin: Set(true), ..Default::default() }, } @@ -32,7 +34,7 @@ impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { let db = manager.get_connection(); - self.admin.to_owned().insert(db).await?; + self.candidate.to_owned().insert(db).await?; Ok(()) } @@ -40,7 +42,7 @@ impl MigrationTrait for Migration { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { let db = manager.get_connection(); - self.admin.to_owned().delete(db).await?; + self.candidate.to_owned().delete(db).await?; Ok(()) }