1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-29 22:01:14 +03:00

Add a way to lock users

This commit is contained in:
Quentin Gliech
2023-07-28 18:25:54 +02:00
parent 8f01d1198c
commit 40b49cdd10
16 changed files with 277 additions and 12 deletions

View File

@ -69,6 +69,18 @@ enum Subcommand {
#[arg(long)] #[arg(long)]
dry_run: bool, dry_run: bool,
}, },
/// Lock a user
LockUser {
/// User to lock
username: String,
},
/// Unlock a user
UnlockUser {
/// User to unlock
username: String,
},
} }
impl Options { impl Options {
@ -330,6 +342,46 @@ impl Options {
Ok(()) Ok(())
} }
SC::LockUser { username } => {
let _span = info_span!("cli.manage.lock_user", user.username = username).entered();
let config: DatabaseConfig = root.load_config()?;
let pool = database_from_config(&config).await?;
let mut repo = PgRepository::from_pool(&pool).await?.boxed();
let user = repo
.user()
.find_by_username(&username)
.await?
.context("User not found")?;
info!(%user.id, "Locking user");
repo.user().lock(&clock, user).await?;
repo.save().await?;
Ok(())
}
SC::UnlockUser { username } => {
let _span = info_span!("cli.manage.lock_user", user.username = username).entered();
let config: DatabaseConfig = root.load_config()?;
let pool = database_from_config(&config).await?;
let mut repo = PgRepository::from_pool(&pool).await?.boxed();
let user = repo
.user()
.find_by_username(&username)
.await?
.context("User not found")?;
info!(%user.id, "Unlocking user");
repo.user().unlock(user).await?;
repo.save().await?;
Ok(())
}
} }
} }
} }

View File

