From 8e3987f7ce39bc8d81a089dacb2ec46b13ea104c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Thu, 21 May 2026 15:44:02 +0200 Subject: [PATCH 1/2] feat: add an actual is_active field in the DatabaseUser --- cot/src/auth.rs | 54 ++++++++++++++++++- cot/src/auth/db.rs | 34 +++++++++++- cot/src/auth/db/migrations.rs | 4 +- .../db/migrations/m_0002_add_is_active.rs | 35 ++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 cot/src/auth/db/migrations/m_0002_add_is_active.rs diff --git a/cot/src/auth.rs b/cot/src/auth.rs index 550bf1d2b..1c590a9bd 100644 --- a/cot/src/auth.rs +++ b/cot/src/auth.rs @@ -54,6 +54,9 @@ pub enum AuthError { /// supported. #[error("{ERROR_PREFIX} tried to get a user by an unsupported user ID type")] UserIdTypeNotSupported, + /// The user is inactive and cannot log in. + #[error("{ERROR_PREFIX} user is inactive")] + UserInactive, } impl_into_cot_error!(AuthError, UNAUTHORIZED); @@ -890,6 +893,10 @@ impl AuthInner { } async fn login(&self, user: Box) -> Result<()> { + if !user.is_active() { + return Err(AuthError::UserInactive); + } + // Mitigate the session fixation attack by changing the session ID: // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#renew-the-session-id-after-any-privilege-level-change self.session.cycle_id().await?; @@ -942,7 +949,9 @@ async fn get_user_with_saved_id( return Ok(None); }; - if session_auth_hash_valid(&*user, session, secret_key, fallback_secret_keys).await? { + if user.is_active() + && session_auth_hash_valid(&*user, session, secret_key, fallback_secret_keys).await? + { Ok(Some(user)) } else { Ok(None) @@ -1222,6 +1231,7 @@ mod tests { let mut mock_user = MockUser::new(); mock_user.expect_id().return_const(UserId::Int(1)); mock_user.expect_session_auth_hash().return_const(None); + mock_user.expect_is_active().return_const(true); mock_user .expect_username() .return_const(Some(Cow::from("mockuser"))); @@ -1262,6 +1272,7 @@ mod tests { let mut mock_user = MockUser::new(); mock_user.expect_id().return_const(UserId::Int(1)); mock_user.expect_session_auth_hash().return_const(None); + mock_user.expect_is_active().return_const(true); mock_user .expect_username() .return_const(Some(Cow::from("mockuser"))); @@ -1286,6 +1297,7 @@ mod tests { let mut mock_user = MockUser::new(); mock_user.expect_id().return_const(UserId::Int(1)); mock_user.expect_session_auth_hash().return_const(None); + mock_user.expect_is_active().return_const(true); mock_user .expect_username() .return_const(Some(Cow::from("mockuser_1"))); @@ -1298,6 +1310,7 @@ mod tests { let mut mock_user = MockUser::new(); mock_user.expect_id().return_const(UserId::Int(2)); mock_user.expect_session_auth_hash().return_const(None); + mock_user.expect_is_active().return_const(true); mock_user .expect_username() .return_const(Some(Cow::from("mockuser_2"))); @@ -1327,6 +1340,43 @@ mod tests { assert!(!user.is_authenticated()); } + #[cot::test] + async fn logout_on_inactive_user() { + let mut request = test_request(|| { + let mut mock_user = MockUser::new(); + mock_user.expect_id().return_const(UserId::Int(1)); + mock_user.expect_session_auth_hash().return_const(None); + mock_user.expect_is_active().return_const(false); // Inactive + mock_user + .expect_username() + .return_const(Some(Cow::from("mockuser"))); + mock_user + }); + + Session::from_request(&request) + .insert(USER_ID_SESSION_KEY, UserId::Int(1)) + .await + .unwrap(); + let auth = Auth::from_request(&mut request).await.unwrap(); + + let user = auth.user(); + assert!(!user.is_authenticated()); + assert_eq!(user.username(), None); + } + + #[cot::test] + async fn login_inactive_user() { + let mut request = test_request(MockUser::new); + let auth = Auth::from_request(&mut request).await.unwrap(); + + let mut mock_user = MockUser::new(); + mock_user.expect_id().return_const(UserId::Int(1)); + mock_user.expect_is_active().return_const(false); // Inactive + + let result = auth.login(Box::new(mock_user)).await; + assert!(matches!(result, Err(AuthError::UserInactive))); + } + #[cot::test] async fn logout_on_session_hash_change() { let session_auth_hash = Arc::new(Mutex::new(SessionAuthHash::new(&[1, 2, 3]))); @@ -1335,6 +1385,7 @@ mod tests { let session_auth_hash_clone = Arc::clone(&session_auth_hash_clone); let mut mock_user = MockUser::new(); mock_user.expect_id().return_const(UserId::Int(1)); + mock_user.expect_is_active().return_const(true); mock_user .expect_session_auth_hash() .returning(move |_| Some(session_auth_hash_clone.lock().unwrap().clone())); @@ -1367,6 +1418,7 @@ mod tests { let create_user = move || { let mut mock_user = MockUser::new(); mock_user.expect_id().return_const(UserId::Int(1)); + mock_user.expect_is_active().return_const(true); mock_user .expect_session_auth_hash() .with(eq(SecretKey::new(TEST_KEY_1))) diff --git a/cot/src/auth/db.rs b/cot/src/auth/db.rs index 8eb6190f9..2cd6d1919 100644 --- a/cot/src/auth/db.rs +++ b/cot/src/auth/db.rs @@ -39,6 +39,7 @@ pub struct DatabaseUser { #[model(unique)] username: LimitedString, password: PasswordHash, + is_active: bool, } /// An error that occurs when creating a user. @@ -61,6 +62,7 @@ impl DatabaseUser { id, username, password: PasswordHash::from_password(password), + is_active: true, } } @@ -113,6 +115,22 @@ impl DatabaseUser { Ok(user) } + /// Sets whether the user is active. + /// + /// # Example + /// + /// ``` + /// use cot::auth::db::DatabaseUser; + /// # use cot::common_types::Password; + /// # use cot::db::{Auto, LimitedString}; + /// + /// # let mut user = DatabaseUser::new(Auto::fixed(1), LimitedString::new("user").unwrap(), &Password::new("password")); + /// user.set_is_active(false); + /// ``` + pub fn set_is_active(&mut self, is_active: bool) { + self.is_active = is_active; + } + /// Retrieves a user by their integer ID. It returns [`None`] if the user /// does not exist. /// @@ -353,7 +371,7 @@ impl User for DatabaseUser { } fn is_active(&self) -> bool { - true + self.is_active } fn is_authenticated(&self) -> bool { @@ -609,6 +627,20 @@ mod tests { ); } + #[test] + #[cfg_attr(miri, ignore)] + fn database_user_inactive() { + let mut user = DatabaseUser::new( + Auto::fixed(1), + LimitedString::new("testuser").unwrap(), + &Password::new("password123"), + ); + user.set_is_active(false); + + let user_ref: &dyn User = &user; + assert!(!user_ref.is_active()); + } + #[cot::test] #[cfg_attr(miri, ignore)] async fn create_user() { diff --git a/cot/src/auth/db/migrations.rs b/cot/src/auth/db/migrations.rs index 71cc67a71..9a0d0b16b 100644 --- a/cot/src/auth/db/migrations.rs +++ b/cot/src/auth/db/migrations.rs @@ -3,5 +3,7 @@ //! Generated by cot CLI 0.1.0 on 2025-02-13 10:29:03+00:00 pub mod m_0001_initial; +pub mod m_0002_add_is_active; /// The list of migrations for current app. -pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[&m_0001_initial::Migration]; +pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = + &[&m_0001_initial::Migration, &m_0002_add_is_active::Migration]; diff --git a/cot/src/auth/db/migrations/m_0002_add_is_active.rs b/cot/src/auth/db/migrations/m_0002_add_is_active.rs new file mode 100644 index 000000000..1d7a095b3 --- /dev/null +++ b/cot/src/auth/db/migrations/m_0002_add_is_active.rs @@ -0,0 +1,35 @@ +//! Generated by cot CLI 0.1.0 on 2026-05-21 12:00:00+00:00 + +#[derive(Debug, Copy, Clone)] +pub(super) struct Migration; +impl ::cot::db::migrations::Migration for Migration { + const APP_NAME: &'static str = "cot"; + const MIGRATION_NAME: &'static str = "m_0002_add_is_active"; + const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = + &[::cot::db::migrations::MigrationDependency::migration( + "cot", + "m_0001_initial", + )]; + const OPERATIONS: &'static [::cot::db::migrations::Operation] = + &[::cot::db::migrations::Operation::add_field() + .table_name(::cot::db::Identifier::new("cot__database_user")) + .field( + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("is_active"), + ::TYPE, + ) + .set_null(::NULLABLE), + ) + .build()]; +} + +#[derive(::core::fmt::Debug)] +#[::cot::db::model(model_type = "migration")] +struct _DatabaseUser { + #[model(primary_key)] + id: cot::db::Auto, + #[model(unique)] + username: crate::db::LimitedString<{ crate::auth::db::MAX_USERNAME_LENGTH }>, + password: crate::auth::PasswordHash, + is_active: bool, +} From c84344cb0c8c0d986b6e47113d00143bb658fe32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Thu, 21 May 2026 16:31:20 +0200 Subject: [PATCH 2/2] update test --- cot/src/auth/db.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/cot/src/auth/db.rs b/cot/src/auth/db.rs index 2cd6d1919..39b1cf49f 100644 --- a/cot/src/auth/db.rs +++ b/cot/src/auth/db.rs @@ -120,12 +120,33 @@ impl DatabaseUser { /// # Example /// /// ``` - /// use cot::auth::db::DatabaseUser; - /// # use cot::common_types::Password; - /// # use cot::db::{Auto, LimitedString}; + /// use cot::auth::User; + /// use cot::auth::db::{DatabaseUser, DatabaseUserCredentials}; + /// use cot::common_types::Password; + /// use cot::db::{Database, Model}; + /// use cot::html::Html; + /// + /// async fn view(db: Database) -> cot::Result { + /// let mut user = + /// DatabaseUser::create_user(&db, "testuser".to_string(), &Password::new("password123")) + /// .await?; + /// user.set_is_active(false); + /// user.save(&db).await?; + /// + /// assert!(!user.is_active()); + /// + /// Ok(Html::new("is_active changed!")) + /// } /// - /// # let mut user = DatabaseUser::new(Auto::fixed(1), LimitedString::new("user").unwrap(), &Password::new("password")); - /// user.set_is_active(false); + /// # #[tokio::main] + /// # async fn main() -> cot::Result<()> { + /// # use cot::test::{TestDatabase, TestRequestBuilder}; + /// # let mut test_database = TestDatabase::new_sqlite().await?; + /// # test_database.with_auth().run_migrations().await; + /// # view(test_database.database()).await?; + /// # test_database.cleanup().await?; + /// # Ok(()) + /// # } /// ``` pub fn set_is_active(&mut self, is_active: bool) { self.is_active = is_active;