From 9c139d6007a9993f0087b8a88fb198de41e9b55c Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 7 Dec 2025 14:40:11 +0800 Subject: [PATCH 01/19] refactor: replace IntoServiceError trait with direct ServiceError conversions --- apps/api/src/errors/service_error.rs | 28 ++++++++++++++++++---------- apps/api/src/services/settings.rs | 18 +++++++++--------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/apps/api/src/errors/service_error.rs b/apps/api/src/errors/service_error.rs index 20a5835..e5d6b47 100644 --- a/apps/api/src/errors/service_error.rs +++ b/apps/api/src/errors/service_error.rs @@ -1,15 +1,23 @@ -pub type ServiceError = Box; +use sea_orm::DbErr; -#[allow(dead_code)] // TODO: remove when used -pub trait IntoServiceError { - fn into_service_error(self) -> ServiceError; +pub enum ServiceError { + NotFound(String), + DatabaseError(String), + Unauthorized(String), + InternalError(String), } -impl IntoServiceError for T -where - T: std::error::Error + Send + Sync + 'static, -{ - fn into_service_error(self) -> ServiceError { - Box::new(self) +impl From> for ServiceError { + fn from(err: Box) -> Self { + ServiceError::InternalError(err.to_string()) + } +} + +impl From for ServiceError { + fn from(err: DbErr) -> Self { + match err { + DbErr::RecordNotFound(msg) => ServiceError::NotFound(msg), + _ => ServiceError::DatabaseError(err.to_string()), + } } } diff --git a/apps/api/src/services/settings.rs b/apps/api/src/services/settings.rs index 847db61..20df281 100644 --- a/apps/api/src/services/settings.rs +++ b/apps/api/src/services/settings.rs @@ -7,7 +7,7 @@ use sea_orm::{ IntoActiveModel, QueryFilter, }; -use crate::errors::service_error::{IntoServiceError, ServiceError}; +use crate::errors::service_error::ServiceError; #[async_trait::async_trait] pub trait SettingsStore: Send + Sync { @@ -37,11 +37,11 @@ impl SettingsStore for SettingsService { .await; match setting { - Err(err) => Err(err.into_service_error()), - Ok(None) => Err( - DbErr::RecordNotFound(format!("Setting with key '{}' not found", key)) - .into_service_error(), - ), + Err(err) => Err(ServiceError::from(err)), + Ok(None) => Err(ServiceError::from(DbErr::RecordNotFound(format!( + "Setting with key '{}' not found", + key + )))), Ok(Some(record)) => Ok(record.value), } } @@ -62,7 +62,7 @@ impl SettingsStore for SettingsService { new_record .insert(&*self.connection) .await - .map_err(|err| err.into_service_error()) + .map_err(ServiceError::from) }; match existing { @@ -71,7 +71,7 @@ impl SettingsStore for SettingsService { handle_not_found(key.to_string(), value).await?; } _ => { - return Err(Box::new(err)); + return Err(ServiceError::from(err)); } }, Ok(None) => { @@ -83,7 +83,7 @@ impl SettingsStore for SettingsService { .into_active_model() .update(&*self.connection) .await - .map_err(|err| err.into_service_error())?; + .map_err(ServiceError::from)?; } } From e758452509a0508ace0a5502982ba802c2b59260 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 7 Dec 2025 19:08:22 +0800 Subject: [PATCH 02/19] Include user table, identity and session table --- public/database/src/generated/entities/mod.rs | 2 + .../src/generated/entities/prelude.rs | 2 + .../src/generated/entities/session.rs | 29 +++++ .../database/src/generated/entities/user.rs | 9 +- .../src/generated/entities/user_identity.rs | 35 ++++++ public/migration/src/lib.rs | 6 +- public/migration/src/migrations.rs | 6 +- ...> m20251011_000001_create_config_table.rs} | 0 ... => m20251011_000002_create_user_table.rs} | 19 +++- ...51011_000003_create_user_identity_table.rs | 102 ++++++++++++++++++ .../m20251011_000004_create_session_table.rs | 86 +++++++++++++++ 11 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 public/database/src/generated/entities/session.rs create mode 100644 public/database/src/generated/entities/user_identity.rs rename public/migration/src/migrations/{m20251011_000002_create_config_table.rs => m20251011_000001_create_config_table.rs} (100%) rename public/migration/src/migrations/{m20251011_000001_create_user_table.rs => m20251011_000002_create_user_table.rs} (77%) create mode 100644 public/migration/src/migrations/m20251011_000003_create_user_identity_table.rs create mode 100644 public/migration/src/migrations/m20251011_000004_create_session_table.rs diff --git a/public/database/src/generated/entities/mod.rs b/public/database/src/generated/entities/mod.rs index f2dcfc6..72511ee 100644 --- a/public/database/src/generated/entities/mod.rs +++ b/public/database/src/generated/entities/mod.rs @@ -3,4 +3,6 @@ pub mod prelude; pub mod config; +pub mod session; pub mod user; +pub mod user_identity; diff --git a/public/database/src/generated/entities/prelude.rs b/public/database/src/generated/entities/prelude.rs index 222250e..e73b8a2 100644 --- a/public/database/src/generated/entities/prelude.rs +++ b/public/database/src/generated/entities/prelude.rs @@ -1,4 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 pub use super::config::Entity as Config; +pub use super::session::Entity as Session; pub use super::user::Entity as User; +pub use super::user_identity::Entity as UserIdentity; diff --git a/public/database/src/generated/entities/session.rs b/public/database/src/generated/entities/session.rs new file mode 100644 index 0000000..fc26f9f --- /dev/null +++ b/public/database/src/generated/entities/session.rs @@ -0,0 +1,29 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "session")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_id: Uuid, + #[sea_orm(unique)] + pub refresh_token_hash: Option, + pub expires_at: DateTimeUtc, + pub revoked_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "user_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub user: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/user.rs b/public/database/src/generated/entities/user.rs index 1a415b4..4c01244 100644 --- a/public/database/src/generated/entities/user.rs +++ b/public/database/src/generated/entities/user.rs @@ -12,10 +12,15 @@ pub struct Model { #[sea_orm(unique)] pub name: String, pub is_admin: bool, - pub password_hash: String, - pub salt: String, + pub is_active: bool, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, + pub last_login_at: Option, + pub deleted_at: Option, + #[sea_orm(has_many)] + pub sessions: HasMany, + #[sea_orm(has_many)] + pub user_identities: HasMany, } impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/user_identity.rs b/public/database/src/generated/entities/user_identity.rs new file mode 100644 index 0000000..240558d --- /dev/null +++ b/public/database/src/generated/entities/user_identity.rs @@ -0,0 +1,35 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user_identity")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique_key = "provider")] + pub user_id: Uuid, + #[sea_orm(unique_key = "provider")] + pub provider: String, + pub email: Option, + pub password_hash: Option, + pub is_revoked: bool, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub metadata: Option, + pub password_changed_at: Option, + pub revoked_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "user_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub user: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/migration/src/lib.rs b/public/migration/src/lib.rs index 1be42ae..eecb57c 100644 --- a/public/migration/src/lib.rs +++ b/public/migration/src/lib.rs @@ -10,8 +10,10 @@ pub struct Migrator; impl MigratorTrait for Migrator { fn migrations() -> Vec> { vec![ - Box::new(m20251011_000001_create_user_table::Migration), - Box::new(m20251011_000002_create_config_table::Migration), + Box::new(m20251011_000001_create_config_table::Migration), + Box::new(m20251011_000002_create_user_table::Migration), + Box::new(m20251011_000003_create_user_identity_table::Migration), + Box::new(m20251011_000004_create_session_table::Migration), ] } } diff --git a/public/migration/src/migrations.rs b/public/migration/src/migrations.rs index 8597472..5ff5d97 100644 --- a/public/migration/src/migrations.rs +++ b/public/migration/src/migrations.rs @@ -1,2 +1,4 @@ -pub mod m20251011_000001_create_user_table; -pub mod m20251011_000002_create_config_table; +pub mod m20251011_000001_create_config_table; +pub mod m20251011_000002_create_user_table; +pub mod m20251011_000003_create_user_identity_table; +pub mod m20251011_000004_create_session_table; diff --git a/public/migration/src/migrations/m20251011_000002_create_config_table.rs b/public/migration/src/migrations/m20251011_000001_create_config_table.rs similarity index 100% rename from public/migration/src/migrations/m20251011_000002_create_config_table.rs rename to public/migration/src/migrations/m20251011_000001_create_config_table.rs diff --git a/public/migration/src/migrations/m20251011_000001_create_user_table.rs b/public/migration/src/migrations/m20251011_000002_create_user_table.rs similarity index 77% rename from public/migration/src/migrations/m20251011_000001_create_user_table.rs rename to public/migration/src/migrations/m20251011_000002_create_user_table.rs index c568e7f..515ed84 100644 --- a/public/migration/src/migrations/m20251011_000001_create_user_table.rs +++ b/public/migration/src/migrations/m20251011_000002_create_user_table.rs @@ -3,16 +3,19 @@ use sea_orm_migration::{prelude::*, schema::*}; #[derive(DeriveMigrationName)] pub struct Migration; +#[forbid(dead_code)] #[derive(DeriveIden)] -enum User { +pub enum User { Table, Id, // Name, IsAdmin, - PasswordHash, - Salt, + IsActive, // + LastLoginAt, + // + DeletedAt, CreatedAt, UpdatedAt, } @@ -33,8 +36,12 @@ impl MigrationTrait for Migration { .default(false) .not_null(), ) - .col(ColumnDef::new(User::PasswordHash).string().not_null()) - .col(ColumnDef::new(User::Salt).string().not_null()) + .col( + ColumnDef::new(User::IsActive) + .boolean() + .default(true) + .not_null(), + ) .col( ColumnDef::new(User::CreatedAt) .timestamp() @@ -47,6 +54,8 @@ impl MigrationTrait for Migration { .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) .not_null(), ) + .col(ColumnDef::new(User::LastLoginAt).timestamp().null()) + .col(ColumnDef::new(User::DeletedAt).timestamp().null()) .to_owned(), ) .await diff --git a/public/migration/src/migrations/m20251011_000003_create_user_identity_table.rs b/public/migration/src/migrations/m20251011_000003_create_user_identity_table.rs new file mode 100644 index 0000000..f85f3e6 --- /dev/null +++ b/public/migration/src/migrations/m20251011_000003_create_user_identity_table.rs @@ -0,0 +1,102 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum UserIdentity { + Table, + Id, + UserId, + Provider, // e.g. "password". Extensible for plugins like OAuth in the future + // + Email, // optional + PasswordHash, // optional for non-password providers + IsRevoked, // default false + // + Metadata, // for custom provider metadata + // + PasswordChangedAt, + RevokedAt, + // + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let _ = manager + .create_table( + Table::create() + .table(UserIdentity::Table) + .if_not_exists() + .col(pk_uuid(UserIdentity::Id)) + // + .col(ColumnDef::new(UserIdentity::UserId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-user-identity-user-id") + .from(UserIdentity::Table, UserIdentity::UserId) + .to( + super::m20251011_000002_create_user_table::User::Table, + super::m20251011_000002_create_user_table::User::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(UserIdentity::Provider).string().not_null()) + // + .col(ColumnDef::new(UserIdentity::Email).string().null()) + .col(ColumnDef::new(UserIdentity::PasswordHash).string().null()) + .col( + ColumnDef::new(UserIdentity::IsRevoked) + .boolean() + .default(false) + .not_null(), + ) + .col(ColumnDef::new(UserIdentity::Metadata).json_binary().null()) + // + .col( + ColumnDef::new(UserIdentity::PasswordChangedAt) + .timestamp() + .null(), + ) + .col(ColumnDef::new(UserIdentity::RevokedAt).timestamp().null()) + // + .col( + ColumnDef::new(UserIdentity::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(UserIdentity::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await; + + manager + .create_index( + Index::create() + .name("idx-user-identity-user-id-provider") + .table(UserIdentity::Table) + .col(UserIdentity::UserId) + .col(UserIdentity::Provider) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UserIdentity::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251011_000004_create_session_table.rs b/public/migration/src/migrations/m20251011_000004_create_session_table.rs new file mode 100644 index 0000000..c7ab7f1 --- /dev/null +++ b/public/migration/src/migrations/m20251011_000004_create_session_table.rs @@ -0,0 +1,86 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum Session { + Table, + Id, + UserId, + // + RefreshTokenHash, + // + ExpiresAt, + RevokedAt, + // + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let _ = manager + .create_table( + Table::create() + .table(Session::Table) + .if_not_exists() + .col(pk_uuid(Session::Id)) + // + .col(ColumnDef::new(Session::UserId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-session-user-id") + .from(Session::Table, Session::UserId) + .to( + super::m20251011_000002_create_user_table::User::Table, + super::m20251011_000002_create_user_table::User::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(Session::RefreshTokenHash) + .string() + .null() + .unique_key(), + ) + .col(ColumnDef::new(Session::ExpiresAt).timestamp().not_null()) + .col(ColumnDef::new(Session::RevokedAt).timestamp().null()) + // + .col( + ColumnDef::new(Session::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(Session::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await; + + manager + .create_index( + Index::create() + .name("idx-session-user-id-token") + .table(Session::Table) + .col(Session::UserId) + .col(Session::RefreshTokenHash) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Session::Table).to_owned()) + .await + } +} From 30e500ec4491a308f43ba454f842b21c36346ebf Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 7 Dec 2025 19:09:37 +0800 Subject: [PATCH 03/19] Added macro for handling both transaction and pooled connection --- apps/api/src/helpers.rs | 1 + apps/api/src/helpers/database.rs | 13 +++++++++++++ apps/api/src/main.rs | 1 + 3 files changed, 15 insertions(+) create mode 100644 apps/api/src/helpers.rs create mode 100644 apps/api/src/helpers/database.rs diff --git a/apps/api/src/helpers.rs b/apps/api/src/helpers.rs new file mode 100644 index 0000000..8fd0a6b --- /dev/null +++ b/apps/api/src/helpers.rs @@ -0,0 +1 @@ +pub mod database; diff --git a/apps/api/src/helpers/database.rs b/apps/api/src/helpers/database.rs new file mode 100644 index 0000000..9cdcde3 --- /dev/null +++ b/apps/api/src/helpers/database.rs @@ -0,0 +1,13 @@ +#[macro_export] +macro_rules! with_conn { + // Usage: with_conn!(connection, tx_option, ident, |conn|-> { ... }) + ($conn:expr, $tx:expr, $ident:ident, $body:block) => {{ + if let Some(t) = $tx { + let $ident = t; + $body + } else { + let $ident = $conn; + $body + } + }}; +} diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index ba0c82f..3eef8e1 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -1,6 +1,7 @@ mod cmd; mod configs; mod errors; +mod helpers; mod log; mod middlewares; mod routes; From 6a88e401f619a56dd7f2251eb5fbb59aed68fa54 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 7 Dec 2025 21:33:01 +0800 Subject: [PATCH 04/19] Add debug and BadRequest error --- apps/api/src/errors/service_error.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/errors/service_error.rs b/apps/api/src/errors/service_error.rs index e5d6b47..22b55dd 100644 --- a/apps/api/src/errors/service_error.rs +++ b/apps/api/src/errors/service_error.rs @@ -1,10 +1,12 @@ use sea_orm::DbErr; +#[derive(Debug)] pub enum ServiceError { NotFound(String), DatabaseError(String), Unauthorized(String), InternalError(String), + BadRequest(String), } impl From> for ServiceError { From 6cd37d675899bdbc4eabd8c175b9d1a843ecd8de Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 7 Dec 2025 21:35:10 +0800 Subject: [PATCH 05/19] use ref of transaction --- apps/api/src/helpers/database.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/helpers/database.rs b/apps/api/src/helpers/database.rs index 9cdcde3..da8f12c 100644 --- a/apps/api/src/helpers/database.rs +++ b/apps/api/src/helpers/database.rs @@ -1,12 +1,12 @@ #[macro_export] macro_rules! with_conn { - // Usage: with_conn!(connection, tx_option, ident, |conn|-> { ... }) + // Usage: with_conn!(&connection, tx_option, ident, |conn|-> { ... }) ($conn:expr, $tx:expr, $ident:ident, $body:block) => {{ - if let Some(t) = $tx { + if let Some(t) = &$tx { let $ident = t; $body } else { - let $ident = $conn; + let $ident = &$conn; $body } }}; From 9447b64a7642a37e7fa4db4b9551f0b9d63d4830 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 7 Dec 2025 21:35:50 +0800 Subject: [PATCH 06/19] feat: add argon2, jsonwebtoken, and update uuid dependencies --- Cargo.lock | 128 +++++++++++++++++++++++++++++++++++++++----- apps/api/Cargo.toml | 3 ++ 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6690d9c..63168a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,18 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -282,6 +294,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1612,6 +1633,22 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "10.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" +dependencies = [ + "base64 0.22.1", + "getrandom 0.2.16", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1797,7 +1834,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1991,6 +2028,17 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "path-clean" version = "1.0.1" @@ -2003,6 +2051,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2254,8 +2312,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -2265,7 +2333,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -2277,6 +2355,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -2423,7 +2510,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2450,7 +2537,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.5", "rkyv", "serde", "serde_json", @@ -2987,7 +3074,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2996,6 +3083,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.11" @@ -3164,7 +3263,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "rust_decimal", "serde", @@ -3208,7 +3307,7 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand", + "rand 0.8.5", "rust_decimal", "serde", "serde_json", @@ -3847,12 +3946,14 @@ dependencies = [ [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ + "getrandom 0.3.4", "js-sys", - "serde", + "rand 0.9.2", + "serde_core", "wasm-bindgen", ] @@ -4350,6 +4451,7 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" name = "yet-another-nginx-proxy-manager" version = "0.1.0" dependencies = [ + "argon2", "async-trait", "axum", "chrono", @@ -4357,6 +4459,7 @@ dependencies = [ "config", "database", "include_dir", + "jsonwebtoken", "migration", "mime_guess", "once_cell", @@ -4368,6 +4471,7 @@ dependencies = [ "tracing", "tracing-subscriber", "utoipa", + "uuid", ] [[package]] diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index a3a1a00..a86ee30 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -23,3 +23,6 @@ mime_guess = { version = "2.0.5" } utoipa = { version = "5.4.0", features = ["macros", "axum_extras", "chrono", "decimal", "uuid", "time", "openapi_extensions"] } clap = { version = "4.5.53" } once_cell = { version = "1.21.3" } +argon2 = { version = "0.5.3", features = ["std"] } +jsonwebtoken = { version = "10.2.0" } +uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } From b17d111c5d4d711c8c4c71533d772a396a9cb3ac Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:20:28 +0800 Subject: [PATCH 07/19] remove unused session table --- public/database/src/generated/entities/mod.rs | 1 - .../src/generated/entities/prelude.rs | 1 - .../database/src/generated/entities/user.rs | 2 - public/migration/src/lib.rs | 1 - public/migration/src/migrations.rs | 1 - .../m20251011_000004_create_session_table.rs | 86 ------------------- 6 files changed, 92 deletions(-) delete mode 100644 public/migration/src/migrations/m20251011_000004_create_session_table.rs diff --git a/public/database/src/generated/entities/mod.rs b/public/database/src/generated/entities/mod.rs index 72511ee..1917343 100644 --- a/public/database/src/generated/entities/mod.rs +++ b/public/database/src/generated/entities/mod.rs @@ -3,6 +3,5 @@ pub mod prelude; pub mod config; -pub mod session; pub mod user; pub mod user_identity; diff --git a/public/database/src/generated/entities/prelude.rs b/public/database/src/generated/entities/prelude.rs index e73b8a2..f0df089 100644 --- a/public/database/src/generated/entities/prelude.rs +++ b/public/database/src/generated/entities/prelude.rs @@ -1,6 +1,5 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 pub use super::config::Entity as Config; -pub use super::session::Entity as Session; pub use super::user::Entity as User; pub use super::user_identity::Entity as UserIdentity; diff --git a/public/database/src/generated/entities/user.rs b/public/database/src/generated/entities/user.rs index 4c01244..ab2edb2 100644 --- a/public/database/src/generated/entities/user.rs +++ b/public/database/src/generated/entities/user.rs @@ -18,8 +18,6 @@ pub struct Model { pub last_login_at: Option, pub deleted_at: Option, #[sea_orm(has_many)] - pub sessions: HasMany, - #[sea_orm(has_many)] pub user_identities: HasMany, } diff --git a/public/migration/src/lib.rs b/public/migration/src/lib.rs index eecb57c..83e0779 100644 --- a/public/migration/src/lib.rs +++ b/public/migration/src/lib.rs @@ -13,7 +13,6 @@ impl MigratorTrait for Migrator { Box::new(m20251011_000001_create_config_table::Migration), Box::new(m20251011_000002_create_user_table::Migration), Box::new(m20251011_000003_create_user_identity_table::Migration), - Box::new(m20251011_000004_create_session_table::Migration), ] } } diff --git a/public/migration/src/migrations.rs b/public/migration/src/migrations.rs index 5ff5d97..2ff1c48 100644 --- a/public/migration/src/migrations.rs +++ b/public/migration/src/migrations.rs @@ -1,4 +1,3 @@ pub mod m20251011_000001_create_config_table; pub mod m20251011_000002_create_user_table; pub mod m20251011_000003_create_user_identity_table; -pub mod m20251011_000004_create_session_table; diff --git a/public/migration/src/migrations/m20251011_000004_create_session_table.rs b/public/migration/src/migrations/m20251011_000004_create_session_table.rs deleted file mode 100644 index c7ab7f1..0000000 --- a/public/migration/src/migrations/m20251011_000004_create_session_table.rs +++ /dev/null @@ -1,86 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum Session { - Table, - Id, - UserId, - // - RefreshTokenHash, - // - ExpiresAt, - RevokedAt, - // - CreatedAt, - UpdatedAt, -} - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - let _ = manager - .create_table( - Table::create() - .table(Session::Table) - .if_not_exists() - .col(pk_uuid(Session::Id)) - // - .col(ColumnDef::new(Session::UserId).uuid().not_null()) - .foreign_key( - ForeignKey::create() - .name("fk-session-user-id") - .from(Session::Table, Session::UserId) - .to( - super::m20251011_000002_create_user_table::User::Table, - super::m20251011_000002_create_user_table::User::Id, - ) - .on_delete(ForeignKeyAction::Cascade) - .on_update(ForeignKeyAction::Cascade), - ) - .col( - ColumnDef::new(Session::RefreshTokenHash) - .string() - .null() - .unique_key(), - ) - .col(ColumnDef::new(Session::ExpiresAt).timestamp().not_null()) - .col(ColumnDef::new(Session::RevokedAt).timestamp().null()) - // - .col( - ColumnDef::new(Session::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .col( - ColumnDef::new(Session::UpdatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .to_owned(), - ) - .await; - - manager - .create_index( - Index::create() - .name("idx-session-user-id-token") - .table(Session::Table) - .col(Session::UserId) - .col(Session::RefreshTokenHash) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(Session::Table).to_owned()) - .await - } -} From 1233f3b736a65668a7e06aeec22b086d42e64909 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:50:43 +0800 Subject: [PATCH 08/19] fix: implement Display trait for ServiceError enum --- apps/api/src/errors/service_error.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/api/src/errors/service_error.rs b/apps/api/src/errors/service_error.rs index 22b55dd..bd99ad8 100644 --- a/apps/api/src/errors/service_error.rs +++ b/apps/api/src/errors/service_error.rs @@ -15,6 +15,20 @@ impl From> for ServiceError { } } +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ServiceError::NotFound(msg) => write!(f, "Not Found: {}", msg), + ServiceError::DatabaseError(msg) => write!(f, "Database Error: {}", msg), + ServiceError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), + ServiceError::InternalError(msg) => write!(f, "Internal Error: {}", msg), + ServiceError::BadRequest(msg) => write!(f, "Bad Request: {}", msg), + } + } +} + +impl std::error::Error for ServiceError {} + impl From for ServiceError { fn from(err: DbErr) -> Self { match err { From 3354154b8774239c1772a3771cb23b7d2e92d880 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:51:45 +0800 Subject: [PATCH 09/19] feat: implement authentication module with JWT support and user management --- Cargo.lock | 211 ++++++++ apps/api/Cargo.toml | 2 +- apps/api/src/cmd/start_server.rs | 19 +- apps/api/src/configs.rs | 4 + apps/api/src/configs/auth.rs | 51 ++ apps/api/src/configs/key.rs | 4 + apps/api/src/routes.rs | 12 +- apps/api/src/services.rs | 1 + apps/api/src/services/auth.rs | 2 + apps/api/src/services/auth/authentication.rs | 269 +++++++++++ .../auth/authentication/strategies.rs | 1 + .../authentication/strategies/password.rs | 453 ++++++++++++++++++ apps/api/src/services/auth/user.rs | 208 ++++++++ 13 files changed, 1232 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/configs/auth.rs create mode 100644 apps/api/src/services/auth.rs create mode 100644 apps/api/src/services/auth/authentication.rs create mode 100644 apps/api/src/services/auth/authentication/strategies.rs create mode 100644 apps/api/src/services/auth/authentication/strategies/password.rs create mode 100644 apps/api/src/services/auth/user.rs diff --git a/Cargo.lock b/Cargo.lock index 63168a3..06944b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,12 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -672,6 +678,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -682,6 +700,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "darling" version = "0.20.11" @@ -862,6 +907,44 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -871,6 +954,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -946,6 +1050,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.26" @@ -1125,6 +1245,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1156,6 +1277,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.12" @@ -1640,11 +1772,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" dependencies = [ "base64 0.22.1", + "ed25519-dalek", "getrandom 0.2.16", + "hmac", "js-sys", + "p256", + "p384", "pem", + "rand 0.8.5", + "rsa", "serde", "serde_json", + "sha2", "signature", "simple_asn1", ] @@ -1974,6 +2113,30 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2211,6 +2374,15 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -2440,6 +2612,16 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2543,6 +2725,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.2" @@ -2852,6 +3043,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -2888,6 +3093,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index a86ee30..3ec1a3d 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -24,5 +24,5 @@ utoipa = { version = "5.4.0", features = ["macros", "axum_extras", "chrono", "de clap = { version = "4.5.53" } once_cell = { version = "1.21.3" } argon2 = { version = "0.5.3", features = ["std"] } -jsonwebtoken = { version = "10.2.0" } +jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } diff --git a/apps/api/src/cmd/start_server.rs b/apps/api/src/cmd/start_server.rs index 461c0b3..d1d1ce8 100644 --- a/apps/api/src/cmd/start_server.rs +++ b/apps/api/src/cmd/start_server.rs @@ -12,7 +12,10 @@ use crate::{ configs::{ProgramSettings, get_program_settings, logging::LoggingSettings}, log, routes::{self, AppService, AppState}, - services::settings::SettingsService, + services::{ + auth::{authentication::AuthenticationServiceImpl, user::UserServiceImpl}, + settings::SettingsService, + }, tasks, }; @@ -58,6 +61,9 @@ pub async fn start_server() { tasks::startup::run_startup_tasks(&settings) .await + .inspect_err(|err| { + tracing::error!("Failed to run startup tasks: {}", err); + }) .expect("Failed to run startup tasks"); // setup database connection pool @@ -78,7 +84,7 @@ pub async fn start_server() { // build the axum app and run the server... info!("Starting application..."); - let app: Router = routes::get_root_router(Arc::new(get_app_state(&db_connection))); + let app: Router = routes::get_root_router(Arc::new(get_app_state(&db_connection, &settings))); let address = format!("{}:{}", settings.server.address, settings.server.port); info!("Starting server at http://{}", address); @@ -115,11 +121,18 @@ fn get_global_tracing_subscriber_builder( } } -fn get_app_state(db_connection: &Arc) -> AppState { +fn get_app_state( + db_connection: &Arc, + settings: &ProgramSettings, +) -> AppState { AppState { database_connection: db_connection.clone(), service: Arc::new(AppService { settings: Arc::new(SettingsService::new(db_connection.clone())), + authentication: Arc::new(AuthenticationServiceImpl::new( + settings.auth.jwt_secret.clone(), + )), + user: Arc::new(UserServiceImpl::new(db_connection.clone())), }), } } diff --git a/apps/api/src/configs.rs b/apps/api/src/configs.rs index cae85de..6c274d6 100644 --- a/apps/api/src/configs.rs +++ b/apps/api/src/configs.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod database; pub mod logging; pub mod server; @@ -17,6 +18,7 @@ pub struct ProgramSettings { pub logging: logging::LoggingSettings, pub database: database::DatabaseSettings, pub server: server::ServerSettings, + pub auth: auth::AuthSettings, } impl FromConfig for ProgramSettings { @@ -25,6 +27,7 @@ impl FromConfig for ProgramSettings { logging: logging::LoggingSettings::from_config(_config)?, database: database::DatabaseSettings::from_config(_config)?, server: server::ServerSettings::from_config(_config)?, + auth: auth::AuthSettings::from_config(_config)?, }; config.validate()?; Ok(config) @@ -34,6 +37,7 @@ impl FromConfig for ProgramSettings { self.logging.validate()?; self.database.validate()?; self.server.validate()?; + self.auth.validate()?; Ok(()) } } diff --git a/apps/api/src/configs/auth.rs b/apps/api/src/configs/auth.rs new file mode 100644 index 0000000..4041092 --- /dev/null +++ b/apps/api/src/configs/auth.rs @@ -0,0 +1,51 @@ +use config::{Config, ConfigError}; +use tracing::warn; + +use crate::configs::key::{ + AUTH_DEFAULT_ADMIN_PASSWORD_KEY, AUTH_DEFAULT_ADMIN_USERNAME_KEY, AUTH_JWT_SECRET_KEY, +}; + +use super::FromConfig; + +#[derive(Debug, Clone)] +pub struct AuthSettings { + pub jwt_secret: Option, + pub default_admin_username: Option, + pub default_admin_password: Option, +} + +impl FromConfig for AuthSettings { + fn from_config(_config: &Config) -> Result { + Ok(AuthSettings { + jwt_secret: _config + .get_string(AUTH_JWT_SECRET_KEY) + .inspect_err(|err| { + match err { + ConfigError::NotFound(_) => { + warn!( + "{} not found in configuration, A random secret will be generated at runtime.", + AUTH_JWT_SECRET_KEY + ); + } + _ => { + warn!( + "Failed to read {} from configuration, A random secret will be generated at runtime: {}", + AUTH_JWT_SECRET_KEY, err + ); + } + }; + }) + .ok(), + default_admin_username: _config + .get_string(AUTH_DEFAULT_ADMIN_USERNAME_KEY) + .ok(), + default_admin_password: _config + .get_string(AUTH_DEFAULT_ADMIN_PASSWORD_KEY) + .ok(), + }) + } + + fn validate(&self) -> Result<(), String> { + Ok(()) + } +} diff --git a/apps/api/src/configs/key.rs b/apps/api/src/configs/key.rs index dd31902..c4a0ee9 100644 --- a/apps/api/src/configs/key.rs +++ b/apps/api/src/configs/key.rs @@ -7,3 +7,7 @@ pub(crate) const SERVER_PORT_KEY: &str = "SERVER.PORT"; pub(crate) const DATABASE_URL_KEY: &str = "DATABASE.URL"; pub(crate) const DATABASE_MAX_CONNECTIONS_KEY: &str = "DATABASE.MAX_CONNECTIONS"; pub(crate) const DATABASE_MIGRATE_ON_STARTUP_KEY: &str = "DATABASE.MIGRATION.MIGRATE_ON_STARTUP"; +// +pub(crate) const AUTH_JWT_SECRET_KEY: &str = "AUTH.JWT_SECRET"; +pub(crate) const AUTH_DEFAULT_ADMIN_USERNAME_KEY: &str = "AUTH.DEFAULT_ADMIN_USERNAME"; +pub(crate) const AUTH_DEFAULT_ADMIN_PASSWORD_KEY: &str = "AUTH.DEFAULT_ADMIN_PASSWORD"; diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index f257f1b..59e181e 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -8,7 +8,13 @@ use std::sync::Arc; use axum::{Extension, Router}; use migration::sea_orm::DatabaseConnection; -use crate::{middlewares, services::settings::SettingsStore}; +use crate::{ + middlewares, + services::{ + auth::{authentication::AuthenticationService, user::UserService}, + settings::SettingsStore, + }, +}; #[derive(Clone)] pub struct AppState { @@ -25,6 +31,10 @@ pub type ServiceState = Arc; pub struct AppService { #[allow(dead_code)] // TODO: remove when used pub settings: ServiceState, + #[allow(dead_code)] // TODO: remove when used + pub authentication: ServiceState, + #[allow(dead_code)] // TODO: remove when used + pub user: ServiceState, } pub fn get_root_router(state: impl Into>) -> Router { diff --git a/apps/api/src/services.rs b/apps/api/src/services.rs index 6e98cef..f7da917 100644 --- a/apps/api/src/services.rs +++ b/apps/api/src/services.rs @@ -1 +1,2 @@ +pub mod auth; pub mod settings; diff --git a/apps/api/src/services/auth.rs b/apps/api/src/services/auth.rs new file mode 100644 index 0000000..0e84b15 --- /dev/null +++ b/apps/api/src/services/auth.rs @@ -0,0 +1,2 @@ +pub mod authentication; +pub mod user; diff --git a/apps/api/src/services/auth/authentication.rs b/apps/api/src/services/auth/authentication.rs new file mode 100644 index 0000000..2bbaae1 --- /dev/null +++ b/apps/api/src/services/auth/authentication.rs @@ -0,0 +1,269 @@ +pub mod strategies; + +use std::{collections::HashSet, sync::Arc}; + +use argon2::password_hash::{SaltString, rand_core::OsRng}; +use jsonwebtoken::{ + DecodingKey, EncodingKey, Header, Validation, decode, encode, + errors::ErrorKind::{ExpiredSignature, InvalidSubject, InvalidToken}, +}; +use sea_orm::prelude::Uuid; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use crate::errors::service_error::ServiceError; + +// Number of requests between invalidation cache cleanups +const INVALIDATE_CACHE_CLEANUP_INTERVAL_REQUESTS: usize = 100; // Cleanup every 100 for invalidation checks + +#[derive(Serialize, Deserialize, Clone)] +pub struct Claims { + // Subject - user ID + pub sub: String, + // Issued at as UNIX timestamp + pub iat: u64, + // Expiration time as UNIX timestamp + pub exp: u64, +} + +#[async_trait::async_trait] +pub trait AuthenticationService: Send + Sync { + async fn generate_jwt(&self, user_id: Uuid, duration_secs: u64) + -> Result; + async fn is_valid_jwt( + &self, + token: &str, + target_sub: Option, + ) -> Result; + async fn parse_jwt(&self, token: &str) -> Result; + async fn invalidate_jwt(&self, token: &str) -> Result<(), ServiceError>; + async fn refresh_jwt(&self, token: &str, duration_secs: u64) -> Result; + async fn logout(&self, token: &str) -> Result<(), ServiceError>; + async fn cleanup_invalidation_cache(&self); +} + +#[derive(Eq, Hash, PartialEq)] +struct InvalidationEntry { + token: String, + invalidated_at: u64, + valid_until: u64, +} + +pub struct AuthenticationServiceImpl { + secret: String, + invalidation_cache: Arc>>, + cache_cleanup_counter: Arc>, +} + +impl AuthenticationServiceImpl { + pub fn new(secret: Option) -> Self { + let secret = secret.unwrap_or_else(|| { + // generate a random secret if none is provided + SaltString::generate(&mut OsRng).as_str().to_owned() + }); + + Self { + secret, + invalidation_cache: Arc::new(RwLock::new(HashSet::new())), + cache_cleanup_counter: Arc::new(RwLock::new(0)), + } + } +} + +#[async_trait::async_trait] +impl AuthenticationService for AuthenticationServiceImpl { + async fn generate_jwt( + &self, + user_id: Uuid, + duration_secs: u64, + ) -> Result { + let header = Header::default(); + let expiration = chrono::Utc::now() + .checked_add_signed(chrono::Duration::seconds(duration_secs as i64)) + .ok_or(ServiceError::InternalError( + "Invalid expiration time".into(), + ))? + .timestamp() as u64; + let claims = Claims { + sub: user_id.to_string(), + iat: chrono::Utc::now().timestamp() as u64, + exp: expiration, + }; + let token = encode( + &header, + &claims, + &EncodingKey::from_secret(self.secret.as_ref()), + ) + .map_err(|e| ServiceError::InternalError(format!("JWT generation error: {}", e)))?; + Ok(token) + } + + async fn is_valid_jwt( + &self, + token: &str, + target_sub: Option, + ) -> Result { + let mut validation = Validation::default(); + if let Some(expected_sub) = target_sub { + validation.sub = Some(expected_sub); + } + let decoding_key = DecodingKey::from_secret(self.secret.as_ref()); + match decode::(token, &decoding_key, &validation) { + Ok(_) => Ok(true), + Err(err) => match *err.kind() { + InvalidToken | InvalidSubject | ExpiredSignature => Ok(false), + _ => Err(ServiceError::InternalError(format!( + "JWT validation error: {}", + err + ))), + }, + } + } + + async fn parse_jwt(&self, token: &str) -> Result { + let decoding_key = DecodingKey::from_secret(self.secret.as_ref()); + let token_data = decode::(token, &decoding_key, &Validation::default()) + .map_err(|e| ServiceError::InternalError(format!("JWT parsing error: {}", e)))?; + Ok(token_data.claims) + } + + async fn invalidate_jwt(&self, token: &str) -> Result<(), ServiceError> { + let claims = self.parse_jwt(token).await?; + let valid_until = claims.exp; + let invalidated_at = chrono::Utc::now().timestamp() as u64; + let entry = InvalidationEntry { + token: token.to_string(), + invalidated_at, + valid_until, + }; + + { + self.invalidation_cache.write().await.insert(entry); + } + // + if self.cache_cleanup_counter.read().await.wrapping_add(1) + % INVALIDATE_CACHE_CLEANUP_INTERVAL_REQUESTS + == 0 + { + self.cleanup_invalidation_cache().await; + } + // + Ok(()) + } + + async fn refresh_jwt(&self, token: &str, duration_secs: u64) -> Result { + let claims = self.parse_jwt(token).await?; + let user_id = Uuid::parse_str(&claims.sub).map_err(|e| { + ServiceError::InternalError(format!("Invalid user ID in JWT claims: {}", e)) + })?; + let new_token = self.generate_jwt(user_id, duration_secs).await?; + Ok(new_token) + } + + async fn logout(&self, token: &str) -> Result<(), ServiceError> { + self.invalidate_jwt(token).await + } + + async fn cleanup_invalidation_cache(&self) { + let now = chrono::Utc::now().timestamp() as u64; + let mut cache = self.invalidation_cache.write().await; + cache.retain(|entry| entry.valid_until > now); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::time::{Duration, sleep}; + + #[tokio::test] + async fn test_jwt_generation_and_validation() { + let service = AuthenticationServiceImpl::new(Some("secret".to_string())); + + let user_id = Uuid::new_v4(); + let token = service + .generate_jwt(user_id, 60) + .await + .expect("generate jwt"); + + let valid = service + .is_valid_jwt(&token, None) + .await + .expect("validate jwt"); + assert!(valid, "Generated token should be valid"); + + let claims = service.parse_jwt(&token).await.expect("parse jwt"); + assert_eq!(claims.sub, user_id.to_string()); + } + + #[tokio::test] + async fn test_jwt_validation_with_wrong_subject() { + let service = AuthenticationServiceImpl::new(Some("secret".to_string())); + + let user_id = Uuid::new_v4(); + let token = service.generate_jwt(user_id, 60).await.unwrap(); + + let other_sub = Uuid::new_v4().to_string(); + let valid = service.is_valid_jwt(&token, Some(other_sub)).await.unwrap(); + assert!(!valid, "Token should be invalid for a different subject"); + } + + #[tokio::test] + async fn test_parse_jwt_invalid_token() { + let service = AuthenticationServiceImpl::new(Some("secret".to_string())); + + let res = service.parse_jwt("not_a_token").await; + assert!(matches!(res, Err(ServiceError::InternalError(_)))); + } + + #[tokio::test] + async fn test_refresh_jwt() { + let service = AuthenticationServiceImpl::new(Some("secret".to_string())); + + let user_id = Uuid::new_v4(); + let token = service.generate_jwt(user_id, 60).await.unwrap(); + let new_token = service.refresh_jwt(&token, 120).await.unwrap(); + + let claims = service.parse_jwt(&new_token).await.unwrap(); + assert_eq!(claims.sub, user_id.to_string()); + assert_eq!(claims.exp - claims.iat, 120); + } + + #[tokio::test] + async fn test_is_valid_jwt_expired() { + let service = AuthenticationServiceImpl::new(Some("secret".to_string())); + + let user_id = Uuid::new_v4(); + let token = service.generate_jwt(user_id, 1).await.unwrap(); + sleep(Duration::from_secs(2)).await; + + let valid = service.is_valid_jwt(&token, None).await.unwrap(); + assert!(!valid, "Token should be expired and thus invalid"); + } + + #[tokio::test] + async fn test_invalidate_and_cleanup() { + let service = AuthenticationServiceImpl::new(Some("secret".to_string())); + + let user_id = Uuid::new_v4(); + let token = service.generate_jwt(user_id, 1).await.unwrap(); + + service.invalidate_jwt(&token).await.unwrap(); + + // ensure entry is present + { + let cache = service.invalidation_cache.read().await; + assert!(cache.iter().any(|e| e.token == token)); + } + + // wait until token validity ends and cleanup + sleep(Duration::from_secs(2)).await; + service.cleanup_invalidation_cache().await; + + let cache = service.invalidation_cache.read().await; + assert!( + cache.is_empty(), + "Cleanup should remove expired invalidation entries" + ); + } +} diff --git a/apps/api/src/services/auth/authentication/strategies.rs b/apps/api/src/services/auth/authentication/strategies.rs new file mode 100644 index 0000000..c72e4b9 --- /dev/null +++ b/apps/api/src/services/auth/authentication/strategies.rs @@ -0,0 +1 @@ +pub mod password; diff --git a/apps/api/src/services/auth/authentication/strategies/password.rs b/apps/api/src/services/auth/authentication/strategies/password.rs new file mode 100644 index 0000000..396fbbb --- /dev/null +++ b/apps/api/src/services/auth/authentication/strategies/password.rs @@ -0,0 +1,453 @@ +use std::sync::Arc; + +use crate::{errors::service_error::ServiceError, with_conn}; +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; +use database::generated::entities::{user, user_identity}; +use sea_orm::{ + ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, IntoActiveModel, + QueryFilter, prelude::Uuid, +}; + +pub struct PasswordStrategy { + connection: Arc, +} + +const MAX_PASSWORD_LENGTH: usize = 32; +const PASSWORD_PROVIDER: &str = "password"; + +impl PasswordStrategy { + pub fn new(connection: Arc) -> Self { + Self { connection } + } + + pub async fn authenticate( + &self, + username: &str, + password: &str, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + // Find user by username + let user = with_conn!(&*self.connection, tx, conn, { + user::Entity::find() + .filter(user::Column::Name.eq(username)) + .one(*conn) + .await? + .ok_or_else(|| { + ServiceError::Unauthorized("Invalid username or password".to_string()) + })? + }); + // Get user's identity + let identity = with_conn!(&*self.connection, tx, conn, { + user_identity::Entity::find() + .filter(user_identity::Column::UserId.eq(user.id)) + .one(*conn) + .await? + .ok_or_else(|| { + ServiceError::Unauthorized("Invalid username or password".to_string()) + })? + }); + + // Check if revoked + if identity.is_revoked { + return Err(ServiceError::Unauthorized("Account is revoked".to_string())); + } + + // Verify password + let password_hash = identity + .password_hash + .ok_or_else(|| ServiceError::InternalError("Invalid password hash".to_string()))?; + let parsed_hash = PasswordHash::new(&password_hash) + .map_err(|_| ServiceError::InternalError("Invalid password hash".to_string()))?; + + Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .map_err(|_| ServiceError::Unauthorized("Invalid username or password".to_string()))?; + + Ok(user.id) + } + + pub async fn revoke_identity( + &self, + user_id: Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let mut identity = with_conn!(&*self.connection, tx, conn, { + user_identity::Entity::find() + .filter(user_identity::Column::UserId.eq(user_id)) + .one(*conn) + .await? + .ok_or_else(|| ServiceError::NotFound("User identity not found".to_string()))? + }); + + identity.is_revoked = true; + + with_conn!(&*self.connection, tx, conn, { + user_identity::Entity::update(identity.into_active_model()) + .exec(*conn) + .await + .map_err(ServiceError::from) + })?; + + Ok(()) + } + + pub async fn create_identity( + &self, + user_id: Uuid, + password: &str, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + Self::is_valid_password(password).map_err(ServiceError::BadRequest)?; + + let password_hash = Argon2::default() + .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) + .map_err(|_| ServiceError::InternalError("Failed to hash password".to_string()))? + .to_string(); + + let new_identity = user_identity::ActiveModel { + user_id: sea_orm::ActiveValue::Set(user_id), + provider: sea_orm::ActiveValue::Set(PASSWORD_PROVIDER.to_string()), + password_hash: sea_orm::ActiveValue::Set(Some(password_hash)), + metadata: sea_orm::ActiveValue::Set(None), + is_revoked: sea_orm::ActiveValue::Set(false), + ..Default::default() + }; + + with_conn!(&*self.connection, tx, conn, { + user_identity::Entity::insert(new_identity) + .exec(*conn) + .await + .map_err(ServiceError::from) + })?; + + Ok(()) + } + + pub async fn update_password( + &self, + user_id: Uuid, + new_password: &str, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + Self::is_valid_password(new_password).map_err(ServiceError::BadRequest)?; + + let password_hash = Argon2::default() + .hash_password(new_password.as_bytes(), &SaltString::generate(&mut OsRng)) + .map_err(|_| ServiceError::InternalError("Failed to hash password".to_string()))? + .to_string(); + + let mut identity = with_conn!(&*self.connection, tx, conn, { + user_identity::Entity::find() + .filter(user_identity::Column::UserId.eq(user_id)) + .one(*conn) + .await? + .ok_or_else(|| ServiceError::NotFound("User identity not found".to_string()))? + }); + + identity.password_hash = Some(password_hash); + identity.password_changed_at = Some(chrono::Utc::now()); + + with_conn!(&*self.connection, tx, conn, { + user_identity::Entity::update(identity.into_active_model()) + .exec(*conn) + .await + .map_err(ServiceError::from) + })?; + + Ok(()) + } + + fn is_valid_password(password: &str) -> Result<(), String> { + if password.is_empty() { + return Err("Password cannot be empty".to_string()); + } + if password.len() > MAX_PASSWORD_LENGTH { + return Err(format!( + "Password cannot be longer than {} characters", + MAX_PASSWORD_LENGTH + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use database::generated::entities::{user, user_identity}; + use sea_orm::MockDatabase; + + #[test] + fn ensure_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + #[test] + fn password_validation() { + let valid_password = "ValidPassword123!"; + let long_password = "a".repeat(129); + + assert!(PasswordStrategy::is_valid_password(valid_password).is_ok()); + assert!(PasswordStrategy::is_valid_password(long_password.as_str()).is_err()); + } + + #[tokio::test] + async fn authenticate_user_not_found() { + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let strategy = PasswordStrategy::new(Arc::new(db)); + + let result = strategy + .authenticate("nonexistent_user", "password", None) + .await; + + assert!(matches!(result, Err(ServiceError::Unauthorized(_)))); + } + + #[tokio::test] + async fn authenticate_invalid_password() { + let user_id = Uuid::new_v4(); + let password_hash = Argon2::default() + .hash_password( + "CorrectPassword".as_bytes(), + &SaltString::generate(&mut OsRng), + ) + .unwrap() + .to_string(); + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![vec![user::Model { + id: user_id, + name: "test_user".to_string(), + is_active: true, + is_admin: false, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + deleted_at: None, + last_login_at: None, + }]]) + .append_query_results(vec![vec![user_identity::Model { + id: Uuid::new_v4(), + user_id, + email: None, + provider: PASSWORD_PROVIDER.to_string(), + password_hash: Some(password_hash), + metadata: None, + is_revoked: false, + revoked_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + password_changed_at: None, + }]]) + .into_connection(); + + let strategy = PasswordStrategy::new(Arc::new(db)); + + let result = strategy + .authenticate("test_user", "InvalidPassword", None) + .await; + + assert!(matches!(result, Err(ServiceError::Unauthorized(_)))); + } + + #[tokio::test] + async fn authenticate_success() { + let user_id = Uuid::new_v4(); + let password_hash = Argon2::default() + .hash_password( + "CorrectPassword".as_bytes(), + &SaltString::generate(&mut OsRng), + ) + .unwrap() + .to_string(); + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![vec![user::Model { + id: user_id, + name: "test_user".to_string(), + is_active: true, + is_admin: false, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + deleted_at: None, + last_login_at: None, + }]]) + .append_query_results(vec![vec![user_identity::Model { + id: Uuid::new_v4(), + user_id, + email: None, + provider: PASSWORD_PROVIDER.to_string(), + password_hash: Some(password_hash), + metadata: None, + is_revoked: false, + revoked_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + password_changed_at: None, + }]]) + .into_connection(); + + let strategy = PasswordStrategy::new(Arc::new(db)); + + let result = strategy + .authenticate("test_user", "CorrectPassword", None) + .await; + + assert!(matches!(result, Ok(id) if id == user_id)); + } + + #[tokio::test] + async fn revoke_identity_not_found() { + let user_id = Uuid::new_v4(); + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let strategy = PasswordStrategy::new(Arc::new(db)); + + let result = strategy.revoke_identity(user_id, None).await; + + assert!(matches!(result, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn revoke_identity_success() { + let user_id = Uuid::new_v4(); + let identity = user_identity::Model { + id: Uuid::new_v4(), + user_id, + email: None, + provider: PASSWORD_PROVIDER.to_string(), + password_hash: None, + metadata: None, + is_revoked: false, + revoked_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + password_changed_at: None, + }; + + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![ + vec![identity.clone()], + vec![user_identity::Model { + is_revoked: true, + ..identity + }], + ]) + .into_connection(); + + let strategy = PasswordStrategy::new(Arc::new(db)); + + let result = strategy.revoke_identity(user_id, None).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn create_identity_invalid_password() { + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite).into_connection(); + + let strategy = PasswordStrategy::new(Arc::new(db)); + + let result = strategy.create_identity(Uuid::new_v4(), "", None).await; + + assert!(matches!(result, Err(ServiceError::BadRequest(_)))); + } + + #[tokio::test] + async fn create_identity_success() { + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![vec![user_identity::Model { + id: Uuid::new_v4(), + user_id: Uuid::new_v4(), + email: None, + provider: PASSWORD_PROVIDER.to_string(), + password_hash: Some("somehash".to_string()), + metadata: None, + is_revoked: false, + revoked_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + password_changed_at: None, + }]]) + .into_connection(); + + let strategy = PasswordStrategy::new(Arc::new(db)); + + let result = strategy + .create_identity(Uuid::new_v4(), "ValidPass1!", None) + .await; + + assert!( + result.is_ok(), + "Failed to create identity, error: {:?}", + result.err() + ); + } + + #[tokio::test] + async fn update_password_not_found() { + let user_id = Uuid::new_v4(); + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let strategy = PasswordStrategy::new(Arc::new(db)); + + let result = strategy.update_password(user_id, "NewPass1!", None).await; + + assert!(matches!(result, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn update_password_success() { + let user_id = Uuid::new_v4(); + let identity = user_identity::Model { + id: Uuid::new_v4(), + user_id, + email: None, + provider: PASSWORD_PROVIDER.to_string(), + password_hash: Some("oldhash".to_string()), + metadata: None, + is_revoked: false, + revoked_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + password_changed_at: None, + }; + + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![ + vec![identity], + vec![user_identity::Model { + id: Uuid::new_v4(), + user_id, + email: None, + provider: PASSWORD_PROVIDER.to_string(), + password_hash: Some("newhash".to_string()), + metadata: None, + is_revoked: false, + revoked_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + password_changed_at: None, + }], + ]) + .into_connection(); + + let strategy = PasswordStrategy::new(Arc::new(db)); + + let result = strategy.update_password(user_id, "NewPass1!", None).await; + + assert!( + result.is_ok(), + "Failed to update password, error: {:?}", + result.err() + ); + } +} diff --git a/apps/api/src/services/auth/user.rs b/apps/api/src/services/auth/user.rs new file mode 100644 index 0000000..58abbfa --- /dev/null +++ b/apps/api/src/services/auth/user.rs @@ -0,0 +1,208 @@ +use std::sync::Arc; + +use database::generated::entities::user::{ + self, ActiveModel as UserActiveModel, Model as UserModel, +}; +use sea_orm::{ + ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, DatabaseTransaction, DbErr, + EntityTrait, IntoActiveModel, QueryFilter, prelude::Uuid, +}; + +use crate::{errors::service_error::ServiceError, with_conn}; + +#[async_trait::async_trait] +pub trait UserService: Send + Sync { + async fn get_user_by_id( + &self, + user_id: Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn is_admin( + &self, + user_id: Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn user_exists( + &self, + username: &str, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn create_user( + &self, + user: NewUser, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn update_user( + &self, + user_id: Uuid, + user: UpdateUser, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn delete_user( + &self, + user_id: Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError>; +} + +pub struct User { + pub id: Uuid, + pub username: String, + pub is_admin: bool, +} + +impl From for User { + fn from(model: UserModel) -> Self { + Self { + id: model.id, + username: model.name, + is_admin: model.is_admin, + } + } +} + +pub struct NewUser { + pub username: String, + pub is_admin: bool, +} + +pub struct UpdateUser { + pub username: Option, + pub is_admin: Option, + pub is_active: Option, +} + +impl UpdateUser { + fn apply_to_active_model(&self, model: &mut UserActiveModel) { + if let Some(username) = &self.username { + model.name = ActiveValue::Set(username.clone()); + } + if let Some(is_admin) = self.is_admin { + model.is_admin = ActiveValue::Set(is_admin); + } + if let Some(is_active) = self.is_active { + model.is_active = ActiveValue::Set(is_active); + } + } +} + +pub struct UserServiceImpl { + connection: Arc, +} + +impl UserServiceImpl { + pub fn new(connection: Arc) -> Self { + Self { connection } + } + + async fn get_user_by_id_from_db( + &self, + user_id: Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let user = with_conn!(&*self.connection, tx, conn, { + user::Entity::find_by_id(user_id).one(*conn).await + }); + + match user { + Err(err) => Err(ServiceError::from(err)), + Ok(None) => Err(ServiceError::NotFound(format!( + "User with id '{}' not found", + user_id + ))), + Ok(Some(record)) => Ok(record), + } + } +} + +#[async_trait::async_trait] +impl UserService for UserServiceImpl { + async fn get_user_by_id( + &self, + user_id: Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let user = self.get_user_by_id_from_db(user_id, tx).await?; + Ok(User::from(user)) + } + + async fn is_admin( + &self, + user_id: Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let user = self.get_user_by_id(user_id, tx).await?; + Ok(user.is_admin) + } + + async fn user_exists( + &self, + username: &str, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let user = with_conn!(&*self.connection, tx, conn, { + user::Entity::find() + .filter(user::Column::Name.eq(username)) + .one(*conn) + .await + }); + + match user { + Err(err) => match err { + DbErr::RecordNotFound(_) => Ok(false), + _ => Err(ServiceError::from(err)), + }, + Ok(None) => Ok(false), + Ok(Some(_)) => Ok(true), + } + } + + async fn create_user( + &self, + user: NewUser, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let user_active_model = UserActiveModel { + id: ActiveValue::NotSet, + name: ActiveValue::Set(user.username), + is_admin: ActiveValue::Set(user.is_admin), + is_active: ActiveValue::Set(true), + ..Default::default() + }; + + let user_model = with_conn!(&*self.connection, tx, conn, { + user_active_model.insert(*conn).await + })?; + + Ok(User::from(user_model)) + } + + async fn update_user( + &self, + user_id: Uuid, + update_user: UpdateUser, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let existing_user = self.get_user_by_id_from_db(user_id, tx).await?; + + let mut user_active_model = existing_user.into_active_model(); + update_user.apply_to_active_model(&mut user_active_model); + + let user_model = user_active_model.update(&*self.connection).await?; + + Ok(User::from(user_model)) + } + + async fn delete_user( + &self, + user_id: Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let user = self.get_user_by_id_from_db(user_id, tx).await?; + + let user_active_model = user.into_active_model(); + user_active_model.delete(&*self.connection).await?; + + Ok(()) + } +} From b0c11c7c67dceac246b8e80192e4615dea58400a Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:54:52 +0800 Subject: [PATCH 10/19] feat: add admin initialization and database migration tasks --- apps/api/src/helpers.rs | 1 + apps/api/src/helpers/constants.rs | 1 + apps/api/src/tasks/startup.rs | 33 ++++--- apps/api/src/tasks/startup/db_migrate.rs | 11 +++ apps/api/src/tasks/startup/init_admin.rs | 116 +++++++++++++++++++++++ 5 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/helpers/constants.rs create mode 100644 apps/api/src/tasks/startup/db_migrate.rs create mode 100644 apps/api/src/tasks/startup/init_admin.rs diff --git a/apps/api/src/helpers.rs b/apps/api/src/helpers.rs index 8fd0a6b..6fbb533 100644 --- a/apps/api/src/helpers.rs +++ b/apps/api/src/helpers.rs @@ -1 +1,2 @@ +pub mod constants; pub mod database; diff --git a/apps/api/src/helpers/constants.rs b/apps/api/src/helpers/constants.rs new file mode 100644 index 0000000..a36bf1c --- /dev/null +++ b/apps/api/src/helpers/constants.rs @@ -0,0 +1 @@ +pub const ADMIN_INIT_SECRET_KEY: &str = "admin_init_secret"; diff --git a/apps/api/src/tasks/startup.rs b/apps/api/src/tasks/startup.rs index 17d150b..ccf7b75 100644 --- a/apps/api/src/tasks/startup.rs +++ b/apps/api/src/tasks/startup.rs @@ -1,25 +1,34 @@ -use migration::migrate_database; -use tracing::{debug, info}; +mod db_migrate; +mod init_admin; + +use std::sync::Arc; + +use sea_orm::ConnectOptions; +use tracing::info; use crate::configs::ProgramSettings; +use database::get_connection; pub async fn run_startup_tasks(config: &ProgramSettings) -> Result<(), Box> { // Here you can add any startup tasks you want to run when the application starts. info!("Running startup tasks..."); + + let db_options = |options: &mut ConnectOptions| { + options.max_connections(config.database.max_connections); + }; + + let db_connection = Arc::new( + get_connection(&config.database.url, Some(db_options)) + .await + .map_err(|err| format!("Failed to establish database connection: {}", err))?, + ); + if config.database.migrate_on_startup { - run_database_migrations(&config.database.url).await?; + db_migrate::run_database_migrations(&config.database.url).await?; } else { info!("Database migration on startup is disabled. Skipping migration."); } + init_admin::init_admin(config, db_connection.clone()).await?; Ok(()) } - -async fn run_database_migrations(db_url: &str) -> Result<(), Box> { - // Logic to run database migrations - info!("Running database migrations..."); - debug!("Database URL: {}", db_url); - migrate_database(db_url).await.map_err(Box::new)?; - info!("Database migrations completed."); - Ok(()) -} diff --git a/apps/api/src/tasks/startup/db_migrate.rs b/apps/api/src/tasks/startup/db_migrate.rs new file mode 100644 index 0000000..41ee5aa --- /dev/null +++ b/apps/api/src/tasks/startup/db_migrate.rs @@ -0,0 +1,11 @@ +use migration::migrate_database; +use tracing::{debug, info}; + +pub async fn run_database_migrations(db_url: &str) -> Result<(), Box> { + // Logic to run database migrations + info!("Running database migrations..."); + debug!("Database URL: {}", db_url); + migrate_database(db_url).await.map_err(Box::new)?; + info!("Database migrations completed."); + Ok(()) +} diff --git a/apps/api/src/tasks/startup/init_admin.rs b/apps/api/src/tasks/startup/init_admin.rs new file mode 100644 index 0000000..e204da6 --- /dev/null +++ b/apps/api/src/tasks/startup/init_admin.rs @@ -0,0 +1,116 @@ +use std::sync::Arc; + +use argon2::password_hash::{SaltString, rand_core::OsRng}; +use database::generated::entities::user; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, TransactionTrait}; +use tracing::{debug, info, warn}; + +use crate::configs::ProgramSettings; +use crate::helpers::constants::ADMIN_INIT_SECRET_KEY; +use crate::services::{ + auth::{ + authentication::strategies::password::PasswordStrategy, + user::{NewUser, UserService, UserServiceImpl}, + }, + settings::{SettingsService, SettingsStore}, +}; + +pub async fn init_admin( + config: &ProgramSettings, + db: Arc, +) -> Result<(), Box> { + // if admin user already exists, skip + let admin_exists = user::Entity::find() + .filter(user::Column::IsAdmin.eq(true)) + .filter(user::Column::IsActive.eq(true)) + .one(db.as_ref()) + .await + .map_err(|err| format!("Failed to query for existing admin user: {}", err))? + .is_some(); + + if admin_exists { + debug!("Admin user already exists. Skipping admin initialization."); + return Ok(()); + } + + // if config contains admin init settings, run admin init + if let (Some(username), Some(password)) = ( + &config.auth.default_admin_username, + &config.auth.default_admin_password, + ) { + let r = _init_admin(username, password, db.clone()).await; + if let Err(e) = r { + warn!("Failed to initialize admin user: {}", e); + info!("Defaulting to manual creation from dashboard."); + } else { + return Ok(()); + } + } + // else generate a random secret to be used when initializing admin from dashboard + let secret = generate_admin_init_secret(db.clone()).await?; + info!( + "Admin initialization secret generated. Use this secret to initialize the admin user from the dashboard: {}. This secret will only be shown once and is only valid until the admin user is created or the application is restarted.", + secret + ); + Ok(()) +} + +async fn generate_admin_init_secret( + db: Arc, +) -> Result> { + let secret = SaltString::generate(&mut OsRng).as_str().to_owned(); + + // Store the secret in a settings table + let setting = SettingsService::new(db.clone()); + setting + .set_setting(ADMIN_INIT_SECRET_KEY, secret.clone()) + .await + .map_err(|err| format!("Failed to store admin init secret: {}", err))?; + + Ok(secret) +} + +async fn _init_admin( + username: &str, + password: &str, + db: Arc, +) -> Result<(), Box> { + info!("Initializing admin user..."); + // Check if an admin user already exists + let admin_exists = user::Entity::find() + .filter(user::Column::IsAdmin.eq(true)) + .one(db.as_ref()) + .await? + .is_some(); + + if admin_exists { + debug!("Admin user already exists. Skipping initialization."); + return Ok(()); + } + info!("No admin user found. Creating default admin user..."); + + let user_service = UserServiceImpl::new(db.clone()); + let password_strategy = PasswordStrategy::new(db.clone()); + + let user = NewUser { + username: username.to_string(), + is_admin: true, + }; + + let mut tx = db.begin().await?; + // create user + let user = user_service.create_user(user, Some(&mut tx)).await?; + // create temporary password + password_strategy + .create_identity(user.id, password, Some(&mut tx)) + .await?; + // + tx.commit().await?; + + info!( + "Default admin user created successfully, username: {}", + username + ); + + Ok(()) +} From ccd8bc7aa1f974157321ca4371a2b015a5a6366c Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:25:57 +0800 Subject: [PATCH 11/19] Include require auth middleware and login route --- Cargo.lock | 34 +++++++ apps/api/Cargo.toml | 4 +- apps/api/src/cmd/start_server.rs | 17 +++- apps/api/src/helpers/constants.rs | 2 + apps/api/src/main.rs | 2 + apps/api/src/middlewares.rs | 11 ++- apps/api/src/middlewares/request_info.rs | 6 ++ apps/api/src/middlewares/require_auth.rs | 68 ++++++++++++++ apps/api/src/routes.rs | 31 +++++-- apps/api/src/routes/api.rs | 10 +- apps/api/src/routes/api/auth.rs | 16 ++++ apps/api/src/routes/api/auth/login.rs | 98 ++++++++++++++++++++ apps/api/src/routes/api/openapi.rs | 10 +- apps/api/src/routes/api/restricted.rs | 15 +++ apps/api/src/services/auth/authentication.rs | 41 ++++---- 15 files changed, 326 insertions(+), 39 deletions(-) create mode 100644 apps/api/src/middlewares/request_info.rs create mode 100644 apps/api/src/middlewares/require_auth.rs create mode 100644 apps/api/src/routes/api/auth.rs create mode 100644 apps/api/src/routes/api/auth/login.rs create mode 100644 apps/api/src/routes/api/restricted.rs diff --git a/Cargo.lock b/Cargo.lock index 06944b1..1f96fe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,6 +224,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-macros" version = "0.5.0" @@ -607,6 +629,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -4665,6 +4698,7 @@ dependencies = [ "argon2", "async-trait", "axum", + "axum-extra", "chrono", "clap", "config", diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 3ec1a3d..8e400da 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -7,7 +7,8 @@ edition = "2024" database = { path = "../../public/database" } migration = { path = "../../public/migration" } -axum = { version = "0.8.7", features = ["form", "http1", "http2", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"]} +axum = { version = "0.8.7", features = ["form", "http1", "http2", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"] } +axum-extra = { version = "0.12.2", features = ["cookie"] } async-trait = { version = "0.1.89" } chrono = { version = "0.4.42", features = ["clock", "std", "oldtime", "wasmbind", "serde"] } config = { version = "0.15.19", features = ["toml", "json", "yaml", "ini", "ron", "json5", "convert-case", "async"] } @@ -26,3 +27,4 @@ once_cell = { version = "1.21.3" } argon2 = { version = "0.5.3", features = ["std"] } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } + diff --git a/apps/api/src/cmd/start_server.rs b/apps/api/src/cmd/start_server.rs index d1d1ce8..e602258 100644 --- a/apps/api/src/cmd/start_server.rs +++ b/apps/api/src/cmd/start_server.rs @@ -13,7 +13,10 @@ use crate::{ log, routes::{self, AppService, AppState}, services::{ - auth::{authentication::AuthenticationServiceImpl, user::UserServiceImpl}, + auth::{ + authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy}, + user::UserServiceImpl, + }, settings::SettingsService, }, tasks, @@ -129,9 +132,15 @@ fn get_app_state( database_connection: db_connection.clone(), service: Arc::new(AppService { settings: Arc::new(SettingsService::new(db_connection.clone())), - authentication: Arc::new(AuthenticationServiceImpl::new( - settings.auth.jwt_secret.clone(), - )), + auth_state: routes::AuthState { + strategy: routes::AuthStrategy { + password: Arc::new(PasswordStrategy::new(db_connection.clone())), + }, + authentication: Arc::new(AuthenticationServiceImpl::new( + settings.auth.jwt_secret.clone(), + )), + user: Arc::new(UserServiceImpl::new(db_connection.clone())), + }, user: Arc::new(UserServiceImpl::new(db_connection.clone())), }), } diff --git a/apps/api/src/helpers/constants.rs b/apps/api/src/helpers/constants.rs index a36bf1c..05b6e2f 100644 --- a/apps/api/src/helpers/constants.rs +++ b/apps/api/src/helpers/constants.rs @@ -1 +1,3 @@ pub const ADMIN_INIT_SECRET_KEY: &str = "admin_init_secret"; +// +pub const JWT_COOKIE_NAME: &str = "session_jwt"; diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 3eef8e1..4d6bbdb 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + mod cmd; mod configs; mod errors; diff --git a/apps/api/src/middlewares.rs b/apps/api/src/middlewares.rs index f47e3de..69935b1 100644 --- a/apps/api/src/middlewares.rs +++ b/apps/api/src/middlewares.rs @@ -1,16 +1,21 @@ +pub mod request_info; +pub mod require_auth; + +use std::{sync::Arc, time::Duration}; + use axum::{ BoxError, Router, error_handling::HandleErrorLayer, http::{Method, StatusCode, Uri}, }; -use std::time::Duration; use tower::{ServiceBuilder, timeout::TimeoutLayer}; - use tracing::warn; +use crate::routes::AppState; + pub const TIMEOUT_DURATION_SECS: u64 = 30; -pub fn apply_root_middleware(router: Router) -> Router { +pub fn apply_root_middleware(router: Router, _state: Arc) -> Router { let timeout_layer = TimeoutLayer::new(Duration::from_secs(TIMEOUT_DURATION_SECS)); let service_builder = ServiceBuilder::new() diff --git a/apps/api/src/middlewares/request_info.rs b/apps/api/src/middlewares/request_info.rs new file mode 100644 index 0000000..fb44b20 --- /dev/null +++ b/apps/api/src/middlewares/request_info.rs @@ -0,0 +1,6 @@ +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct RequestInfo { + pub user_id: Option, +} diff --git a/apps/api/src/middlewares/require_auth.rs b/apps/api/src/middlewares/require_auth.rs new file mode 100644 index 0000000..b504210 --- /dev/null +++ b/apps/api/src/middlewares/require_auth.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use axum::{ + extract::State, + http::{Request, StatusCode}, + middleware::Next, + response::Response, +}; +use axum_extra::extract::cookie::CookieJar; +use uuid::Uuid; + +use crate::{ + errors::service_error::ServiceError, helpers::constants::JWT_COOKIE_NAME, + middlewares::request_info::RequestInfo, routes::AppState, +}; + +pub async fn require_auth( + cookies: CookieJar, + State(state): State>, + req: Request, + next: Next, +) -> Result { + // get jwt from cookies + let auth_service = &state.service.auth_state.authentication; + let token = if let Some(cookie) = cookies.get(JWT_COOKIE_NAME) { + cookie.value().to_string() + } else { + return handle_unauthenticated().await; + }; + + // validate jwt + let is_valid = auth_service.is_valid_jwt(&token, None).await; + let user_id = match is_valid { + Ok(Some(claims)) => claims + .sub + .parse::() + .map_err(|_| StatusCode::UNAUTHORIZED)?, + Ok(None) => return handle_unauthenticated().await, + Err(err) => { + tracing::error!("Error validating JWT: {}", err); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // ensure user exists + if let Err(err) = state.service.user.get_user_by_id(user_id, None).await { + match err { + ServiceError::NotFound(_) => return handle_unauthenticated().await, + _ => { + tracing::error!("Error fetching user by ID: {}", err); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + } + + let mut req = req; + let user = req + .extensions_mut() + .get_or_insert_with(|| RequestInfo { user_id: None }); + user.user_id = Some(user_id); + + Ok(next.run(req).await) +} + +async fn handle_unauthenticated() -> Result { + // TODO: log unauthenticated access attempts + Err(StatusCode::UNAUTHORIZED) +} diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 59e181e..7abbf97 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -11,7 +11,10 @@ use migration::sea_orm::DatabaseConnection; use crate::{ middlewares, services::{ - auth::{authentication::AuthenticationService, user::UserService}, + auth::{ + authentication::{AuthenticationService, strategies::password::PasswordStrategy}, + user::UserService, + }, settings::SettingsStore, }, }; @@ -28,25 +31,35 @@ pub struct AppState { pub type ServiceState = Arc; -pub struct AppService { - #[allow(dead_code)] // TODO: remove when used - pub settings: ServiceState, - #[allow(dead_code)] // TODO: remove when used +pub struct AuthStrategy { + pub password: ServiceState, +} + +pub struct AuthState { + pub strategy: AuthStrategy, pub authentication: ServiceState, - #[allow(dead_code)] // TODO: remove when used + pub user: ServiceState, +} + +pub struct AppService { + // #[allow(dead_code)] // TODO: remove when used + pub settings: ServiceState, + pub auth_state: AuthState, + // #[allow(dead_code)] // TODO: remove when used pub user: ServiceState, } pub fn get_root_router(state: impl Into>) -> Router { let mut router = Router::new(); + let state = state.into(); router = router - .nest("/api", api::get_api_router()) + .nest("/api", api::get_api_router(state.clone())) .merge(view::get_view_router()); - router = middlewares::apply_root_middleware(router); + router = middlewares::apply_root_middleware(router, state.clone()); - router = router.layer(Extension(state.into())); + router = router.layer(Extension(state.clone())); router } diff --git a/apps/api/src/routes/api.rs b/apps/api/src/routes/api.rs index 68bbdcc..8fbf5d0 100644 --- a/apps/api/src/routes/api.rs +++ b/apps/api/src/routes/api.rs @@ -1,13 +1,21 @@ +mod auth; mod health; mod openapi; +mod restricted; + +use std::sync::Arc; + +use crate::routes::AppState; pub use self::openapi::ApiDoc; use axum::{Router, response::IntoResponse, routing::any}; -pub fn get_api_router() -> Router { +pub fn get_api_router(state: Arc) -> Router { Router::new() .nest("/health", health::get_health_router()) + .merge(auth::get_basic_auth_router(state.clone())) + .merge(restricted::get_restricted_router(state.clone())) // explicit fallback for unmatched API routes .route("/{*wildcard}", any(api_fallback_handler)) } diff --git a/apps/api/src/routes/api/auth.rs b/apps/api/src/routes/api/auth.rs new file mode 100644 index 0000000..98d87d9 --- /dev/null +++ b/apps/api/src/routes/api/auth.rs @@ -0,0 +1,16 @@ +pub mod login; + +use std::sync::Arc; + +use axum::{ + Router, + routing::{get, post}, +}; + +use crate::routes::AppState; + +pub fn get_basic_auth_router(state: Arc) -> Router { + Router::new() + .route("/login", post(login::login)) + .with_state(state) +} diff --git a/apps/api/src/routes/api/auth/login.rs b/apps/api/src/routes/api/auth/login.rs new file mode 100644 index 0000000..a2abf66 --- /dev/null +++ b/apps/api/src/routes/api/auth/login.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; + +use axum::{ + Json, + body::Body, + extract::State, + http::{StatusCode, header::SET_COOKIE}, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, from_value}; +use tracing::{error, warn}; + +use crate::routes::{AppState, api::openapi::tag::AUTH_TAG}; + +/// Login request payload +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct LoginRequest { + username: String, + password: String, +} + +/// Login endpoint +/// +/// Authenticates a user and returns a JWT in an HttpOnly cookie. +#[utoipa::path( + post, + path = "/api/auth/login", + request_body = LoginRequest, + responses( + (status = 200, description = "User authenticated successfully", body = ()), + (status = 401, description = "Authentication failed"), + (status = 500, description = "Internal server error"), + ), + tag = AUTH_TAG, + )] +pub async fn login(State(state): State>, Json(payload): Json) -> Response { + let login_request: LoginRequest = match from_value(payload) { + Ok(req) => req, + Err(e) => { + warn!("Invalid login request: {}", e); + return (StatusCode::BAD_REQUEST).into_response(); + } + }; + + let user_id = match state + .service + .auth_state + .strategy + .password + .authenticate(&login_request.username, &login_request.password, None) + .await + { + Ok(user_id) => user_id, + Err(e) => { + warn!( + "Authentication failed for user {}: {}", + login_request.username, e + ); + return (StatusCode::UNAUTHORIZED).into_response(); + } + }; + + let (jwt, claims) = match state + .service + .auth_state + .authentication + .generate_jwt(user_id, 3600) + .await + { + Ok(token) => token, + Err(e) => { + error!("Error generating JWT for user {}: {}", user_id, e); + return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); + } + }; + + let response_builder = Response::builder() + .status(StatusCode::OK) + // add jwt as cookie + .header( + SET_COOKIE, + format!( + "token={}; HttpOnly; Path=/; Max-Age={}; SameSite=Strict;", + jwt, + claims.exp - claims.iat + ), + ) + .body(Body::from(())); + + match response_builder { + Ok(resp) => resp, + Err(e) => { + error!("Error building response: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); + } + } +} diff --git a/apps/api/src/routes/api/openapi.rs b/apps/api/src/routes/api/openapi.rs index 6b39737..b8ae85b 100644 --- a/apps/api/src/routes/api/openapi.rs +++ b/apps/api/src/routes/api/openapi.rs @@ -1,18 +1,22 @@ pub mod tag { /// Health tag constant pub const HEALTH_TAG: &str = "Health"; + pub const AUTH_TAG: &str = "Authentication"; } #[derive(utoipa::OpenApi)] #[openapi( paths( - crate::routes::api::health::info::get_health_info + crate::routes::api::health::info::get_health_info, + crate::routes::api::auth::login::login ), components( - schemas(crate::routes::api::health::info::HealthInfo) // Register any schemas used in your paths + schemas(crate::routes::api::health::info::HealthInfo), // Register any schemas used in your paths + schemas(crate::routes::api::auth::login::LoginRequest) ), tags( - (name = tag::HEALTH_TAG, description = "Health information API") + (name = tag::HEALTH_TAG, description = "Health information API"), + (name = tag::AUTH_TAG, description = "Authentication API") ) )] pub struct ApiDoc; diff --git a/apps/api/src/routes/api/restricted.rs b/apps/api/src/routes/api/restricted.rs new file mode 100644 index 0000000..2372184 --- /dev/null +++ b/apps/api/src/routes/api/restricted.rs @@ -0,0 +1,15 @@ +use std::sync::Arc; + +use axum::{Router, routing::get}; + +use crate::{middlewares::require_auth::require_auth, routes::AppState}; + +pub fn get_restricted_router(state: Arc) -> Router { + Router::new() + // + // + .layer(axum::middleware::from_fn_with_state( + state.clone(), + require_auth, + )) +} diff --git a/apps/api/src/services/auth/authentication.rs b/apps/api/src/services/auth/authentication.rs index 2bbaae1..a7c1096 100644 --- a/apps/api/src/services/auth/authentication.rs +++ b/apps/api/src/services/auth/authentication.rs @@ -28,13 +28,16 @@ pub struct Claims { #[async_trait::async_trait] pub trait AuthenticationService: Send + Sync { - async fn generate_jwt(&self, user_id: Uuid, duration_secs: u64) - -> Result; + async fn generate_jwt( + &self, + user_id: Uuid, + duration_secs: u64, + ) -> Result<(String, Claims), ServiceError>; async fn is_valid_jwt( &self, token: &str, target_sub: Option, - ) -> Result; + ) -> Result, ServiceError>; async fn parse_jwt(&self, token: &str) -> Result; async fn invalidate_jwt(&self, token: &str) -> Result<(), ServiceError>; async fn refresh_jwt(&self, token: &str, duration_secs: u64) -> Result; @@ -76,7 +79,7 @@ impl AuthenticationService for AuthenticationServiceImpl { &self, user_id: Uuid, duration_secs: u64, - ) -> Result { + ) -> Result<(String, Claims), ServiceError> { let header = Header::default(); let expiration = chrono::Utc::now() .checked_add_signed(chrono::Duration::seconds(duration_secs as i64)) @@ -95,23 +98,23 @@ impl AuthenticationService for AuthenticationServiceImpl { &EncodingKey::from_secret(self.secret.as_ref()), ) .map_err(|e| ServiceError::InternalError(format!("JWT generation error: {}", e)))?; - Ok(token) + Ok((token, claims)) } async fn is_valid_jwt( &self, token: &str, target_sub: Option, - ) -> Result { + ) -> Result, ServiceError> { let mut validation = Validation::default(); if let Some(expected_sub) = target_sub { validation.sub = Some(expected_sub); } let decoding_key = DecodingKey::from_secret(self.secret.as_ref()); match decode::(token, &decoding_key, &validation) { - Ok(_) => Ok(true), + Ok(data) => Ok(Some(data.claims)), Err(err) => match *err.kind() { - InvalidToken | InvalidSubject | ExpiredSignature => Ok(false), + InvalidToken | InvalidSubject | ExpiredSignature => Ok(None), _ => Err(ServiceError::InternalError(format!( "JWT validation error: {}", err @@ -156,7 +159,7 @@ impl AuthenticationService for AuthenticationServiceImpl { let user_id = Uuid::parse_str(&claims.sub).map_err(|e| { ServiceError::InternalError(format!("Invalid user ID in JWT claims: {}", e)) })?; - let new_token = self.generate_jwt(user_id, duration_secs).await?; + let (new_token, _) = self.generate_jwt(user_id, duration_secs).await?; Ok(new_token) } @@ -181,7 +184,7 @@ mod tests { let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let user_id = Uuid::new_v4(); - let token = service + let (token, _) = service .generate_jwt(user_id, 60) .await .expect("generate jwt"); @@ -190,8 +193,7 @@ mod tests { .is_valid_jwt(&token, None) .await .expect("validate jwt"); - assert!(valid, "Generated token should be valid"); - + assert!(valid.is_some(), "Generated token should be valid"); let claims = service.parse_jwt(&token).await.expect("parse jwt"); assert_eq!(claims.sub, user_id.to_string()); } @@ -201,11 +203,14 @@ mod tests { let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let user_id = Uuid::new_v4(); - let token = service.generate_jwt(user_id, 60).await.unwrap(); + let (token, _) = service.generate_jwt(user_id, 60).await.unwrap(); let other_sub = Uuid::new_v4().to_string(); let valid = service.is_valid_jwt(&token, Some(other_sub)).await.unwrap(); - assert!(!valid, "Token should be invalid for a different subject"); + assert!( + valid.is_none(), + "Token should be invalid for a different subject" + ); } #[tokio::test] @@ -221,7 +226,7 @@ mod tests { let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let user_id = Uuid::new_v4(); - let token = service.generate_jwt(user_id, 60).await.unwrap(); + let (token, _) = service.generate_jwt(user_id, 60).await.unwrap(); let new_token = service.refresh_jwt(&token, 120).await.unwrap(); let claims = service.parse_jwt(&new_token).await.unwrap(); @@ -234,11 +239,11 @@ mod tests { let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let user_id = Uuid::new_v4(); - let token = service.generate_jwt(user_id, 1).await.unwrap(); + let (token, _) = service.generate_jwt(user_id, 1).await.unwrap(); sleep(Duration::from_secs(2)).await; let valid = service.is_valid_jwt(&token, None).await.unwrap(); - assert!(!valid, "Token should be expired and thus invalid"); + assert!(valid.is_none(), "Token should be expired and thus invalid"); } #[tokio::test] @@ -246,7 +251,7 @@ mod tests { let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let user_id = Uuid::new_v4(); - let token = service.generate_jwt(user_id, 1).await.unwrap(); + let (token, _) = service.generate_jwt(user_id, 1).await.unwrap(); service.invalidate_jwt(&token).await.unwrap(); From ed4a091d6ee9e400a8ce339856b0d489c966cb1b Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:26:27 +0800 Subject: [PATCH 12/19] update swagger and api-client --- apps/api/swagger.json | 58 +++++++++++++++++++ .../app/generated/api-client/api-client.ts | 45 ++++++++++++++ .../generated/api-client/tanstack-client.ts | 31 ++++++++++ 3 files changed, 134 insertions(+) diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 1b35dde..71bd824 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -9,6 +9,44 @@ "version": "0.1.0" }, "paths": { + "/api/auth/login": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Login endpoint", + "description": "Authenticates a user and returns a JWT in an HttpOnly cookie.", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User authenticated successfully", + "content": { + "application/json": { + "schema": { + "default": null + } + } + } + }, + "401": { + "description": "Authentication failed" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/api/health/info": { "get": { "tags": [ @@ -70,6 +108,22 @@ "description": "Application version" } } + }, + "LoginRequest": { + "type": "object", + "description": "Login request payload", + "required": [ + "username", + "password" + ], + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } } } }, @@ -77,6 +131,10 @@ { "name": "Health", "description": "Health information API" + }, + { + "name": "Authentication", + "description": "Authentication API" } ] } \ No newline at end of file diff --git a/apps/frontend/app/generated/api-client/api-client.ts b/apps/frontend/app/generated/api-client/api-client.ts index 8a5e6b3..4f648e5 100644 --- a/apps/frontend/app/generated/api-client/api-client.ts +++ b/apps/frontend/app/generated/api-client/api-client.ts @@ -6,6 +6,7 @@ export namespace Schemas { up_since: string; version: string; }; + export type LoginRequest = { password: string; username: string }; // } @@ -13,6 +14,15 @@ export namespace Schemas { export namespace Endpoints { // + export type post_Login = { + method: "POST"; + path: "/api/auth/login"; + requestFormat: "json"; + parameters: { + body: Schemas.LoginRequest; + }; + responses: { 200: unknown; 401: unknown; 500: unknown }; + }; export type get_Get_health_info = { method: "GET"; path: "/api/health/info"; @@ -26,6 +36,9 @@ export namespace Endpoints { // export type EndpointByMethod = { + post: { + "/api/auth/login": Endpoints.post_Login; + }; get: { "/api/health/info": Endpoints.get_Get_health_info; }; @@ -34,6 +47,7 @@ export type EndpointByMethod = { // // +export type PostEndpoints = EndpointByMethod["post"]; export type GetEndpoints = EndpointByMethod["get"]; // @@ -267,6 +281,37 @@ export class ApiClient { return; }; + // + post( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + > + ): Promise, { data: {} }>["data"]>; + + post( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + > + ): Promise>; + + post( + path: Path, + ...params: MaybeOptionalArg + ): Promise { + return this.request("post", path, ...params); + } + // + // get( path: Path, diff --git a/apps/frontend/app/generated/api-client/tanstack-client.ts b/apps/frontend/app/generated/api-client/tanstack-client.ts index e76a8a3..0cd843e 100644 --- a/apps/frontend/app/generated/api-client/tanstack-client.ts +++ b/apps/frontend/app/generated/api-client/tanstack-client.ts @@ -41,6 +41,7 @@ const createQueryKey = ( }; // +export type PostEndpoints = EndpointByMethod["post"]; export type GetEndpoints = EndpointByMethod["get"]; // @@ -69,6 +70,36 @@ type InferResponseData = export class TanstackQueryApiClient { constructor(public client: ApiClient) {} + // + post( + path: Path, + ...params: MaybeOptionalArg + ) { + const queryKey = createQueryKey(path as string, params[0]); + const query = { + /** type-only property if you need easy access to the endpoint params */ + "~endpoint": {} as TEndpoint, + queryKey, + queryFn: {} as "You need to pass .queryOptions to the useQuery hook", + queryOptions: queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const requestParams = { + ...(params[0] || {}), + ...(queryKey[0] || {}), + overrides: { signal }, + withResponse: false as const, + }; + const res = await this.client.post(path, requestParams as never); + return res as InferResponseData; + }, + queryKey: queryKey, + }), + }; + + return query; + } + // + // get( path: Path, From 8f2193bed229709a3e869a31d809f6c9785e48f8 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:10:10 +0800 Subject: [PATCH 13/19] Fix invalid query for settings and users --- .../services/auth/authentication/strategies/password.rs | 1 + apps/api/src/services/auth/user.rs | 2 +- apps/api/src/services/settings.rs | 9 +++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/api/src/services/auth/authentication/strategies/password.rs b/apps/api/src/services/auth/authentication/strategies/password.rs index 396fbbb..167685c 100644 --- a/apps/api/src/services/auth/authentication/strategies/password.rs +++ b/apps/api/src/services/auth/authentication/strategies/password.rs @@ -108,6 +108,7 @@ impl PasswordStrategy { .to_string(); let new_identity = user_identity::ActiveModel { + id: sea_orm::ActiveValue::Set(Uuid::new_v4()), user_id: sea_orm::ActiveValue::Set(user_id), provider: sea_orm::ActiveValue::Set(PASSWORD_PROVIDER.to_string()), password_hash: sea_orm::ActiveValue::Set(Some(password_hash)), diff --git a/apps/api/src/services/auth/user.rs b/apps/api/src/services/auth/user.rs index 58abbfa..add0dbf 100644 --- a/apps/api/src/services/auth/user.rs +++ b/apps/api/src/services/auth/user.rs @@ -163,7 +163,7 @@ impl UserService for UserServiceImpl { tx: Option<&mut DatabaseTransaction>, ) -> Result { let user_active_model = UserActiveModel { - id: ActiveValue::NotSet, + id: ActiveValue::Set(Uuid::new_v4()), name: ActiveValue::Set(user.username), is_admin: ActiveValue::Set(user.is_admin), is_active: ActiveValue::Set(true), diff --git a/apps/api/src/services/settings.rs b/apps/api/src/services/settings.rs index 20df281..aa48f16 100644 --- a/apps/api/src/services/settings.rs +++ b/apps/api/src/services/settings.rs @@ -77,10 +77,11 @@ impl SettingsStore for SettingsService { Ok(None) => { handle_not_found(key.to_string(), value).await?; } - Ok(Some(mut record)) => { - record.value = value; - record - .into_active_model() + Ok(Some(record)) => { + let mut record_active_model = record.into_active_model(); + record_active_model.value = ActiveValue::Set(value); + record_active_model.updated_at = ActiveValue::Set(chrono::Utc::now()); + record_active_model .update(&*self.connection) .await .map_err(ServiceError::from)?; From 08b1a055a40fe1fd0371fd038bcf902fc1c5bc67 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:10:50 +0800 Subject: [PATCH 14/19] feat: add admin user initialization endpoint with request handling --- apps/api/src/routes/api/auth.rs | 4 +- apps/api/src/routes/api/auth/init_admin.rs | 143 ++++++++++++++++++ apps/api/src/routes/api/openapi.rs | 10 +- apps/api/swagger.json | 54 +++++++ .../app/generated/api-client/api-client.ts | 11 ++ 5 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/routes/api/auth/init_admin.rs diff --git a/apps/api/src/routes/api/auth.rs b/apps/api/src/routes/api/auth.rs index 98d87d9..2242a44 100644 --- a/apps/api/src/routes/api/auth.rs +++ b/apps/api/src/routes/api/auth.rs @@ -1,3 +1,4 @@ +pub mod init_admin; pub mod login; use std::sync::Arc; @@ -11,6 +12,7 @@ use crate::routes::AppState; pub fn get_basic_auth_router(state: Arc) -> Router { Router::new() - .route("/login", post(login::login)) + .route("/auth/login", post(login::login)) + .route("/auth/init_admin", post(init_admin::init_admin)) .with_state(state) } diff --git a/apps/api/src/routes/api/auth/init_admin.rs b/apps/api/src/routes/api/auth/init_admin.rs new file mode 100644 index 0000000..2c15054 --- /dev/null +++ b/apps/api/src/routes/api/auth/init_admin.rs @@ -0,0 +1,143 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use database::generated::entities::user; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, TransactionTrait}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, from_value}; +use tracing::{debug, error, info, warn}; + +use crate::{ + helpers::constants::ADMIN_INIT_SECRET_KEY, + routes::{AppState, api::openapi::tag::AUTH_TAG}, + services::auth::user::NewUser, +}; + +/// Login request payload +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct AdminInitRequest { + username: String, + password: String, + // The secret key required to initialize the admin user + setup_secret: String, +} + +/// Initializes the admin user +/// +/// Initializes the admin user if no admin user exists and the correct setup secret is provided. +#[utoipa::path( + post, + path = "/api/auth/init_admin", + request_body = AdminInitRequest, + responses( + (status = 200, description = "Admin user initialized successfully"), + (status = 400, description = "Invalid request payload"), + (status = 401, description = "Unauthorized: Admin user already exists or invalid setup secret"), + (status = 500, description = "Internal server error"), + ), + tag = AUTH_TAG, + )] +pub async fn init_admin( + State(state): State>, + Json(payload): Json, +) -> Response { + if user::Entity::find() + .filter(user::Column::IsAdmin.eq(true)) + .filter(user::Column::IsActive.eq(true)) + .one(state.database_connection.as_ref()) + .await + .map_err(|err| { + error!("Failed to query for existing admin user: {}", err); + StatusCode::INTERNAL_SERVER_ERROR + }) + .unwrap_or(None) + .is_some() + { + warn!("Admin user already exists. Skipping admin initialization."); + return (StatusCode::UNAUTHORIZED).into_response(); + } + + let init_request: AdminInitRequest = match from_value(payload) { + Ok(req) => req, + Err(e) => { + warn!("Invalid login request: {}", e); + return (StatusCode::BAD_REQUEST).into_response(); + } + }; + + let admin_secret = match state + .service + .settings + .get_setting(ADMIN_INIT_SECRET_KEY) + .await + { + Ok(secret) => secret, + Err(e) => { + error!( + "Failed to retrieve admin initialization secret. Invalid internal state?: {}", + e + ); + return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); + } + }; + + if init_request.setup_secret != admin_secret { + info!("{},{}", init_request.setup_secret, admin_secret); + warn!("Invalid admin initialization secret provided."); + return (StatusCode::UNAUTHORIZED).into_response(); + } + + let mut tx = match state.database_connection.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to start transaction: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); + } + }; + + let user = match state + .service + .user + .create_user( + NewUser { + username: init_request.username, + is_admin: true, + }, + Some(&mut tx), + ) + .await + { + Ok(user) => user, + Err(e) => { + error!("Failed to initialize admin user: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); + } + }; + + debug!("Created admin user with ID: {}", user.id); + match state + .service + .auth_state + .strategy + .password + .create_identity(user.id, &init_request.password, Some(&mut tx)) + .await + { + Ok(_) => {} + Err(e) => { + error!("Failed to create admin user identity: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); + } + }; + + tx.commit().await.unwrap_or_else(|e| { + error!("Failed to commit transaction: {}", e); + }); + + (StatusCode::OK).into_response() +} diff --git a/apps/api/src/routes/api/openapi.rs b/apps/api/src/routes/api/openapi.rs index b8ae85b..d7c4d9d 100644 --- a/apps/api/src/routes/api/openapi.rs +++ b/apps/api/src/routes/api/openapi.rs @@ -8,11 +8,15 @@ pub mod tag { #[openapi( paths( crate::routes::api::health::info::get_health_info, - crate::routes::api::auth::login::login + // Authentication paths + crate::routes::api::auth::login::login, + crate::routes::api::auth::init_admin::init_admin, ), components( - schemas(crate::routes::api::health::info::HealthInfo), // Register any schemas used in your paths - schemas(crate::routes::api::auth::login::LoginRequest) + schemas(crate::routes::api::health::info::HealthInfo), + // Authentication schemas + schemas(crate::routes::api::auth::login::LoginRequest), + schemas(crate::routes::api::auth::init_admin::AdminInitRequest), ), tags( (name = tag::HEALTH_TAG, description = "Health information API"), diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 71bd824..6bc6036 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -9,6 +9,40 @@ "version": "0.1.0" }, "paths": { + "/api/auth/init_admin": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Initializes the admin user", + "description": "Initializes the admin user if no admin user exists and the correct setup secret is provided.", + "operationId": "init_admin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminInitRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Admin user initialized successfully" + }, + "400": { + "description": "Invalid request payload" + }, + "401": { + "description": "Unauthorized: Admin user already exists or invalid setup secret" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/api/auth/login": { "post": { "tags": [ @@ -75,6 +109,26 @@ }, "components": { "schemas": { + "AdminInitRequest": { + "type": "object", + "description": "Login request payload", + "required": [ + "username", + "password", + "setup_secret" + ], + "properties": { + "password": { + "type": "string" + }, + "setup_secret": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "HealthInfo": { "type": "object", "description": "System health information", diff --git a/apps/frontend/app/generated/api-client/api-client.ts b/apps/frontend/app/generated/api-client/api-client.ts index 4f648e5..aba754f 100644 --- a/apps/frontend/app/generated/api-client/api-client.ts +++ b/apps/frontend/app/generated/api-client/api-client.ts @@ -1,5 +1,6 @@ export namespace Schemas { // + export type AdminInitRequest = { password: string; setup_secret: string; username: string }; export type HealthInfo = { errors?: (Array | null) | undefined; status: string; @@ -14,6 +15,15 @@ export namespace Schemas { export namespace Endpoints { // + export type post_Init_admin = { + method: "POST"; + path: "/api/auth/init_admin"; + requestFormat: "json"; + parameters: { + body: Schemas.AdminInitRequest; + }; + responses: { 200: unknown; 400: unknown; 401: unknown; 500: unknown }; + }; export type post_Login = { method: "POST"; path: "/api/auth/login"; @@ -37,6 +47,7 @@ export namespace Endpoints { // export type EndpointByMethod = { post: { + "/api/auth/init_admin": Endpoints.post_Init_admin; "/api/auth/login": Endpoints.post_Login; }; get: { From 86fb222d187f70cd11f8cc3862930a0452665662 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:19:16 +0800 Subject: [PATCH 15/19] added serving openapi options --- apps/api/src/cmd/start_server.rs | 16 +++++++++++++++- apps/api/src/configs/key.rs | 1 + apps/api/src/configs/server.rs | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/api/src/cmd/start_server.rs b/apps/api/src/cmd/start_server.rs index e602258..4292718 100644 --- a/apps/api/src/cmd/start_server.rs +++ b/apps/api/src/cmd/start_server.rs @@ -87,7 +87,21 @@ pub async fn start_server() { // build the axum app and run the server... info!("Starting application..."); - let app: Router = routes::get_root_router(Arc::new(get_app_state(&db_connection, &settings))); + let mut app: Router = + routes::get_root_router(Arc::new(get_app_state(&db_connection, &settings))); + + if settings.server.serve_openapi { + info!("Enabling OpenAPI documentation endpoint at /openapi.json"); + app = app.route( + "/openapi.json", + axum::routing::get(|| async { + use utoipa::OpenApi; + let doc = routes::ApiDoc::openapi(); + doc.to_pretty_json() + .expect("Failed to serialize OpenAPI doc to JSON") + }), + ); + } let address = format!("{}:{}", settings.server.address, settings.server.port); info!("Starting server at http://{}", address); diff --git a/apps/api/src/configs/key.rs b/apps/api/src/configs/key.rs index c4a0ee9..0bc3baf 100644 --- a/apps/api/src/configs/key.rs +++ b/apps/api/src/configs/key.rs @@ -3,6 +3,7 @@ pub(crate) const LOGGING_UTC_KEY: &str = "LOGGING.UTC"; // pub(crate) const SERVER_ADDRESS_KEY: &str = "SERVER.ADDRESS"; pub(crate) const SERVER_PORT_KEY: &str = "SERVER.PORT"; +pub(crate) const SERVER_SERVE_OPENAPI_KEY: &str = "SERVER.SERVE_OPENAPI"; // pub(crate) const DATABASE_URL_KEY: &str = "DATABASE.URL"; pub(crate) const DATABASE_MAX_CONNECTIONS_KEY: &str = "DATABASE.MAX_CONNECTIONS"; diff --git a/apps/api/src/configs/server.rs b/apps/api/src/configs/server.rs index 16e6bee..a79ce14 100644 --- a/apps/api/src/configs/server.rs +++ b/apps/api/src/configs/server.rs @@ -3,6 +3,8 @@ use std::net::IpAddr; use config::{Config, ConfigError}; use tracing::warn; +use crate::configs::key::SERVER_SERVE_OPENAPI_KEY; + use super::{ FromConfig, key::{SERVER_ADDRESS_KEY, SERVER_PORT_KEY}, @@ -12,6 +14,7 @@ use super::{ pub struct ServerSettings { pub address: IpAddr, pub port: u16, + pub serve_openapi: bool, } impl FromConfig for ServerSettings { @@ -43,6 +46,17 @@ impl FromConfig for ServerSettings { ); DEFAULT_PORT }) as u16, + + serve_openapi: _config + .get_bool(SERVER_SERVE_OPENAPI_KEY) + .unwrap_or_else(|err| { + const DEFAULT_SERVE_OPENAPI: bool = false; + warn!( + "{} not set or invalid in configuration, defaulting to {}. Error: {}", + SERVER_SERVE_OPENAPI_KEY, DEFAULT_SERVE_OPENAPI, err + ); + DEFAULT_SERVE_OPENAPI + }), }) } From 66b29b96eeb3070c1911611ab8b7cddf9a1fa74e Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:21:15 +0800 Subject: [PATCH 16/19] remove unused user service in auth_state --- apps/api/src/cmd/start_server.rs | 1 - apps/api/src/routes.rs | 3 --- 2 files changed, 4 deletions(-) diff --git a/apps/api/src/cmd/start_server.rs b/apps/api/src/cmd/start_server.rs index 4292718..e3629fd 100644 --- a/apps/api/src/cmd/start_server.rs +++ b/apps/api/src/cmd/start_server.rs @@ -153,7 +153,6 @@ fn get_app_state( authentication: Arc::new(AuthenticationServiceImpl::new( settings.auth.jwt_secret.clone(), )), - user: Arc::new(UserServiceImpl::new(db_connection.clone())), }, user: Arc::new(UserServiceImpl::new(db_connection.clone())), }), diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 7abbf97..85ca15a 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -38,14 +38,11 @@ pub struct AuthStrategy { pub struct AuthState { pub strategy: AuthStrategy, pub authentication: ServiceState, - pub user: ServiceState, } pub struct AppService { - // #[allow(dead_code)] // TODO: remove when used pub settings: ServiceState, pub auth_state: AuthState, - // #[allow(dead_code)] // TODO: remove when used pub user: ServiceState, } From 8111aaf6728d65084ccb7609efa8416788186c70 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:24:47 +0800 Subject: [PATCH 17/19] feat: enhance health check with application state and initialization status --- apps/api/src/cmd/start_server.rs | 2 + apps/api/src/routes.rs | 4 +- apps/api/src/routes/api.rs | 2 +- apps/api/src/routes/api/health.rs | 9 ++- apps/api/src/routes/api/health/info.rs | 61 +++++++++++++++++-- apps/api/src/routes/api/health/state.rs | 9 +++ apps/api/src/services.rs | 1 + apps/api/src/services/server_state.rs | 36 +++++++++++ apps/api/swagger.json | 7 ++- .../app/generated/api-client/api-client.ts | 1 + 10 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/services/server_state.rs diff --git a/apps/api/src/cmd/start_server.rs b/apps/api/src/cmd/start_server.rs index e3629fd..e49f5a8 100644 --- a/apps/api/src/cmd/start_server.rs +++ b/apps/api/src/cmd/start_server.rs @@ -17,6 +17,7 @@ use crate::{ authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy}, user::UserServiceImpl, }, + server_state::ServerStateService, settings::SettingsService, }, tasks, @@ -145,6 +146,7 @@ fn get_app_state( AppState { database_connection: db_connection.clone(), service: Arc::new(AppService { + server_state: Arc::new(ServerStateService::new(db_connection.clone())), settings: Arc::new(SettingsService::new(db_connection.clone())), auth_state: routes::AuthState { strategy: routes::AuthStrategy { diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 85ca15a..41cc73b 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -15,16 +15,15 @@ use crate::{ authentication::{AuthenticationService, strategies::password::PasswordStrategy}, user::UserService, }, + server_state::ServerStateStore, settings::SettingsStore, }, }; #[derive(Clone)] pub struct AppState { - // TODO: remove dead_code allowances when fields are used #[allow(dead_code)] pub database_connection: Arc, - // TODO: remove dead_code allowances when fields are used #[allow(dead_code)] pub service: Arc, } @@ -44,6 +43,7 @@ pub struct AppService { pub settings: ServiceState, pub auth_state: AuthState, pub user: ServiceState, + pub server_state: ServiceState, } pub fn get_root_router(state: impl Into>) -> Router { diff --git a/apps/api/src/routes/api.rs b/apps/api/src/routes/api.rs index 8fbf5d0..3546a2b 100644 --- a/apps/api/src/routes/api.rs +++ b/apps/api/src/routes/api.rs @@ -13,7 +13,7 @@ use axum::{Router, response::IntoResponse, routing::any}; pub fn get_api_router(state: Arc) -> Router { Router::new() - .nest("/health", health::get_health_router()) + .nest("/health", health::get_health_router(state.clone())) .merge(auth::get_basic_auth_router(state.clone())) .merge(restricted::get_restricted_router(state.clone())) // explicit fallback for unmatched API routes diff --git a/apps/api/src/routes/api/health.rs b/apps/api/src/routes/api/health.rs index b6cc7bc..329c589 100644 --- a/apps/api/src/routes/api/health.rs +++ b/apps/api/src/routes/api/health.rs @@ -5,8 +5,13 @@ use std::sync::Arc; use axum::{Router, routing::get}; -pub fn get_health_router() -> Router { +use crate::routes::{AppState, api::health::state::AppStateWithHealth}; + +pub fn get_health_router(app_state: Arc) -> Router { Router::new() .route("/info", get(info::get_health_info)) - .with_state(Arc::new(state::HealthState::default())) + .with_state(Arc::new(AppStateWithHealth { + app_state: app_state.clone(), + health_state: Arc::new(state::HealthState::default()), + })) } diff --git a/apps/api/src/routes/api/health/info.rs b/apps/api/src/routes/api/health/info.rs index 069d5b1..27ea792 100644 --- a/apps/api/src/routes/api/health/info.rs +++ b/apps/api/src/routes/api/health/info.rs @@ -3,8 +3,9 @@ use std::sync::Arc; use axum::{Json, extract::State, http::StatusCode}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use tracing::error; -use crate::routes::api::{health::state::HealthState, openapi::tag::HEALTH_TAG}; +use crate::routes::api::{health::state::AppStateWithHealth, openapi::tag::HEALTH_TAG}; const STATUS_HEALTHY: &str = "healthy"; const STATUS_UNHEALTHY: &str = "unhealthy"; @@ -20,6 +21,8 @@ pub struct HealthInfo { pub up_since: DateTime, /// List of error messages if unhealthy pub errors: Option>, + /// Is initialized + pub is_initialized: bool, } /// Health check endpoint @@ -35,12 +38,23 @@ pub struct HealthInfo { tag = HEALTH_TAG, )] pub async fn get_health_info( - State(state): State>, + State(app_state_with_health): State>, ) -> (StatusCode, Json) { #[allow(unused_mut)] let mut errors = vec![]; let is_healthy = errors.is_empty(); + let health_state = &app_state_with_health.health_state; + let app_state = &app_state_with_health.app_state; + + let is_initialized = match app_state.service.server_state.is_server_initialized().await { + Ok(initialized) => initialized, + Err(err) => { + errors.push("Failed to determine if server is initialized".to_string()); + error!("Error checking server initialization status: {}", err); + false + } + }; ( if is_healthy { @@ -55,14 +69,29 @@ pub async fn get_health_info( STATUS_UNHEALTHY.into() }, version: env!("CARGO_PKG_VERSION").into(), - up_since: *state.get_start_at(), + up_since: *health_state.get_start_at(), errors: if is_healthy { None } else { Some(errors) }, + is_initialized, }), ) } #[cfg(test)] mod test { + use crate::{ + routes::{AppState, api::health::state::HealthState}, + services::{ + auth::{ + authentication::{ + AuthenticationServiceImpl, strategies::password::PasswordStrategy, + }, + user::UserServiceImpl, + }, + server_state::ServerStateService, + settings::SettingsService, + }, + }; + use super::*; use axum::body::to_bytes; use axum::{ @@ -70,14 +99,38 @@ mod test { body::Body, http::{Request, StatusCode}, }; + use sea_orm::MockDatabase; use tower::ServiceExt; #[tokio::test] async fn test_get_health_info() { let health_state = Arc::new(HealthState::default()); + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + let db = Arc::new(db); + + let app_state = Arc::new(AppState { + database_connection: db.clone(), + service: Arc::new(crate::routes::AppService { + settings: Arc::new(SettingsService::new(db.clone())), + auth_state: crate::routes::AuthState { + strategy: crate::routes::AuthStrategy { + password: Arc::new(PasswordStrategy::new(db.clone())), + }, + authentication: Arc::new(AuthenticationServiceImpl::new(None)), + }, + user: Arc::new(UserServiceImpl::new(db.clone())), + server_state: Arc::new(ServerStateService::new(db.clone())), + }), + }); + let app = Router::new() .route("/info", axum::routing::get(get_health_info)) - .with_state(health_state); + .with_state(Arc::new(AppStateWithHealth { + app_state: app_state.clone(), + health_state: health_state.clone(), + })); let response = app .oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap()) diff --git a/apps/api/src/routes/api/health/state.rs b/apps/api/src/routes/api/health/state.rs index db2f703..a46fcdd 100644 --- a/apps/api/src/routes/api/health/state.rs +++ b/apps/api/src/routes/api/health/state.rs @@ -1,5 +1,14 @@ +use std::sync::Arc; + use chrono::{DateTime, Utc}; +use crate::routes::AppState; + +pub struct AppStateWithHealth { + pub app_state: Arc, + pub health_state: Arc, +} + pub struct HealthState { start_at: DateTime, } diff --git a/apps/api/src/services.rs b/apps/api/src/services.rs index f7da917..173b067 100644 --- a/apps/api/src/services.rs +++ b/apps/api/src/services.rs @@ -1,2 +1,3 @@ pub mod auth; +pub mod server_state; pub mod settings; diff --git a/apps/api/src/services/server_state.rs b/apps/api/src/services/server_state.rs new file mode 100644 index 0000000..75ca966 --- /dev/null +++ b/apps/api/src/services/server_state.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use sea_orm::{DatabaseConnection, prelude::*}; + +use crate::errors::service_error::ServiceError; + +#[async_trait::async_trait] +pub trait ServerStateStore: Send + Sync { + async fn is_server_initialized(&self) -> Result; +} + +pub struct ServerStateService { + connection: Arc, +} + +impl ServerStateService { + pub fn new(connection: Arc) -> Self { + Self { connection } + } +} + +#[async_trait::async_trait] +impl ServerStateStore for ServerStateService { + async fn is_server_initialized(&self) -> Result { + // For example, check if any admin user exists to determine if the server is initialized + let admin_exists = database::generated::entities::user::Entity::find() + .filter(database::generated::entities::user::Column::IsAdmin.eq(true)) + .filter(database::generated::entities::user::Column::IsActive.eq(true)) + .one(&*self.connection) + .await + .map_err(ServiceError::from)? + .is_some(); + + Ok(admin_exists) + } +} diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 6bc6036..dc65ac2 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -135,7 +135,8 @@ "required": [ "status", "version", - "up_since" + "up_since", + "is_initialized" ], "properties": { "errors": { @@ -148,6 +149,10 @@ }, "description": "List of error messages if unhealthy" }, + "is_initialized": { + "type": "boolean", + "description": "Is initialized" + }, "status": { "type": "string", "description": "Health status: \"healthy\" or \"unhealthy\"" diff --git a/apps/frontend/app/generated/api-client/api-client.ts b/apps/frontend/app/generated/api-client/api-client.ts index aba754f..5ebf513 100644 --- a/apps/frontend/app/generated/api-client/api-client.ts +++ b/apps/frontend/app/generated/api-client/api-client.ts @@ -3,6 +3,7 @@ export namespace Schemas { export type AdminInitRequest = { password: string; setup_secret: string; username: string }; export type HealthInfo = { errors?: (Array | null) | undefined; + is_initialized: boolean; status: string; up_since: string; version: string; From ec81d3228b9caaa6f9665904f63abd8e153a7e91 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:25:55 +0800 Subject: [PATCH 18/19] fix clippy warnings --- .vscode/settings.json | 11 ++++++++++- apps/api/src/routes/api/auth.rs | 5 +---- apps/api/src/routes/api/auth/login.rs | 2 +- apps/api/src/routes/api/restricted.rs | 2 +- apps/api/src/services/auth/authentication.rs | 8 ++++++++ .../auth/authentication/strategies/password.rs | 10 +++++----- apps/api/src/services/auth/user.rs | 10 ++++++++++ 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9029dfa..473d1d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,12 @@ { - "cSpell.words": ["YANPM"] + "cSpell.words": ["chrono", "jsonwebtoken", "oneshot", "utoipa", "YANPM"], + "sqltools.useNodeRuntime": true, + "sqltools.connections": [ + { + "previewLimit": 50, + "driver": "SQLite", + "database": "${workspaceFolder:yet-another-nginx-proxy-manager}/apps/container/generated/sqlite/sqlite.db", + "name": "YANPM" + } + ] } diff --git a/apps/api/src/routes/api/auth.rs b/apps/api/src/routes/api/auth.rs index 2242a44..a76b52d 100644 --- a/apps/api/src/routes/api/auth.rs +++ b/apps/api/src/routes/api/auth.rs @@ -3,10 +3,7 @@ pub mod login; use std::sync::Arc; -use axum::{ - Router, - routing::{get, post}, -}; +use axum::{Router, routing::post}; use crate::routes::AppState; diff --git a/apps/api/src/routes/api/auth/login.rs b/apps/api/src/routes/api/auth/login.rs index a2abf66..06f16c4 100644 --- a/apps/api/src/routes/api/auth/login.rs +++ b/apps/api/src/routes/api/auth/login.rs @@ -92,7 +92,7 @@ pub async fn login(State(state): State>, Json(payload): Json resp, Err(e) => { error!("Error building response: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); + (StatusCode::INTERNAL_SERVER_ERROR).into_response() } } } diff --git a/apps/api/src/routes/api/restricted.rs b/apps/api/src/routes/api/restricted.rs index 2372184..a5ec9f4 100644 --- a/apps/api/src/routes/api/restricted.rs +++ b/apps/api/src/routes/api/restricted.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use axum::{Router, routing::get}; +use axum::Router; use crate::{middlewares::require_auth::require_auth, routes::AppState}; diff --git a/apps/api/src/services/auth/authentication.rs b/apps/api/src/services/auth/authentication.rs index a7c1096..131486e 100644 --- a/apps/api/src/services/auth/authentication.rs +++ b/apps/api/src/services/auth/authentication.rs @@ -14,6 +14,7 @@ use tokio::sync::RwLock; use crate::errors::service_error::ServiceError; // Number of requests between invalidation cache cleanups +#[allow(dead_code)] // TODO: remove when used const INVALIDATE_CACHE_CLEANUP_INTERVAL_REQUESTS: usize = 100; // Cleanup every 100 for invalidation checks #[derive(Serialize, Deserialize, Clone)] @@ -38,10 +39,15 @@ pub trait AuthenticationService: Send + Sync { token: &str, target_sub: Option, ) -> Result, ServiceError>; + #[allow(dead_code)] // TODO: remove when used async fn parse_jwt(&self, token: &str) -> Result; + #[allow(dead_code)] // TODO: remove when used async fn invalidate_jwt(&self, token: &str) -> Result<(), ServiceError>; + #[allow(dead_code)] // TODO: remove when used async fn refresh_jwt(&self, token: &str, duration_secs: u64) -> Result; + #[allow(dead_code)] // TODO: remove when used async fn logout(&self, token: &str) -> Result<(), ServiceError>; + #[allow(dead_code)] // TODO: remove when used async fn cleanup_invalidation_cache(&self); } @@ -54,7 +60,9 @@ struct InvalidationEntry { pub struct AuthenticationServiceImpl { secret: String, + #[allow(dead_code)] // TODO: remove when used invalidation_cache: Arc>>, + #[allow(dead_code)] // TODO: remove when used cache_cleanup_counter: Arc>, } diff --git a/apps/api/src/services/auth/authentication/strategies/password.rs b/apps/api/src/services/auth/authentication/strategies/password.rs index 167685c..ce7d794 100644 --- a/apps/api/src/services/auth/authentication/strategies/password.rs +++ b/apps/api/src/services/auth/authentication/strategies/password.rs @@ -68,7 +68,7 @@ impl PasswordStrategy { Ok(user.id) } - + #[allow(dead_code)] // TODO: remove when used pub async fn revoke_identity( &self, user_id: Uuid, @@ -126,7 +126,7 @@ impl PasswordStrategy { Ok(()) } - + #[allow(dead_code)] // TODO: remove when used pub async fn update_password( &self, user_id: Uuid, @@ -368,7 +368,7 @@ mod test { user_id: Uuid::new_v4(), email: None, provider: PASSWORD_PROVIDER.to_string(), - password_hash: Some("somehash".to_string()), + password_hash: Some("some_hash".to_string()), metadata: None, is_revoked: false, revoked_at: None, @@ -413,7 +413,7 @@ mod test { user_id, email: None, provider: PASSWORD_PROVIDER.to_string(), - password_hash: Some("oldhash".to_string()), + password_hash: Some("old_hash".to_string()), metadata: None, is_revoked: false, revoked_at: None, @@ -430,7 +430,7 @@ mod test { user_id, email: None, provider: PASSWORD_PROVIDER.to_string(), - password_hash: Some("newhash".to_string()), + password_hash: Some("new_hash".to_string()), metadata: None, is_revoked: false, revoked_at: None, diff --git a/apps/api/src/services/auth/user.rs b/apps/api/src/services/auth/user.rs index add0dbf..03703ac 100644 --- a/apps/api/src/services/auth/user.rs +++ b/apps/api/src/services/auth/user.rs @@ -17,11 +17,13 @@ pub trait UserService: Send + Sync { user_id: Uuid, tx: Option<&mut DatabaseTransaction>, ) -> Result; + #[allow(dead_code)] // TODO: remove when used async fn is_admin( &self, user_id: Uuid, tx: Option<&mut DatabaseTransaction>, ) -> Result; + #[allow(dead_code)] // TODO: remove when used async fn user_exists( &self, username: &str, @@ -32,12 +34,14 @@ pub trait UserService: Send + Sync { user: NewUser, tx: Option<&mut DatabaseTransaction>, ) -> Result; + #[allow(dead_code)] // TODO: remove when used async fn update_user( &self, user_id: Uuid, user: UpdateUser, tx: Option<&mut DatabaseTransaction>, ) -> Result; + #[allow(dead_code)] // TODO: remove when used async fn delete_user( &self, user_id: Uuid, @@ -47,7 +51,9 @@ pub trait UserService: Send + Sync { pub struct User { pub id: Uuid, + #[allow(dead_code)] // TODO: remove when used pub username: String, + #[allow(dead_code)] // TODO: remove when used pub is_admin: bool, } @@ -67,12 +73,16 @@ pub struct NewUser { } pub struct UpdateUser { + #[allow(dead_code)] // TODO: remove when used pub username: Option, + #[allow(dead_code)] // TODO: remove when used pub is_admin: Option, + #[allow(dead_code)] // TODO: remove when used pub is_active: Option, } impl UpdateUser { + #[allow(dead_code)] // TODO: remove when used fn apply_to_active_model(&self, model: &mut UserActiveModel) { if let Some(username) = &self.username { model.name = ActiveValue::Set(username.clone()); From 507b5f0e49d74d368589355b4b86c5dc3c18a652 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:22:13 +0800 Subject: [PATCH 19/19] feat: enforce strict expiration checking for JWT and handle existing user identities in password strategy --- apps/api/src/services/auth/authentication.rs | 11 +++- .../authentication/strategies/password.rs | 62 +++++++++++++++---- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/apps/api/src/services/auth/authentication.rs b/apps/api/src/services/auth/authentication.rs index 131486e..d471e73 100644 --- a/apps/api/src/services/auth/authentication.rs +++ b/apps/api/src/services/auth/authentication.rs @@ -115,6 +115,8 @@ impl AuthenticationService for AuthenticationServiceImpl { target_sub: Option, ) -> Result, ServiceError> { let mut validation = Validation::default(); + // disable leeway for strict expiration checking + validation.leeway = 0; if let Some(expected_sub) = target_sub { validation.sub = Some(expected_sub); } @@ -247,11 +249,16 @@ mod tests { let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let user_id = Uuid::new_v4(); - let (token, _) = service.generate_jwt(user_id, 1).await.unwrap(); + let (token, claims) = service.generate_jwt(user_id, 1).await.unwrap(); sleep(Duration::from_secs(2)).await; let valid = service.is_valid_jwt(&token, None).await.unwrap(); - assert!(valid.is_none(), "Token should be expired and thus invalid"); + assert!( + valid.is_none(), + "Token should be expired and thus invalid. Current time: {:?}. Diff: {}", + chrono::Utc::now(), + chrono::Utc::now().timestamp() - claims.exp as i64 + ); } #[tokio::test] diff --git a/apps/api/src/services/auth/authentication/strategies/password.rs b/apps/api/src/services/auth/authentication/strategies/password.rs index ce7d794..e152ae9 100644 --- a/apps/api/src/services/auth/authentication/strategies/password.rs +++ b/apps/api/src/services/auth/authentication/strategies/password.rs @@ -102,6 +102,23 @@ impl PasswordStrategy { ) -> Result<(), ServiceError> { Self::is_valid_password(password).map_err(ServiceError::BadRequest)?; + // If an identity already exists for this user/provider, treat as success. + // This also allows tests using MockDatabase to provide a query result + // for an existing identity without requiring an insert exec result. + let existing = with_conn!(&*self.connection, tx, conn, { + user_identity::Entity::find() + .filter(user_identity::Column::UserId.eq(user_id)) + .filter(user_identity::Column::Provider.eq(PASSWORD_PROVIDER.to_string())) + .one(*conn) + .await? + }); + + if existing.is_some() { + return Err(ServiceError::BadRequest( + "Identity already exists".to_string(), + )); + } + let password_hash = Argon2::default() .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) .map_err(|_| ServiceError::InternalError("Failed to hash password".to_string()))? @@ -363,19 +380,14 @@ mod test { #[tokio::test] async fn create_identity_success() { let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) - .append_query_results(vec![vec![user_identity::Model { - id: Uuid::new_v4(), - user_id: Uuid::new_v4(), - email: None, - provider: PASSWORD_PROVIDER.to_string(), - password_hash: Some("some_hash".to_string()), - metadata: None, - is_revoked: false, - revoked_at: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - password_changed_at: None, - }]]) + // No existing identity + .append_query_results(vec![Vec::::new()]) + // Insert exec result (mock exec result for insert) + .append_exec_results(vec![sea_orm::MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }]) + // Return inserted identity for any subsequent queries .into_connection(); let strategy = PasswordStrategy::new(Arc::new(db)); @@ -391,6 +403,30 @@ mod test { ); } + #[tokio::test] + async fn create_identity_existing() { + let user_id = Uuid::new_v4(); + let identity = user_identity::Model { + id: Uuid::new_v4(), + user_id, + email: None, + provider: PASSWORD_PROVIDER.to_string(), + password_hash: Some("hash".to_string()), + metadata: None, + is_revoked: false, + revoked_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + password_changed_at: None, + }; + let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) + .append_query_results(vec![vec![identity]]) + .into_connection(); + let strategy = PasswordStrategy::new(Arc::new(db)); + let result = strategy.create_identity(user_id, "ValidPass1!", None).await; + assert!(matches!(result, Err(ServiceError::BadRequest(_)))); + } + #[tokio::test] async fn update_password_not_found() { let user_id = Uuid::new_v4();