@ -25,6 +25,16 @@ pub struct User {
pub username: String, pub username: String,
pub sub: String, pub sub: String,
pub primary_user_email_id: Option<Ulid>, pub primary_user_email_id: Option<Ulid>,
pub created_at: DateTime<Utc>,
pub locked_at: Option<DateTime<Utc>>,
}
impl User {
/// Returns `true` unless the user is locked.
#[must_use]
pub fn is_valid(&self) -> bool {
self.locked_at.is_none()
}
} }
impl User { impl User {
@ -35,6 +45,8 @@ impl User {
username: "john".to_owned(), username: "john".to_owned(),
sub: "123-456".to_owned(), sub: "123-456".to_owned(),
primary_user_email_id: None, primary_user_email_id: None,
created_at: now,
locked_at: None,
}] }]
} }
} }
@ -65,7 +77,7 @@ pub struct BrowserSession {
impl BrowserSession { impl BrowserSession {
#[must_use] #[must_use]
pub fn active(&self) -> bool { pub fn active(&self) -> bool {
self.finished_at.is_none() self.finished_at.is_none() && self.user.is_valid()
} }
} }

View File

@ -335,6 +335,7 @@ async fn token_login(
.user() .user()
.lookup(session.user_id) .lookup(session.user_id)
.await? .await?
.filter(mas_data_model::User::is_valid)
.ok_or(RouteError::UserNotFound)?; .ok_or(RouteError::UserNotFound)?;
repo.compat_sso_login().exchange(clock, login).await?; repo.compat_sso_login().exchange(clock, login).await?;
@ -355,6 +356,7 @@ async fn user_password_login(
.user() .user()
.find_by_username(&username) .find_by_username(&username)
.await? .await?
.filter(mas_data_model::User::is_valid)
.ok_or(RouteError::UserNotFound)?; .ok_or(RouteError::UserNotFound)?;
// Lookup its password // Lookup its password

View File

@ -188,6 +188,7 @@ pub(crate) async fn post(
.browser_session() .browser_session()
.lookup(session.user_session_id) .lookup(session.user_session_id)
.await? .await?
.filter(|b| b.user.is_valid())
// XXX: is that the right error to bubble up? // XXX: is that the right error to bubble up?
.ok_or(RouteError::UnknownToken)?; .ok_or(RouteError::UnknownToken)?;
@ -227,6 +228,7 @@ pub(crate) async fn post(
.browser_session() .browser_session()
.lookup(session.user_session_id) .lookup(session.user_session_id)
.await? .await?
.filter(|b| b.user.is_valid())
// XXX: is that the right error to bubble up? // XXX: is that the right error to bubble up?
.ok_or(RouteError::UnknownToken)?; .ok_or(RouteError::UnknownToken)?;
@ -265,6 +267,7 @@ pub(crate) async fn post(
.user() .user()
.lookup(session.user_id) .lookup(session.user_id)
.await? .await?
.filter(mas_data_model::User::is_valid)
// XXX: is that the right error to bubble up? // XXX: is that the right error to bubble up?
.ok_or(RouteError::UnknownToken)?; .ok_or(RouteError::UnknownToken)?;
@ -311,6 +314,7 @@ pub(crate) async fn post(
.user() .user()
.lookup(session.user_id) .lookup(session.user_id)
.await? .await?
.filter(mas_data_model::User::is_valid)
// XXX: is that the right error to bubble up? // XXX: is that the right error to bubble up?
.ok_or(RouteError::UnknownToken)?; .ok_or(RouteError::UnknownToken)?;

View File

@ -23,7 +23,7 @@ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
SessionInfoExt, SessionInfoExt,
}; };
use mas_data_model::UpstreamOAuthProviderImportPreference; use mas_data_model::{UpstreamOAuthProviderImportPreference, User};
use mas_jose::jwt::Jwt; use mas_jose::jwt::Jwt;
use mas_keystore::Encrypter; use mas_keystore::Encrypter;
use mas_storage::{ use mas_storage::{
@ -239,6 +239,8 @@ pub(crate) async fn get(
.user() .user()
.lookup(user_id) .lookup(user_id)
.await? .await?
// XXX: is that right?
.filter(User::is_valid)
.ok_or(RouteError::UserNotFound)?; .ok_or(RouteError::UserNotFound)?;
let ctx = UpstreamExistingLinkContext::new(user) let ctx = UpstreamExistingLinkContext::new(user)
@ -263,6 +265,7 @@ pub(crate) async fn get(
.user() .user()
.lookup(user_id) .lookup(user_id)
.await? .await?
.filter(mas_data_model::User::is_valid)
.ok_or(RouteError::UserNotFound)?; .ok_or(RouteError::UserNotFound)?;
let ctx = UpstreamExistingLinkContext::new(user).with_csrf(csrf_token.form_value()); let ctx = UpstreamExistingLinkContext::new(user).with_csrf(csrf_token.form_value());
@ -390,6 +393,7 @@ pub(crate) async fn post(
.user() .user()
.lookup(user_id) .lookup(user_id)
.await? .await?
.filter(mas_data_model::User::is_valid)
.ok_or(RouteError::UserNotFound)?; .ok_or(RouteError::UserNotFound)?;
repo.browser_session().add(&mut rng, &clock, &user).await? repo.browser_session().add(&mut rng, &clock, &user).await?

View File

@ -202,6 +202,7 @@ async fn login(
.find_by_username(username) .find_by_username(username)
.await .await
.map_err(|_e| FormError::Internal)? .map_err(|_e| FormError::Internal)?
.filter(mas_data_model::User::is_valid)
.ok_or(FormError::InvalidCredentials)?; .ok_or(FormError::InvalidCredentials)?;
// And its password // And its password

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users\n SET locked_at = NULL\n WHERE user_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "22896e8f2a002f307089c3e0f9ee561e6521c45ce07d3a42411984c9a6b75fdc"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.primary_user_email_id AS \"user_primary_user_email_id\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", "query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.primary_user_email_id AS \"user_primary_user_email_id\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -32,6 +32,16 @@
"ordinal": 5, "ordinal": 5,
"name": "user_primary_user_email_id", "name": "user_primary_user_email_id",
"type_info": "Uuid" "type_info": "Uuid"
},
{
"ordinal": 6,
"name": "user_created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "user_locked_at",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@ -45,8 +55,10 @@
true, true,
false, false,
false, false,
true,
false,
true true
] ]
}, },
"hash": "25d61a373560556deafe056c8cd2982ac472f5ec2fab08b0b5275c4b78c11a7e" "hash": "73fe61f03a41778c6273b1c2dbdb13b91fbccfe5fbdbead8c4868d52a61a0f9d"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n FROM users\n WHERE username = $1\n ", "query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n , locked_at\n FROM users\n WHERE username = $1\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -22,6 +22,11 @@
"ordinal": 3, "ordinal": 3,
"name": "created_at", "name": "created_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "locked_at",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@ -33,8 +38,9 @@
false, false,
false, false,
true, true,
false false,
true
] ]
}, },
"hash": "836fb7567d84057fa7f1edaab834c21a158a5762fe220b6bfacd6576be6c613c" "hash": "bfa5eaeaa5b4574bb083c86711eb4599f6374c96bb4a6827d400acb22fb0fd39"
} }

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users\n SET locked_at = $1\n WHERE user_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Uuid"
]
},
"nullable": []
},
"hash": "c29fa41743811a6ac3a9b952b6ea75d18e914f823902587b63c9f295407144b1"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n FROM users\n WHERE user_id = $1\n ", "query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n , locked_at\n FROM users\n WHERE user_id = $1\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -22,6 +22,11 @@
"ordinal": 3, "ordinal": 3,
"name": "created_at", "name": "created_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "locked_at",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@ -33,8 +38,9 @@
false, false,
false, false,
true, true,
false false,
true
] ]
}, },
"hash": "08d7df347c806ef14b6d0fb031cab041d79ba48528420160e23286369db7af35" "hash": "e0ea7d93ab3f565828b2faab4cc5e1a6ac868c95bfaee3a6960df1cf484d53da"
} }

