You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-07-29 22:01:14 +03:00
graphql: admin API to add a user, lock them, and add emails without verification
This commit is contained in:
@ -63,6 +63,16 @@ impl User {
|
||||
&self.0.username
|
||||
}
|
||||
|
||||
/// When the object was created.
|
||||
pub async fn created_at(&self) -> DateTime<Utc> {
|
||||
self.0.created_at
|
||||
}
|
||||
|
||||
/// When the user was locked out.
|
||||
pub async fn locked_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.0.locked_at
|
||||
}
|
||||
|
||||
/// Access to the user's Matrix account information.
|
||||
async fn matrix(&self, ctx: &Context<'_>) -> Result<MatrixUser, async_graphql::Error> {
|
||||
let state = ctx.state();
|
||||
|
@ -16,6 +16,7 @@ mod browser_session;
|
||||
mod compat_session;
|
||||
mod matrix;
|
||||
mod oauth2_session;
|
||||
mod user;
|
||||
mod user_email;
|
||||
|
||||
use async_graphql::MergedObject;
|
||||
@ -24,6 +25,7 @@ use async_graphql::MergedObject;
|
||||
#[derive(Default, MergedObject)]
|
||||
pub struct Mutation(
|
||||
user_email::UserEmailMutations,
|
||||
user::UserMutations,
|
||||
oauth2_session::OAuth2SessionMutations,
|
||||
compat_session::CompatSessionMutations,
|
||||
browser_session::BrowserSessionMutations,
|
||||
|
235
crates/graphql/src/mutations/user.rs
Normal file
235
crates/graphql/src/mutations/user.rs
Normal file
@ -0,0 +1,235 @@
|
||||
// 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.
|
||||
|
||||
use async_graphql::{Context, Description, Enum, InputObject, Object, ID};
|
||||
use mas_storage::{
|
||||
job::{DeactivateUserJob, JobRepositoryExt, ProvisionUserJob},
|
||||
user::UserRepository,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
model::{NodeType, User},
|
||||
state::ContextExt,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UserMutations {
|
||||
_private: (),
|
||||
}
|
||||
|
||||
/// The input for the `addUser` mutation.
|
||||
#[derive(InputObject)]
|
||||
struct AddUserInput {
|
||||
/// The username of the user to add.
|
||||
username: String,
|
||||
}
|
||||
|
||||
/// The status of the `addUser` mutation.
|
||||
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
|
||||
enum AddUserStatus {
|
||||
/// The user was added.
|
||||
Added,
|
||||
|
||||
/// The user already exists.
|
||||
Exists,
|
||||
|
||||
/// The username is invalid.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// The payload for the `addUser` mutation.
|
||||
#[derive(Description)]
|
||||
enum AddUserPayload {
|
||||
Added(mas_data_model::User),
|
||||
Exists(mas_data_model::User),
|
||||
Invalid,
|
||||
}
|
||||
|
||||
#[Object(use_type_description)]
|
||||
impl AddUserPayload {
|
||||
/// Status of the operation
|
||||
async fn status(&self) -> AddUserStatus {
|
||||
match self {
|
||||
Self::Added(_) => AddUserStatus::Added,
|
||||
Self::Exists(_) => AddUserStatus::Exists,
|
||||
Self::Invalid => AddUserStatus::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
/// The user that was added.
|
||||
async fn user(&self) -> Option<User> {
|
||||
match self {
|
||||
Self::Added(user) | Self::Exists(user) => Some(User(user.clone())),
|
||||
Self::Invalid => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The input for the `lockUser` mutation.
|
||||
#[derive(InputObject)]
|
||||
struct LockUserInput {
|
||||
/// The ID of the user to lock.
|
||||
user_id: ID,
|
||||
|
||||
/// Permanently lock the user.
|
||||
deactivate: Option<bool>,
|
||||
}
|
||||
|
||||
/// The status of the `lockUser` mutation.
|
||||
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
|
||||
enum LockUserStatus {
|
||||
/// The user was locked.
|
||||
Locked,
|
||||
|
||||
/// The user was not found.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// The payload for the `lockUser` mutation.
|
||||
#[derive(Description)]
|
||||
enum LockUserPayload {
|
||||
/// The user was locked.
|
||||
Locked(mas_data_model::User),
|
||||
|
||||
/// The user was not found.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[Object(use_type_description)]
|
||||
impl LockUserPayload {
|
||||
/// Status of the operation
|
||||
async fn status(&self) -> LockUserStatus {
|
||||
match self {
|
||||
Self::Locked(_) => LockUserStatus::Locked,
|
||||
Self::NotFound => LockUserStatus::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
/// The user that was locked.
|
||||
async fn user(&self) -> Option<User> {
|
||||
match self {
|
||||
Self::Locked(user) => Some(User(user.clone())),
|
||||
Self::NotFound => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_username_character(c: char) -> bool {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| c == '='
|
||||
|| c == '_'
|
||||
|| c == '-'
|
||||
|| c == '.'
|
||||
|| c == '/'
|
||||
|| c == '+'
|
||||
}
|
||||
|
||||
// XXX: this should probably be moved somewhere else
|
||||
fn username_valid(username: &str) -> bool {
|
||||
if username.is_empty() || username.len() > 255 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should not start with an underscore
|
||||
if username.get(0..1) == Some("_") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should only contain valid characters
|
||||
if !username.chars().all(valid_username_character) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[Object]
|
||||
impl UserMutations {
|
||||
/// Add a user. This is only available to administrators.
|
||||
async fn add_user(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: AddUserInput,
|
||||
) -> Result<AddUserPayload, async_graphql::Error> {
|
||||
let state = ctx.state();
|
||||
let requester = ctx.requester();
|
||||
let clock = state.clock();
|
||||
let mut rng = state.rng();
|
||||
|
||||
if !requester.is_admin() {
|
||||
return Err(async_graphql::Error::new("Unauthorized"));
|
||||
}
|
||||
|
||||
let mut repo = state.repository().await?;
|
||||
|
||||
if let Some(user) = repo.user().find_by_username(&input.username).await? {
|
||||
return Ok(AddUserPayload::Exists(user));
|
||||
}
|
||||
|
||||
// Do some basic check on the username
|
||||
if !username_valid(&input.username) {
|
||||
return Ok(AddUserPayload::Invalid);
|
||||
}
|
||||
|
||||
let user = repo.user().add(&mut rng, &clock, input.username).await?;
|
||||
|
||||
repo.job()
|
||||
.schedule_job(ProvisionUserJob::new(&user))
|
||||
.await?;
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
Ok(AddUserPayload::Added(user))
|
||||
}
|
||||
|
||||
/// Lock a user. This is only available to administrators.
|
||||
async fn lock_user(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: LockUserInput,
|
||||
) -> Result<LockUserPayload, async_graphql::Error> {
|
||||
let state = ctx.state();
|
||||
let requester = ctx.requester();
|
||||
|
||||
if !requester.is_admin() {
|
||||
return Err(async_graphql::Error::new("Unauthorized"));
|
||||
}
|
||||
|
||||
let mut repo = state.repository().await?;
|
||||
|
||||
let user_id = NodeType::User.extract_ulid(&input.user_id)?;
|
||||
let user = repo.user().lookup(user_id).await?;
|
||||
|
||||
let Some(user) = user else {
|
||||
return Ok(LockUserPayload::NotFound);
|
||||
};
|
||||
|
||||
let deactivate = input.deactivate.unwrap_or(false);
|
||||
|
||||
let user = repo.user().lock(&state.clock(), user).await?;
|
||||
|
||||
if deactivate {
|
||||
info!("Scheduling deactivation of user {}", user.id);
|
||||
repo.job()
|
||||
.schedule_job(DeactivateUserJob::new(&user, deactivate))
|
||||
.await?;
|
||||
}
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
Ok(LockUserPayload::Locked(user))
|
||||
}
|
||||
}
|
@ -36,8 +36,15 @@ pub struct UserEmailMutations {
|
||||
struct AddEmailInput {
|
||||
/// The email address to add
|
||||
email: String,
|
||||
|
||||
/// The ID of the user to add the email address to
|
||||
user_id: ID,
|
||||
|
||||
/// Skip the email address verification. Only allowed for admins.
|
||||
skip_verification: Option<bool>,
|
||||
|
||||
/// Skip the email address policy check. Only allowed for admins.
|
||||
skip_policy_check: Option<bool>,
|
||||
}
|
||||
|
||||
/// The status of the `addEmail` mutation
|
||||
@ -382,6 +389,16 @@ impl UserEmailMutations {
|
||||
return Err(async_graphql::Error::new("Unauthorized"));
|
||||
}
|
||||
|
||||
// Only admins can skip validation
|
||||
if (input.skip_verification.is_some() || input.skip_policy_check.is_some())
|
||||
&& !requester.is_admin()
|
||||
{
|
||||
return Err(async_graphql::Error::new("Unauthorized"));
|
||||
}
|
||||
|
||||
let skip_verification = input.skip_verification.unwrap_or(false);
|
||||
let skip_policy_check = input.skip_policy_check.unwrap_or(false);
|
||||
|
||||
let mut repo = state.repository().await?;
|
||||
|
||||
let user = repo
|
||||
@ -398,17 +415,19 @@ impl UserEmailMutations {
|
||||
return Ok(AddEmailPayload::Invalid);
|
||||
}
|
||||
|
||||
let mut policy = state.policy().await?;
|
||||
let res = policy.evaluate_email(&input.email).await?;
|
||||
if !res.valid() {
|
||||
return Ok(AddEmailPayload::Denied {
|
||||
violations: res.violations,
|
||||
});
|
||||
if !skip_policy_check {
|
||||
let mut policy = state.policy().await?;
|
||||
let res = policy.evaluate_email(&input.email).await?;
|
||||
if !res.valid() {
|
||||
return Ok(AddEmailPayload::Denied {
|
||||
violations: res.violations,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find an existing email address
|
||||
let existing_user_email = repo.user_email().find(&user, &input.email).await?;
|
||||
let (added, user_email) = if let Some(user_email) = existing_user_email {
|
||||
let (added, mut user_email) = if let Some(user_email) = existing_user_email {
|
||||
(false, user_email)
|
||||
} else {
|
||||
let clock = state.clock();
|
||||
@ -424,9 +443,16 @@ impl UserEmailMutations {
|
||||
|
||||
// Schedule a job to verify the email address if needed
|
||||
if user_email.confirmed_at.is_none() {
|
||||
repo.job()
|
||||
.schedule_job(VerifyEmailJob::new(&user_email))
|
||||
.await?;
|
||||
if skip_verification {
|
||||
user_email = repo
|
||||
.user_email()
|
||||
.mark_as_verified(&state.clock(), user_email)
|
||||
.await?;
|
||||
} else {
|
||||
repo.job()
|
||||
.schedule_job(VerifyEmailJob::new(&user_email))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
repo.save().await?;
|
||||
|
@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use async_graphql::{Context, MergedObject, Object, ID};
|
||||
use mas_storage::user::UserRepository;
|
||||
|
||||
use crate::{
|
||||
model::{Anonymous, BrowserSession, Node, NodeType, OAuth2Client, User, UserEmail},
|
||||
@ -99,6 +100,30 @@ impl BaseQuery {
|
||||
Ok(user.map(User))
|
||||
}
|
||||
|
||||
/// Fetch a user by its username.
|
||||
async fn user_by_username(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
username: String,
|
||||
) -> Result<Option<User>, async_graphql::Error> {
|
||||
let requester = ctx.requester();
|
||||
let state = ctx.state();
|
||||
let mut repo = state.repository().await?;
|
||||
|
||||
let user = repo.user().find_by_username(&username).await?;
|
||||
let Some(user) = user else {
|
||||
// We don't want to leak the existence of a user
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Users can only see themselves, except for admins
|
||||
if !requester.is_owner_or_admin(&user) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(User(user)))
|
||||
}
|
||||
|
||||
/// Fetch a browser session by its ID.
|
||||
async fn browser_session(
|
||||
&self,
|
||||
|
Reference in New Issue
Block a user