diff --git a/cot/src/auth.rs b/cot/src/auth.rs index 550bf1d2..1c590a9b 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 8eb6190f..39b1cf49 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,43 @@ impl DatabaseUser { Ok(user) } + /// Sets whether the user is active. + /// + /// # Example + /// + /// ``` + /// 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!")) + /// } + /// + /// # #[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; + } + /// Retrieves a user by their integer ID. It returns [`None`] if the user /// does not exist. /// @@ -353,7 +392,7 @@ impl User for DatabaseUser { } fn is_active(&self) -> bool { - true + self.is_active } fn is_authenticated(&self) -> bool { @@ -609,6 +648,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 71cc67a7..9a0d0b16 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 00000000..1d7a095b --- /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, +}