Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion cot/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -890,6 +893,10 @@ impl AuthInner {
}

async fn login(&self, user: Box<dyn User + Send + Sync + 'static>) -> 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?;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")));
Expand Down Expand Up @@ -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")));
Expand All @@ -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")));
Expand All @@ -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")));
Expand Down Expand Up @@ -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])));
Expand All @@ -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()));
Expand Down Expand Up @@ -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)))
Expand Down
55 changes: 54 additions & 1 deletion cot/src/auth/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub struct DatabaseUser {
#[model(unique)]
username: LimitedString<MAX_USERNAME_LENGTH>,
password: PasswordHash,
is_active: bool,
}

/// An error that occurs when creating a user.
Expand All @@ -61,6 +62,7 @@ impl DatabaseUser {
id,
username,
password: PasswordHash::from_password(password),
is_active: true,
}
}

Expand Down Expand Up @@ -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<Html> {
/// 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(())
/// # }
/// ```
Comment on lines +122 to +150
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.
///
Expand Down Expand Up @@ -353,7 +392,7 @@ impl User for DatabaseUser {
}

fn is_active(&self) -> bool {
true
self.is_active
}

fn is_authenticated(&self) -> bool {
Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 3 additions & 1 deletion cot/src/auth/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
35 changes: 35 additions & 0 deletions cot/src/auth/db/migrations/m_0002_add_is_active.rs
Original file line number Diff line number Diff line change
@@ -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"),
<bool as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<bool as ::cot::db::DatabaseField>::NULLABLE),
)
.build()];
Comment on lines +14 to +23
}

#[derive(::core::fmt::Debug)]
#[::cot::db::model(model_type = "migration")]
struct _DatabaseUser {
#[model(primary_key)]
id: cot::db::Auto<i64>,
#[model(unique)]
username: crate::db::LimitedString<{ crate::auth::db::MAX_USERNAME_LENGTH }>,
password: crate::auth::PasswordHash,
is_active: bool,
}
Loading