View File

@ -0,0 +1,19 @@
-- Copyright 2023 The Matrix.org Foundation C.I.C.
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
-- Add a new column in on the `users` to record when an account gets locked
ALTER TABLE "users"
ADD COLUMN "locked_at"
TIMESTAMP WITH TIME ZONE
DEFAULT NULL;

View File

@ -29,6 +29,8 @@ pub enum Users {
UserId, UserId,
Username, Username,
PrimaryUserEmailId, PrimaryUserEmailId,
CreatedAt,
LockedAt,
} }
#[derive(sea_query::Iden)] #[derive(sea_query::Iden)]

View File

@ -55,9 +55,8 @@ struct UserLookup {
user_id: Uuid, user_id: Uuid,
username: String, username: String,
primary_user_email_id: Option<Uuid>, primary_user_email_id: Option<Uuid>,
#[allow(dead_code)]
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
locked_at: Option<DateTime<Utc>>,
} }
impl From<UserLookup> for User { impl From<UserLookup> for User {
@ -68,6 +67,8 @@ impl From<UserLookup> for User {
username: value.username, username: value.username,
sub: id.to_string(), sub: id.to_string(),
primary_user_email_id: value.primary_user_email_id.map(Into::into), primary_user_email_id: value.primary_user_email_id.map(Into::into),
created_at: value.created_at,
locked_at: value.locked_at,
} }
} }
} }
@ -93,6 +94,7 @@ impl<'c> UserRepository for PgUserRepository<'c> {
, username , username
, primary_user_email_id , primary_user_email_id
, created_at , created_at
, locked_at
FROM users FROM users
WHERE user_id = $1 WHERE user_id = $1
"#, "#,
@ -124,6 +126,7 @@ impl<'c> UserRepository for PgUserRepository<'c> {
, username , username
, primary_user_email_id , primary_user_email_id
, created_at , created_at
, locked_at
FROM users FROM users
WHERE username = $1 WHERE username = $1
"#, "#,
@ -176,6 +179,8 @@ impl<'c> UserRepository for PgUserRepository<'c> {
username, username,
sub: id.to_string(), sub: id.to_string(),
primary_user_email_id: None, primary_user_email_id: None,
created_at,
locked_at: None,
}) })
} }
@ -203,4 +208,72 @@ impl<'c> UserRepository for PgUserRepository<'c> {
Ok(exists) Ok(exists)
} }
#[tracing::instrument(
name = "db.user.lock",
skip_all,
fields(
db.statement,
%user.id,
),
err,
)]
async fn lock(&mut self, clock: &dyn Clock, mut user: User) -> Result<User, Self::Error> {
if user.locked_at.is_some() {
return Ok(user);
}
let locked_at = clock.now();
let res = sqlx::query!(
r#"
UPDATE users
SET locked_at = $1
WHERE user_id = $2
"#,
locked_at,
Uuid::from(user.id),
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
user.locked_at = Some(locked_at);
Ok(user)
}
#[tracing::instrument(
name = "db.user.unlock",
skip_all,
fields(
db.statement,
%user.id,
),
err,
)]
async fn unlock(&mut self, mut user: User) -> Result<User, Self::Error> {
if user.locked_at.is_none() {
return Ok(user);
}
let res = sqlx::query!(
r#"
UPDATE users
SET locked_at = NULL
WHERE user_id = $1
"#,
Uuid::from(user.id),
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
user.locked_at = None;
Ok(user)
}
} }

