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)]
dry_run: bool,
},
/// Lock a user
LockUser {
/// User to lock
username: String,
},
/// Unlock a user
UnlockUser {
/// User to unlock
username: String,
},
}
impl Options {
@ -330,6 +342,46 @@ impl Options {
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 sub: String,
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 {
@ -35,6 +45,8 @@ impl User {
username: "john".to_owned(),
sub: "123-456".to_owned(),
primary_user_email_id: None,
created_at: now,
locked_at: None,
}]
}
}
@ -65,7 +77,7 @@ pub struct BrowserSession {
impl BrowserSession {
#[must_use]
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()
.lookup(session.user_id)
.await?
.filter(mas_data_model::User::is_valid)
.ok_or(RouteError::UserNotFound)?;
repo.compat_sso_login().exchange(clock, login).await?;
@ -355,6 +356,7 @@ async fn user_password_login(
.user()
.find_by_username(&username)
.await?
.filter(mas_data_model::User::is_valid)
.ok_or(RouteError::UserNotFound)?;
// Lookup its password

View File

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

View File

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

View File

@ -202,6 +202,7 @@ async fn login(
.find_by_username(username)
.await
.map_err(|_e| FormError::Internal)?
.filter(mas_data_model::User::is_valid)
.ok_or(FormError::InvalidCredentials)?;
// 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",
"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": {
"columns": [
{
@ -32,6 +32,16 @@
"ordinal": 5,
"name": "user_primary_user_email_id",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "user_created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "user_locked_at",
"type_info": "Timestamptz"
}
],
"parameters": {
@ -45,8 +55,10 @@
true,
false,
false,
true,
false,
true
]
},
"hash": "25d61a373560556deafe056c8cd2982ac472f5ec2fab08b0b5275c4b78c11a7e"
"hash": "73fe61f03a41778c6273b1c2dbdb13b91fbccfe5fbdbead8c4868d52a61a0f9d"
}

View File

@ -1,6 +1,6 @@
{
"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": {
"columns": [
{
@ -22,6 +22,11 @@
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "locked_at",
"type_info": "Timestamptz"
}
],
"parameters": {
@ -33,8 +38,9 @@
false,
false,
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",
"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": {
"columns": [
{
@ -22,6 +22,11 @@
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "locked_at",
"type_info": "Timestamptz"
}
],
"parameters": {
@ -33,8 +38,9 @@
false,
false,
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,
Username,
PrimaryUserEmailId,
CreatedAt,
LockedAt,
}
#[derive(sea_query::Iden)]

View File

@ -55,9 +55,8 @@ struct UserLookup {
user_id: Uuid,
username: String,
primary_user_email_id: Option<Uuid>,
#[allow(dead_code)]
created_at: DateTime<Utc>,
locked_at: Option<DateTime<Utc>>,
}
impl From<UserLookup> for User {
@ -68,6 +67,8 @@ impl From<UserLookup> for User {
username: value.username,
sub: id.to_string(),
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
, primary_user_email_id
, created_at
, locked_at
FROM users
WHERE user_id = $1
"#,
@ -124,6 +126,7 @@ impl<'c> UserRepository for PgUserRepository<'c> {
, username
, primary_user_email_id
, created_at
, locked_at
FROM users
WHERE username = $1
"#,
@ -176,6 +179,8 @@ impl<'c> UserRepository for PgUserRepository<'c> {
username,
sub: id.to_string(),
primary_user_email_id: None,
created_at,
locked_at: None,
})
}
@ -203,4 +208,72 @@ impl<'c> UserRepository for PgUserRepository<'c> {
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_username: String,
user_primary_user_email_id: Option<Uuid>,
user_created_at: DateTime<Utc>,
user_locked_at: Option<DateTime<Utc>>,
}
impl TryFrom<SessionLookup> for BrowserSession {
@ -65,6 +67,8 @@ impl TryFrom<SessionLookup> for BrowserSession {
username: value.user_username,
sub: id.to_string(),
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 {
@ -99,6 +103,8 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
, u.user_id
, u.username AS "user_username"
, 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
INNER JOIN users u
USING (user_id)
@ -232,6 +238,14 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
Expr::col((Users::Table, Users::PrimaryUserEmailId)),
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)
.inner_join(
Users::Table,

View File

@ -96,6 +96,33 @@ pub trait UserRepository: Send + Sync {
///
/// Returns [`Self::Error`] if the underlying repository fails
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:
@ -108,4 +135,6 @@ repository_impl!(UserRepository:
username: String,
) -> Result<User, 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>;
);