diff --git a/Cargo.lock b/Cargo.lock index 23be0b9..1816c81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,9 +226,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.58" +version = "0.1.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e805d94e6b5001b651426cf4cd446b1ab5f319d27bab5c644f61de0a804360c" +checksum = "677d1d8ab452a3936018a687b20e6f7cf5363d713b732b8884001317b0e48aa3" dependencies = [ "proc-macro2", "quote", @@ -2064,6 +2064,7 @@ dependencies = [ "argon2", "async-compat", "async-tempfile", + "async-trait", "async_zip", "base64", "chrono", @@ -2088,6 +2089,7 @@ dependencies = [ name = "portfolio-entity" version = "0.1.0" dependencies = [ + "async-trait", "chrono", "sea-orm", ] diff --git a/api/src/guards/request/auth/admin.rs b/api/src/guards/request/auth/admin.rs index 984c082..4871bf5 100644 --- a/api/src/guards/request/auth/admin.rs +++ b/api/src/guards/request/auth/admin.rs @@ -1,5 +1,6 @@ use entity::admin::Model as Admin; use log::info; +use portfolio_core::models::auth::AuthenticableTrait; use portfolio_core::sea_orm::prelude::Uuid; use portfolio_core::services::admin_service::AdminService; use rocket::http::Status; diff --git a/api/src/guards/request/auth/candidate.rs b/api/src/guards/request/auth/candidate.rs index 08d5fe3..0cd3d48 100644 --- a/api/src/guards/request/auth/candidate.rs +++ b/api/src/guards/request/auth/candidate.rs @@ -1,4 +1,5 @@ use entity::candidate::Model as Candidate; +use portfolio_core::models::auth::AuthenticableTrait; use portfolio_core::sea_orm::prelude::Uuid; use portfolio_core::services::candidate_service::CandidateService; use rocket::http::Status; diff --git a/api/src/routes/admin.rs b/api/src/routes/admin.rs index 02c0112..4224fa1 100644 --- a/api/src/routes/admin.rs +++ b/api/src/routes/admin.rs @@ -2,7 +2,7 @@ use std::net::{SocketAddr, IpAddr, Ipv4Addr}; use portfolio_core::{ crypto::random_12_char_string, - services::{admin_service::AdminService, candidate_service::CandidateService, application_service::ApplicationService, portfolio_service::PortfolioService}, models::candidate::{BaseCandidateResponse, CreateCandidateResponse, ApplicationDetails}, sea_orm::prelude::Uuid, Query, error::ServiceError, utils::csv, + services::{admin_service::AdminService, candidate_service::CandidateService, application_service::ApplicationService, portfolio_service::PortfolioService}, models::{candidate::{BaseCandidateResponse, CreateCandidateResponse, ApplicationDetails}, auth::AuthenticableTrait}, sea_orm::prelude::Uuid, Query, error::ServiceError, utils::csv, }; use requests::{AdminLoginRequest, RegisterRequest}; use rocket::http::{Cookie, Status, CookieJar}; @@ -58,8 +58,9 @@ pub async fn logout(conn: Connection<'_, Db>, _session: AdminAuth, cookies: &Coo .ok_or(Custom(Status::Unauthorized, "No session cookie".to_string()))?; let session_id = Uuid::try_parse(cookie.value()) // unwrap would be safe here because of the auth guard .map_err(|e| Custom(Status::BadRequest, e.to_string()))?; + let session = Query::find_admin_session_by_uuid(db, session_id).await.unwrap().unwrap(); - let _res = AdminService::logout(db, session_id) + let _res = AdminService::logout(db, session) .await .map_err(to_custom_error)?; diff --git a/api/src/routes/candidate.rs b/api/src/routes/candidate.rs index 91e0cab..39cea7f 100644 --- a/api/src/routes/candidate.rs +++ b/api/src/routes/candidate.rs @@ -1,5 +1,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use portfolio_core::Query; +use portfolio_core::models::auth::AuthenticableTrait; use portfolio_core::models::candidate::ApplicationDetails; use portfolio_core::sea_orm::prelude::Uuid; use portfolio_core::services::application_service::ApplicationService; @@ -58,8 +60,8 @@ pub async fn logout( ))?; let session_id = Uuid::try_parse(cookie.value()) // unwrap would be safe here because of the auth guard .map_err(|e| Custom(Status::BadRequest, e.to_string()))?; - - CandidateService::logout(db, session_id) + let session = Query::find_session_by_uuid(db, session_id).await.unwrap().unwrap(); // TODO + CandidateService::logout(db, session) .await .map_err(to_custom_error)?; @@ -227,7 +229,6 @@ pub async fn submit_portfolio( #[post("/delete")] pub async fn delete_portfolio( - conn: Connection<'_, Db>, session: CandidateAuth, ) -> Result<(), Custom> { let candidate: entity::candidate::Model = session.into(); diff --git a/core/Cargo.toml b/core/Cargo.toml index be3962a..dc63198 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -15,8 +15,9 @@ serde = { version = "^1.0", features = ["derive"] } # csv csv = "^1.1" -# error +async-trait = "0.1.60" +# error thiserror = "^1.0" # env diff --git a/core/src/database/mutation/admin_session.rs b/core/src/database/mutation/admin_session.rs new file mode 100644 index 0000000..e182381 --- /dev/null +++ b/core/src/database/mutation/admin_session.rs @@ -0,0 +1,49 @@ +use chrono::{Utc, Duration}; +use entity::{admin_session}; +use sea_orm::{DbConn, prelude::Uuid, DbErr, Set, ActiveModelTrait}; + +use crate::Mutation; + +impl Mutation { + pub async fn insert_admin_session( + db: &DbConn, + admin_id: i32, + random_uuid: Uuid, + ip_addr: String, + ) -> Result { + admin_session::ActiveModel { + id: Set(random_uuid), + admin_id: Set(Some(admin_id)), + ip_address: Set(ip_addr), + created_at: Set(Utc::now().naive_local()), + expires_at: Set(Utc::now() + .naive_local() + .checked_add_signed(Duration::days(1)) + .unwrap()), + updated_at: Set(Utc::now().naive_local()) + } + .insert(db) + .await + } + + /* pub async fn update_session_expiration(db: &DbConn, + session: session::Model, + expires_at: NaiveDateTime, + ) -> Result { + let mut session = session.into_active_model(); + + session.expires_at = Set(expires_at); + session.updated_at = Set(Utc::now().naive_local()); + + session.update(db).await + } + + pub async fn delete_admin_session(db: &DbConn, session: ad) -> Result { + session::ActiveModel { + id: Set(session_id), + ..Default::default() + } + .delete(db) + .await + } */ +} \ No newline at end of file diff --git a/core/src/database/mutation/mod.rs b/core/src/database/mutation/mod.rs index 1a654a9..f5afed2 100644 --- a/core/src/database/mutation/mod.rs +++ b/core/src/database/mutation/mod.rs @@ -2,4 +2,5 @@ pub(crate) struct Mutation; pub mod session; pub mod candidate; -pub mod parent; \ No newline at end of file +pub mod parent; +pub mod admin_session; \ No newline at end of file diff --git a/core/src/database/mutation/session.rs b/core/src/database/mutation/session.rs index c540398..6b476de 100644 --- a/core/src/database/mutation/session.rs +++ b/core/src/database/mutation/session.rs @@ -1,46 +1,56 @@ -use chrono::{Utc, Duration}; -use ::entity::session; +use chrono::{Utc, Duration, NaiveDateTime}; +use ::entity::{session, candidate}; use sea_orm::{*, prelude::Uuid}; use crate::Mutation; impl Mutation { - pub async fn insert_session( + pub async fn insert_candidate_session( db: &DbConn, - user_id: Option, - admin_id: Option, random_uuid: Uuid, + candidate_id: i32, ip_addr: String, ) -> Result { session::ActiveModel { id: Set(random_uuid), - user_id: Set(user_id), - admin_id: Set(admin_id), + candidate_id: Set(Some(candidate_id)), ip_address: Set(ip_addr), created_at: Set(Utc::now().naive_local()), expires_at: Set(Utc::now() .naive_local() - .checked_add_signed(Duration::days(1)) + .checked_add_signed(Duration::days(14)) .unwrap()), + updated_at: Set(Utc::now().naive_local()) } .insert(db) .await } - pub async fn delete_session(db: &DbConn, session_id: Uuid) -> Result { - session::ActiveModel { - id: Set(session_id), - ..Default::default() - } - .delete(db) - .await + pub async fn update_session_expiration(db: &DbConn, + session: session::Model, + expires_at: NaiveDateTime, + ) -> Result { + let mut session = session.into_active_model(); + + session.expires_at = Set(expires_at); + session.updated_at = Set(Utc::now().naive_local()); + + session.update(db).await + } + + pub async fn delete_session(db: &DbConn, session: T) -> Result + where T: ActiveModelTrait + std::marker::Send + ActiveModelBehavior + { + session + .delete(db) + .await } } #[cfg(test)] mod tests { - use sea_orm::prelude::Uuid; + /* use sea_orm::prelude::Uuid; use crate::{utils::db::get_memory_sqlite_connection, Mutation, services::candidate_service::tests::put_user_data}; @@ -49,14 +59,14 @@ mod tests { let db = get_memory_sqlite_connection().await; let session_id = Uuid::new_v4(); - let (user, _) = put_user_data(&db).await; + let (candidate, _) = put_user_data(&db).await; - let session = Mutation::insert_session(&db, Some(user.application), None, session_id, "127.0.0.1".to_string()).await.unwrap(); + let session = Mutation::insert_candidate_session(&db, session_id, candidate.application, "127.0.0.1".to_string()).await.unwrap(); assert_eq!(session.id, session_id); let delete_result = Mutation::delete_session(&db, session_id).await.unwrap(); assert_eq!(delete_result.rows_affected, 1); - } + } */ } \ No newline at end of file diff --git a/core/src/database/query/session.rs b/core/src/database/query/session.rs index 854a2d9..8d5b753 100644 --- a/core/src/database/query/session.rs +++ b/core/src/database/query/session.rs @@ -1,5 +1,7 @@ use crate::Query; +use ::entity::prelude::AdminSession; +use ::entity::{candidate, admin, admin_session}; use ::entity::{session, session::Entity as Session}; use sea_orm::prelude::Uuid; use sea_orm::*; @@ -12,19 +14,23 @@ impl Query { Session::find_by_id(uuid).one(db).await } - // find session by user id - pub async fn find_sessions_by_user_id( + pub async fn find_admin_session_by_uuid( db: &DbConn, - user_id: Option, - admin_id: Option, - ) -> Result, DbErr> { - if user_id.is_some() { - Session::find() - .filter(session::Column::UserId.eq(user_id)) - } else { - Session::find() - .filter(session::Column::AdminId.eq(admin_id)) - } + uuid: Uuid, + ) -> Result, DbErr> { + AdminSession::find_by_id(uuid).one(db).await + } + + pub async fn find_related_candidate_sessions(db: &DbConn, candidate: candidate::Model) -> Result, DbErr> { + candidate.find_related(Session) + .order_by_asc(session::Column::UpdatedAt) + .all(db) + .await + } + + pub async fn find_related_admin_sessions(db: &DbConn, admin: admin::Model) -> Result, DbErr> { + admin.find_related(admin_session::Entity) + .order_by_asc(admin_session::Column::UpdatedAt) .all(db) .await } @@ -32,8 +38,7 @@ impl Query { #[cfg(test)] mod tests { - use entity::{session, admin, candidate}; - use sea_orm::ActiveValue::NotSet; + use entity::{session, admin, candidate, admin_session}; use sea_orm::{prelude::Uuid, ActiveModelTrait, Set}; use crate::utils::db::get_memory_sqlite_connection; @@ -48,6 +53,7 @@ mod tests { ip_address: Set("10.10.10.10".to_string()), created_at: Set(chrono::offset::Local::now().naive_local()), expires_at: Set(chrono::offset::Local::now().naive_local()), + updated_at: Set(chrono::offset::Local::now().naive_local()), ..Default::default() } .insert(&db) @@ -64,7 +70,7 @@ mod tests { const APPLICATION_ID: i32 = 103158; - candidate::ActiveModel { + let candidate = candidate::ActiveModel { application: Set(APPLICATION_ID), code: Set("test".to_string()), public_key: Set("test".to_string()), @@ -80,11 +86,11 @@ mod tests { session::ActiveModel { id: Set(Uuid::new_v4()), - user_id: Set(Some(APPLICATION_ID)), - admin_id: NotSet, + candidate_id: Set(Some(APPLICATION_ID)), ip_address: Set("10.10.10.10".to_string()), created_at: Set(chrono::offset::Local::now().naive_local()), expires_at: Set(chrono::offset::Local::now().naive_local()), + updated_at: Set(chrono::offset::Local::now().naive_local()), ..Default::default() } .insert(&db) @@ -93,7 +99,7 @@ mod tests { const ADMIN_ID: i32 = 1; - admin::ActiveModel { + let admin = admin::ActiveModel { id: Set(ADMIN_ID), name: Set("admin".to_string()), public_key: Set("test".to_string()), @@ -107,23 +113,23 @@ mod tests { .await .unwrap(); - session::ActiveModel { + admin_session::ActiveModel { id: Set(Uuid::new_v4()), - user_id: NotSet, admin_id: Set(Some(ADMIN_ID)), ip_address: Set("10.10.10.10".to_string()), created_at: Set(chrono::offset::Local::now().naive_local()), expires_at: Set(chrono::offset::Local::now().naive_local()), + updated_at: Set(chrono::offset::Local::now().naive_local()), ..Default::default() } .insert(&db) .await .unwrap(); - let sessions = Query::find_sessions_by_user_id(&db, Some(APPLICATION_ID), None).await.unwrap(); + let sessions = Query::find_related_candidate_sessions(&db, candidate).await.unwrap(); assert_eq!(sessions.len(), 1); - let sessions = Query::find_sessions_by_user_id(&db, None, Some(ADMIN_ID)).await.unwrap(); + let sessions = Query::find_related_admin_sessions(&db, admin).await.unwrap(); assert_eq!(sessions.len(), 1); } } diff --git a/core/src/models/auth.rs b/core/src/models/auth.rs new file mode 100644 index 0000000..45bc5eb --- /dev/null +++ b/core/src/models/auth.rs @@ -0,0 +1,17 @@ +use async_trait::async_trait; +use entity::session; +use sea_orm::{prelude::Uuid, DbConn}; + +use crate::error::ServiceError; + + +#[async_trait] +pub trait AuthenticableTrait { + type User; + type Session; + async fn login(db: &DbConn, user: i32, password: String, ip_addr: String) -> Result<(String, String), ServiceError>; + async fn auth(db: &DbConn, session_id: Uuid) -> Result; + async fn logout(db: &DbConn, session: Self::Session) -> Result<(), ServiceError>; + async fn new_session(db: &DbConn, user: Self::User, ip_addr: String, password: String) -> Result; + async fn delete_old_sessions(db: &DbConn, user: Self::User, keep_n_recent: usize) -> Result<(), ServiceError>; +} \ No newline at end of file diff --git a/core/src/models/mod.rs b/core/src/models/mod.rs index 6cd26ab..4c3b0be 100644 --- a/core/src/models/mod.rs +++ b/core/src/models/mod.rs @@ -1,2 +1,3 @@ pub mod candidate_details; -pub mod candidate; \ No newline at end of file +pub mod candidate; +pub mod auth; \ No newline at end of file diff --git a/core/src/services/admin_service.rs b/core/src/services/admin_service.rs index f393102..6d30950 100644 --- a/core/src/services/admin_service.rs +++ b/core/src/services/admin_service.rs @@ -1,9 +1,10 @@ -use entity::admin; -use sea_orm::{prelude::Uuid, DbConn}; +use async_trait::async_trait; +use entity::{admin, admin_session}; +use sea_orm::{prelude::Uuid, DbConn, IntoActiveModel}; -use crate::{crypto, error::ServiceError, Query, Mutation}; +use crate::{crypto, error::ServiceError, Query, Mutation, models::auth::AuthenticableTrait}; -use super::session_service::{AdminUser, SessionService}; +use super::session_service::SessionService; pub struct AdminService; @@ -19,36 +20,87 @@ impl AdminService { Ok(private_key) } +} - pub async fn login( +#[async_trait] +impl AuthenticableTrait for AdminService { + type User = admin::Model; + type Session = admin_session::Model; + + async fn login( db: &DbConn, admin_id: i32, password: String, ip_addr: String, ) -> Result<(String, String), ServiceError> { - let session_id = SessionService::new_session(db, - None, - Some(admin_id), + let admin = Query::find_admin_by_id(db, admin_id).await?.ok_or(ServiceError::InvalidCredentials)?; + + let session_id = Self::new_session(db, + admin.clone(), password.clone(), ip_addr ) .await?; - let private_key = Self::decrypt_private_key(db, admin_id, password).await?; + let private_key = Self::decrypt_private_key(db, admin.id, password).await?; Ok((session_id, private_key)) } - pub async fn logout(db: &DbConn, session_id: Uuid) -> Result<(), ServiceError> { - Mutation::delete_session(db, session_id).await?; + async fn auth(db: &DbConn, session_uuid: Uuid) -> Result { + let session = Query::find_admin_session_by_uuid(db, session_uuid) + .await? + .ok_or(ServiceError::Unauthorized)?; + + if !SessionService::is_valid(&session).await? { + Mutation::delete_session(db, session.into_active_model()).await?; + return Err(ServiceError::ExpiredSession); + } + + let admin = Query::find_admin_by_id(db, session.admin_id.unwrap()) + .await? + .ok_or(ServiceError::CandidateNotFound)?; + + Ok(admin) + } + + async fn logout(db: &DbConn, session: admin_session::Model) -> Result<(), ServiceError> { + Mutation::delete_session(db, session.into_active_model()).await?; Ok(()) } - pub async fn auth(db: &DbConn, session_uuid: Uuid) -> Result { - match SessionService::auth_user_session(db, session_uuid).await? { - AdminUser::Admin(admin) => Ok(admin), - AdminUser::Candidate(_) => Err(ServiceError::Unauthorized), + async fn new_session( + db: &DbConn, + admin: admin::Model, + password: String, + ip_addr: String, + ) -> Result { + if !crypto::verify_password(password.clone(), admin.password.clone()).await? { + return Err(ServiceError::InvalidCredentials); } + // user is authenticated, generate a new session + let random_uuid: Uuid = Uuid::new_v4(); + + let session = Mutation::insert_admin_session(db, admin.id, random_uuid, ip_addr).await?; + + Self::delete_old_sessions(db, admin, 1).await?; + + Ok(session.id.to_string()) } + async fn delete_old_sessions( + db: &DbConn, + admin: admin::Model, + keep_n_recent: usize, + ) -> Result<(), ServiceError> { + let sessions = Query::find_related_admin_sessions(db, admin) + .await? + .iter() + .map(|s| s.clone().into_active_model()) + .collect(); + + SessionService::delete_sessions(db, sessions, keep_n_recent).await?; + Ok(()) + } + } #[cfg(test)] @@ -65,7 +117,7 @@ mod admin_tests { #[tokio::test] async fn test_admin_login() -> Result<(), ServiceError> { let db = get_memory_sqlite_connection().await; - let _ = admin::ActiveModel { + let admin = admin::ActiveModel { id: Set(1), name: Set("Admin".to_owned()), public_key: Set("age1u889gp407hsz309wn09kxx9anl6uns30m27lfwnctfyq9tq4qpus8tzmq5".to_owned()), @@ -80,7 +132,7 @@ mod admin_tests { .insert(&db) .await?; - let (session_id, _private_key) = AdminService::login(&db, 1, "test".to_owned(), "127.0.0.1".to_owned()).await?; + let (session_id, _private_key) = AdminService::login(&db, admin.id, "test".to_owned(), "127.0.0.1".to_owned()).await?; let logged_admin = AdminService::auth(&db, session_id.parse().unwrap()).await?; diff --git a/core/src/services/candidate_service.rs b/core/src/services/candidate_service.rs index dc0639a..b1f0bab 100644 --- a/core/src/services/candidate_service.rs +++ b/core/src/services/candidate_service.rs @@ -1,14 +1,16 @@ -use entity::candidate; -use sea_orm::{prelude::Uuid, DbConn}; +use async_trait::async_trait; +use chrono::Duration; +use entity::{candidate, session}; +use sea_orm::{prelude::Uuid, DbConn, IntoActiveModel}; use crate::{ models::{candidate_details::{EncryptedApplicationDetails, EncryptedString, EncryptedCandidateDetails}, candidate::CandidateDetails}, crypto::{self, hash_password}, error::ServiceError, - Mutation, Query, models::candidate::{BaseCandidateResponse, CreateCandidateResponse}, utils::db::get_recipients, + Mutation, Query, models::{candidate::{BaseCandidateResponse, CreateCandidateResponse}, auth::AuthenticableTrait}, utils::db::get_recipients, }; -use super::{session_service::{AdminUser, SessionService}, application_service::ApplicationService, portfolio_service::PortfolioService}; +use super::{session_service::SessionService, application_service::ApplicationService, portfolio_service::PortfolioService}; // TODO validation @@ -114,7 +116,7 @@ impl CandidateService { ).await?; - SessionService::revoke_all_sessions(db, Some(id), None).await?; + Self::delete_old_sessions(db, candidate.clone(), 0).await?; Mutation::update_candidate_password_and_keys(db, candidate.clone(), new_password_hash, pubkey, encrypted_priv_key).await?; // user might no have filled his details yet, but personal id number is filled from beginning @@ -140,11 +142,6 @@ impl CandidateService { ) } - pub async fn logout(db: &DbConn, session_id: Uuid) -> Result<(), ServiceError> { - Mutation::delete_session(db, session_id).await?; - Ok(()) - } - pub async fn delete_candidate(db: &DbConn, candidate: candidate::Model) -> Result<(), ServiceError> { PortfolioService::delete_candidate_root(candidate.application).await?; @@ -218,34 +215,6 @@ impl CandidateService { Ok(private_key) } - pub async fn login( - db: &DbConn, - candidate_id: i32, - password: String, - ip_addr: String, - ) -> Result<(String, String), ServiceError> { - let candidate = Query::find_candidate_by_id(db, candidate_id) - .await? - .ok_or(ServiceError::CandidateNotFound)?; - - let session_id = - SessionService::new_session(db, Some(candidate_id), None, password.clone(), ip_addr) - .await?; - - let private_key = Self::decrypt_private_key(candidate, password).await?; - Ok((session_id, private_key)) - } - - pub async fn auth(db: &DbConn, session_uuid: Uuid) -> Result { - match SessionService::auth_user_session(db, session_uuid).await { - Ok(user) => match user { - AdminUser::Candidate(candidate) => Ok(candidate), - AdminUser::Admin(_) => Err(ServiceError::Unauthorized), - }, - Err(e) => Err(e), - } - } - fn is_application_id_valid(application_id: i32) -> bool { let s = &application_id.to_string(); if s.len() <= 3 { @@ -255,12 +224,103 @@ impl CandidateService { let field_of_study_prefix = &s[0..3]; FIELD_OF_STUDY_PREFIXES.contains(&field_of_study_prefix) } + + pub async fn extend_session_duration_to_14_days(db: &DbConn, session: session::Model) -> Result<(), ServiceError> { + let now = chrono::Utc::now().naive_utc(); + if now >= session.updated_at.checked_add_signed(Duration::days(1)).ok_or(ServiceError::Unauthorized)? { + let new_expires_at = now.checked_add_signed(Duration::days(14)).ok_or(ServiceError::Unauthorized)?; + Mutation::update_session_expiration(db, session, new_expires_at).await?; + } + Ok(()) + } +} + +#[async_trait] +impl AuthenticableTrait for CandidateService { + type User = candidate::Model; + type Session = session::Model; + + async fn login( + db: &DbConn, + application_id: i32, + password: String, + ip_addr: String, + ) -> Result<(String, String), ServiceError> { + let candidate = Query::find_candidate_by_id(db, application_id) + .await? + .ok_or(ServiceError::CandidateNotFound)?; + + let session_id = Self::new_session(db, candidate.clone(), password.clone(), ip_addr).await?; + + let private_key = Self::decrypt_private_key(candidate, password).await?; + Ok((session_id, private_key)) + } + + async fn auth(db: &DbConn, session_uuid: Uuid) -> Result { + let session = Query::find_session_by_uuid(db, session_uuid) + .await? + .ok_or(ServiceError::Unauthorized)?; + + if !SessionService::is_valid(&session).await? { + Mutation::delete_session(db, session.into_active_model()).await?; + return Err(ServiceError::ExpiredSession); + } + // Candidate authenticated + + Self::extend_session_duration_to_14_days(db, session.clone()).await?; + + let candidate = Query::find_candidate_by_id(db, session.candidate_id.unwrap()) + .await? + .ok_or(ServiceError::CandidateNotFound)?; + + Ok(candidate) + } + + async fn logout(db: &DbConn, session: session::Model) -> Result<(), ServiceError> { + Mutation::delete_session(db, session.into_active_model()).await?; + Ok(()) + } + + async fn new_session( + db: &DbConn, + candidate: candidate::Model, + password: String, + ip_addr: String, + ) -> Result { + if !crypto::verify_password(password.clone(), candidate.code.clone()).await? { + return Err(ServiceError::InvalidCredentials); + } + // user is authenticated, generate a new session + let random_uuid: Uuid = Uuid::new_v4(); + + let session = Mutation::insert_candidate_session(db, random_uuid, candidate.application, ip_addr).await?; + + Self::delete_old_sessions(db, candidate, 3).await?; + + Ok(session.id.to_string()) + } + async fn delete_old_sessions( + db: &DbConn, + candidate: candidate::Model, + keep_n_recent: usize, + ) -> Result<(), ServiceError> { + let sessions = Query::find_related_candidate_sessions(db, candidate) + .await? + .iter() + .map(|s| s.clone().into_active_model()) + .collect(); + + SessionService::delete_sessions(db, sessions, keep_n_recent).await?; + Ok(()) + } + } #[cfg(test)] pub mod tests { use sea_orm::{DbConn}; + use crate::models::auth::AuthenticableTrait; use crate::models::candidate_details::tests::assert_all_application_details; use crate::utils::db::get_memory_sqlite_connection; use crate::{crypto, services::candidate_service::CandidateService, Mutation}; diff --git a/core/src/services/session_service.rs b/core/src/services/session_service.rs index 78bc054..16e05c6 100644 --- a/core/src/services/session_service.rs +++ b/core/src/services/session_service.rs @@ -1,165 +1,36 @@ use std::cmp::min; - -use entity::{admin, candidate}; -use sea_orm::{prelude::Uuid, DatabaseConnection, ModelTrait, DbConn}; +use entity::{session_trait::UserSession}; +use sea_orm::{DbConn, ActiveModelTrait, ActiveModelBehavior}; use crate::{ - crypto::{self}, error::ServiceError, - Mutation, Query, + Mutation, }; -pub enum AdminUser { - Admin(entity::admin::Model), - Candidate(entity::candidate::Model), -} - pub(in crate::services) struct SessionService; impl SessionService { - /// Delete n old sessions for user - async fn delete_old_sessions( - db: &DatabaseConnection, - user_id: Option, - admin_id: Option, - keep_n_recent: usize, - ) -> Result<(), ServiceError> { - let mut sessions = Query::find_sessions_by_user_id(db, user_id, admin_id) - .await - .unwrap(); - - sessions.sort_by_key(|s| s.created_at); + /// Check if session is valid + pub async fn is_valid(session: &T) -> Result where T: UserSession { + let now = chrono::Utc::now().naive_utc(); + if now >= session.expires_at().await { + Ok(false) + } else { + Ok(true) + } + } + /// Delete list of sessions + pub async fn delete_sessions(db: &DbConn, sessions: Vec, keep_n_recent: usize) -> Result<(), ServiceError> where T: ActiveModelTrait + std::marker::Send + ActiveModelBehavior { for session in sessions .iter() .take(sessions.len() - min(sessions.len(), keep_n_recent)) { - Mutation::delete_session(db, session.id).await.unwrap(); + Mutation::delete_session(db, session.clone()).await?; } Ok(()) - } - /// Authenticate user by application id and password and generate a new session - pub async fn new_session( - db: &DatabaseConnection, - user_id: Option, - admin_id: Option, - password: String, - ip_addr: String, - ) -> Result { - if user_id.is_none() && admin_id.is_none() { - return Err(ServiceError::UserNotFoundBySessionId); - } - - if admin_id.is_none() { - // unwrap is safe here - let candidate = match Query::find_candidate_by_id(db, user_id.unwrap()).await { - Ok(candidate) => match candidate { - Some(candidate) => candidate, - None => return Err(ServiceError::CandidateNotFound), - }, - Err(e) => return Err(ServiceError::DbError(e)), - }; - - // compare passwords - match crypto::verify_password(password.clone(), candidate.code.clone()).await { - Ok(valid) => { - if !valid { - return Err(ServiceError::InvalidCredentials); - } - } - Err(_) => return Err(ServiceError::InvalidCredentials), - } - } - - if user_id.is_none() { - // unwrap is safe here - let admin = match Query::find_admin_by_id(db, admin_id.unwrap()).await { - Ok(admin) => match admin { - Some(admin) => admin, - None => return Err(ServiceError::CandidateNotFound), - }, - Err(e) => return Err(ServiceError::DbError(e)), - }; - - // compare passwords - match crypto::verify_password(password.clone(), admin.password.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, admin_id, random_uuid, ip_addr).await { - Ok(session) => session, - Err(e) => { - eprintln!("Error creating session: {}", e); - return Err(ServiceError::DbError(e)); - } - }; - - // delete old sessions - SessionService::delete_old_sessions(db, user_id, admin_id, 3) - .await - .ok(); - - Ok(session.id.to_string()) - } - - pub async fn revoke_all_sessions(db: &DbConn, user_id: Option, admin_id: Option) -> Result<(), ServiceError> { - Self::delete_old_sessions(db, user_id, admin_id, 0).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(ServiceError::UserNotFoundBySessionId), - }, - Err(e) => return Err(ServiceError::DbError(e)), - }; - - 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 = session.find_related(candidate::Entity).one(db).await; - let admin = session.find_related(admin::Entity).one(db).await; - - if candidate.is_err() || admin.is_err() { - eprintln!("Kurva"); - return Err(ServiceError::UserNotFoundBySessionId); - } - - if candidate.is_ok() { - if let Some(candidate) = candidate.unwrap() { - return Ok(AdminUser::Candidate(candidate)); - } - } - - if admin.is_ok() { - if let Some(admin) = admin.unwrap() { - return Ok(AdminUser::Admin(admin)); - } - } - return Err(ServiceError::UserNotFoundBySessionId); } } @@ -171,8 +42,8 @@ mod tests { use crate::{ crypto, - services::{application_service::ApplicationService, session_service::SessionService}, - utils::db::get_memory_sqlite_connection, + services::{application_service::ApplicationService, candidate_service::CandidateService}, + utils::db::get_memory_sqlite_connection, models::auth::AuthenticableTrait, }; #[tokio::test] @@ -204,7 +75,7 @@ mod tests { async fn test_candidate_session_correct_password() { let db = &get_memory_sqlite_connection().await; - ApplicationService::create_candidate_with_parent( + let candidate = ApplicationService::create_candidate_with_parent( db, 103151, &"Tajny_kod".to_string(), @@ -215,10 +86,9 @@ mod tests { .0; // correct password - let session = SessionService::new_session( + let session = CandidateService::new_session( db, - Some(103151), - None, + candidate, "Tajny_kod".to_string(), "127.0.0.1".to_string(), ) @@ -226,7 +96,7 @@ mod tests { .unwrap(); // println!("{}", session.err().unwrap().1); assert!( - SessionService::auth_user_session(db, Uuid::parse_str(&session).unwrap()) + CandidateService::auth(db, Uuid::parse_str(&session).unwrap()) .await .is_ok() ); @@ -247,10 +117,9 @@ mod tests { .0; // incorrect password - assert!(SessionService::new_session( + assert!(CandidateService::new_session( db, - Some(candidate_form.application), - None, + candidate_form, "Spatny_kod".to_string(), "127.0.0.1".to_string() ) diff --git a/core/src/utils/db.rs b/core/src/utils/db.rs index c9a4503..ac1b4bd 100644 --- a/core/src/utils/db.rs +++ b/core/src/utils/db.rs @@ -1,3 +1,4 @@ +use entity::admin_session; use sea_orm::DbConn; use crate::Query; @@ -24,10 +25,12 @@ pub async fn get_memory_sqlite_connection() -> sea_orm::DbConn { let stmt2: TableCreateStatement = schema.create_table_from_entity(admin::Entity); let stmt3: TableCreateStatement = schema.create_table_from_entity(session::Entity); let stmt4: TableCreateStatement = schema.create_table_from_entity(parent::Entity); + let stmt5: TableCreateStatement = schema.create_table_from_entity(admin_session::Entity); db.execute(db.get_database_backend().build(&stmt)).await.unwrap(); db.execute(db.get_database_backend().build(&stmt2)).await.unwrap(); db.execute(db.get_database_backend().build(&stmt3)).await.unwrap(); db.execute(db.get_database_backend().build(&stmt4)).await.unwrap(); + db.execute(db.get_database_backend().build(&stmt5)).await.unwrap(); db } diff --git a/entity/Cargo.toml b/entity/Cargo.toml index af3e744..f916167 100644 --- a/entity/Cargo.toml +++ b/entity/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] chrono = "^0.4" +async-trait = "0.1.60" [dependencies.sea-orm] version = "^0.10" diff --git a/entity/src/admin.rs b/entity/src/admin.rs index 8486730..3d06f08 100644 --- a/entity/src/admin.rs +++ b/entity/src/admin.rs @@ -18,13 +18,13 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm(has_many = "super::session::Entity")] - Session, + #[sea_orm(has_many = "super::admin_session::Entity")] + AdminSession, } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::Session.def() + Relation::AdminSession.def() } } diff --git a/entity/src/admin_session.rs b/entity/src/admin_session.rs new file mode 100644 index 0000000..1815b5d --- /dev/null +++ b/entity/src/admin_session.rs @@ -0,0 +1,47 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.9.3 + +use sea_orm::entity::prelude::*; + +use crate::session_trait::UserSession; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "admin_session")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub admin_id: Option, + pub ip_address: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::admin::Entity", + from = "Column::AdminId", + to = "super::admin::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Admin, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Admin.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +#[async_trait::async_trait] +impl UserSession for Model { + async fn id(&self) -> Uuid { + self.id + } + async fn expires_at(&self) -> chrono::NaiveDateTime { + self.expires_at + } +} diff --git a/entity/src/lib.rs b/entity/src/lib.rs index ef5f522..c2e3813 100644 --- a/entity/src/lib.rs +++ b/entity/src/lib.rs @@ -4,3 +4,5 @@ pub mod admin; pub mod candidate; pub mod parent; pub mod session; +pub mod admin_session; +pub mod session_trait; \ No newline at end of file diff --git a/entity/src/mod.rs b/entity/src/mod.rs index 63cf621..268951e 100644 --- a/entity/src/mod.rs +++ b/entity/src/mod.rs @@ -3,6 +3,7 @@ pub mod prelude; pub mod admin; +pub mod admin_session; pub mod candidate; pub mod parent; pub mod session; diff --git a/entity/src/prelude.rs b/entity/src/prelude.rs index 73b608f..55797e7 100644 --- a/entity/src/prelude.rs +++ b/entity/src/prelude.rs @@ -1,6 +1,7 @@ //! SeaORM Entity. Generated by sea-orm-codegen 0.9.3 pub use super::admin::Entity as Admin; +pub use super::admin_session::Entity as AdminSession; pub use super::candidate::Entity as Candidate; pub use super::parent::Entity as Parent; pub use super::session::Entity as Session; diff --git a/entity/src/session.rs b/entity/src/session.rs index e0b5829..63efcde 100644 --- a/entity/src/session.rs +++ b/entity/src/session.rs @@ -2,31 +2,25 @@ use sea_orm::entity::prelude::*; +use crate::session_trait::UserSession; + #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[sea_orm(table_name = "session")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, - pub user_id: Option, - pub admin_id: Option, + pub candidate_id: Option, pub ip_address: String, pub created_at: DateTime, pub expires_at: DateTime, + pub updated_at: DateTime, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm( - belongs_to = "super::admin::Entity", - from = "Column::AdminId", - to = "super::admin::Column::Id", - on_update = "Cascade", - on_delete = "Cascade" - )] - Admin, #[sea_orm( belongs_to = "super::candidate::Entity", - from = "Column::UserId", + from = "Column::CandidateId", to = "super::candidate::Column::Application", on_update = "Cascade", on_delete = "Cascade" @@ -34,12 +28,6 @@ pub enum Relation { Candidate, } -impl Related for Entity { - fn to() -> RelationDef { - Relation::Admin.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::Candidate.def() @@ -47,3 +35,13 @@ impl Related for Entity { } impl ActiveModelBehavior for ActiveModel {} + +#[async_trait::async_trait] +impl UserSession for Model { + async fn id(&self) -> Uuid { + self.id + } + async fn expires_at(&self) -> chrono::NaiveDateTime { + self.expires_at + } +} \ No newline at end of file diff --git a/entity/src/session_trait.rs b/entity/src/session_trait.rs new file mode 100644 index 0000000..cec9db7 --- /dev/null +++ b/entity/src/session_trait.rs @@ -0,0 +1,8 @@ +use async_trait::async_trait; +use sea_orm::prelude::Uuid; + +#[async_trait] +pub trait UserSession { + async fn expires_at(&self) -> chrono::NaiveDateTime; + async fn id(&self) -> Uuid; +} \ No newline at end of file diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 6e2081d..3d8205f 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -8,6 +8,7 @@ mod m20221025_154422_create_session; mod m20221027_194728_session_create_user_fk; mod m20221028_194728_session_create_admin_fk; mod m20221112_112212_create_parent_candidate_fk; +mod m20221221_162232_create_admin_session; pub struct Migrator; #[async_trait::async_trait] @@ -20,8 +21,10 @@ impl MigratorTrait for Migrator { 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(m20221028_194728_session_create_admin_fk::Migration), Box::new(m20221112_112212_create_parent_candidate_fk::Migration), + Box::new(m20221221_162232_create_admin_session::Migration), + Box::new(m20221028_194728_session_create_admin_fk::Migration), + ] } } diff --git a/migration/src/m20221025_154422_create_session.rs b/migration/src/m20221025_154422_create_session.rs index 3bb8b8c..94cd12a 100644 --- a/migration/src/m20221025_154422_create_session.rs +++ b/migration/src/m20221025_154422_create_session.rs @@ -18,11 +18,11 @@ impl MigrationTrait for Migration { .unique_key() .primary_key(), ) - .col(ColumnDef::new(Session::UserId).integer()) - .col(ColumnDef::new(Session::AdminId).integer()) + .col(ColumnDef::new(Session::CandidateId).integer()) .col(ColumnDef::new(Session::IpAddress).string().not_null()) .col(ColumnDef::new(Session::CreatedAt).date_time().not_null()) .col(ColumnDef::new(Session::ExpiresAt).date_time().not_null()) + .col(ColumnDef::new(Session::UpdatedAt).date_time().not_null()) .to_owned(), ) .await @@ -39,9 +39,9 @@ impl MigrationTrait for Migration { pub enum Session { Table, Id, - UserId, - AdminId, + CandidateId, IpAddress, CreatedAt, ExpiresAt, + UpdatedAt, } diff --git a/migration/src/m20221027_194728_session_create_user_fk.rs b/migration/src/m20221027_194728_session_create_user_fk.rs index 248fe2d..9a06760 100644 --- a/migration/src/m20221027_194728_session_create_user_fk.rs +++ b/migration/src/m20221027_194728_session_create_user_fk.rs @@ -10,7 +10,7 @@ impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.create_foreign_key(ForeignKey::create() .name("user_fk") - .from(Session::Table, Session::UserId) + .from(Session::Table, Session::CandidateId) .to(Candidate::Table, Candidate::Application) .on_delete(ForeignKeyAction::Cascade) .on_update(ForeignKeyAction::Cascade) diff --git a/migration/src/m20221028_194728_session_create_admin_fk.rs b/migration/src/m20221028_194728_session_create_admin_fk.rs index 5b36851..214b064 100644 --- a/migration/src/m20221028_194728_session_create_admin_fk.rs +++ b/migration/src/m20221028_194728_session_create_admin_fk.rs @@ -1,6 +1,6 @@ use sea_orm_migration::prelude::*; -use crate::{m20221025_154422_create_session::Session, m20221024_111310_create_admin::Admin}; +use crate::{m20221221_162232_create_admin_session::AdminSession, m20221024_111310_create_admin::Admin}; #[derive(DeriveMigrationName)] pub struct Migration; @@ -10,7 +10,7 @@ impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.create_foreign_key(ForeignKey::create() .name("admin_fk") - .from(Session::Table, Session::AdminId) + .from(AdminSession::Table, AdminSession::AdminId) .to(Admin::Table, Admin::Id) .on_delete(ForeignKeyAction::Cascade) .on_update(ForeignKeyAction::Cascade) @@ -20,7 +20,7 @@ impl MigrationTrait for Migration { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.drop_foreign_key(ForeignKey::drop() .name("admin_fk") - .table(Session::Table) + .table(AdminSession::Table) .to_owned()).await } } \ No newline at end of file diff --git a/migration/src/m20221221_162232_create_admin_session.rs b/migration/src/m20221221_162232_create_admin_session.rs new file mode 100644 index 0000000..73d343d --- /dev/null +++ b/migration/src/m20221221_162232_create_admin_session.rs @@ -0,0 +1,48 @@ +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(AdminSession::Table) + .if_not_exists() + .col( + ColumnDef::new(AdminSession::Id) + .uuid() + .unique_key() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(AdminSession::AdminId).integer()) + .col(ColumnDef::new(AdminSession::IpAddress).string().not_null()) + .col(ColumnDef::new(AdminSession::CreatedAt).date_time().not_null()) + .col(ColumnDef::new(AdminSession::ExpiresAt).date_time().not_null()) + .col(ColumnDef::new(AdminSession::UpdatedAt).date_time().not_null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(AdminSession::Table).to_owned()) + .await + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +pub enum AdminSession { + Table, + Id, + AdminId, + IpAddress, + CreatedAt, + ExpiresAt, + UpdatedAt, +}