View File

@ -53,6 +53,8 @@ struct SessionLookup {
user_id: Uuid, user_id: Uuid,
user_username: String, user_username: String,
user_primary_user_email_id: Option<Uuid>, user_primary_user_email_id: Option<Uuid>,
user_created_at: DateTime<Utc>,
user_locked_at: Option<DateTime<Utc>>,
} }
impl TryFrom<SessionLookup> for BrowserSession { impl TryFrom<SessionLookup> for BrowserSession {
@ -65,6 +67,8 @@ impl TryFrom<SessionLookup> for BrowserSession {
username: value.user_username, username: value.user_username,
sub: id.to_string(), sub: id.to_string(),
primary_user_email_id: value.user_primary_user_email_id.map(Into::into), primary_user_email_id: value.user_primary_user_email_id.map(Into::into),
created_at: value.user_created_at,
locked_at: value.user_locked_at,
}; };
Ok(BrowserSession { Ok(BrowserSession {
@ -99,6 +103,8 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
, u.user_id , u.user_id
, u.username AS "user_username" , u.username AS "user_username"
, u.primary_user_email_id AS "user_primary_user_email_id" , u.primary_user_email_id AS "user_primary_user_email_id"
, u.created_at AS "user_created_at"
, u.locked_at AS "user_locked_at"
FROM user_sessions s FROM user_sessions s
INNER JOIN users u INNER JOIN users u
USING (user_id) USING (user_id)
@ -232,6 +238,14 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
Expr::col((Users::Table, Users::PrimaryUserEmailId)), Expr::col((Users::Table, Users::PrimaryUserEmailId)),
SessionLookupIden::UserPrimaryUserEmailId, SessionLookupIden::UserPrimaryUserEmailId,
) )
.expr_as(
Expr::col((Users::Table, Users::CreatedAt)),
SessionLookupIden::UserCreatedAt,
)
.expr_as(
Expr::col((Users::Table, Users::LockedAt)),
SessionLookupIden::UserLockedAt,
)
.from(UserSessions::Table) .from(UserSessions::Table)
.inner_join( .inner_join(
Users::Table, Users::Table,

View File

@ -96,6 +96,33 @@ pub trait UserRepository: Send + Sync {
/// ///
/// Returns [`Self::Error`] if the underlying repository fails /// Returns [`Self::Error`] if the underlying repository fails
async fn exists(&mut self, username: &str) -> Result<bool, Self::Error>; async fn exists(&mut self, username: &str) -> Result<bool, Self::Error>;
/// Lock a [`User`]
///
/// Returns the locked [`User`]
///
/// # Parameters
///
/// * `clock`: The clock used to generate timestamps
/// * `user`: The [`User`] to lock
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn lock(&mut self, clock: &dyn Clock, user: User) -> Result<User, Self::Error>;
/// Unlock a [`User`]
///
/// Returns the unlocked [`User`]
///
/// # Parameters
///
/// * `user`: The [`User`] to unlock
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn unlock(&mut self, user: User) -> Result<User, Self::Error>;
} }
repository_impl!(UserRepository: repository_impl!(UserRepository:
@ -108,4 +135,6 @@ repository_impl!(UserRepository:
username: String, username: String,
) -> Result<User, Self::Error>; ) -> Result<User, Self::Error>;
async fn exists(&mut self, username: &str) -> Result<bool, Self::Error>; async fn exists(&mut self, username: &str) -> Result<bool, Self::Error>;
async fn lock(&mut self, clock: &dyn Clock, user: User) -> Result<User, Self::Error>;
async fn unlock(&mut self, user: User) -> Result<User, Self::Error>;
); );