You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-08-06 06:02:40 +03:00
admin: add API to create users
This commit is contained in:
@@ -26,10 +26,12 @@ use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
|
|||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use mas_axum_utils::FancyError;
|
use mas_axum_utils::FancyError;
|
||||||
use mas_http::CorsLayerExt;
|
use mas_http::CorsLayerExt;
|
||||||
|
use mas_matrix::BoxHomeserverConnection;
|
||||||
use mas_router::{
|
use mas_router::{
|
||||||
ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute,
|
ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute,
|
||||||
UrlBuilder,
|
UrlBuilder,
|
||||||
};
|
};
|
||||||
|
use mas_storage::BoxRng;
|
||||||
use mas_templates::{ApiDocContext, Templates};
|
use mas_templates::{ApiDocContext, Templates};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
@@ -45,6 +47,8 @@ use self::call_context::CallContext;
|
|||||||
pub fn router<S>() -> (OpenApi, Router<S>)
|
pub fn router<S>() -> (OpenApi, Router<S>)
|
||||||
where
|
where
|
||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
|
BoxHomeserverConnection: FromRef<S>,
|
||||||
|
BoxRng: FromRequestParts<S>,
|
||||||
CallContext: FromRequestParts<S>,
|
CallContext: FromRequestParts<S>,
|
||||||
Templates: FromRef<S>,
|
Templates: FromRef<S>,
|
||||||
UrlBuilder: FromRef<S>,
|
UrlBuilder: FromRef<S>,
|
||||||
|
@@ -13,7 +13,9 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use aide::axum::{routing::get_with, ApiRouter};
|
use aide::axum::{routing::get_with, ApiRouter};
|
||||||
use axum::extract::FromRequestParts;
|
use axum::extract::{FromRef, FromRequestParts};
|
||||||
|
use mas_matrix::BoxHomeserverConnection;
|
||||||
|
use mas_storage::BoxRng;
|
||||||
|
|
||||||
use super::call_context::CallContext;
|
use super::call_context::CallContext;
|
||||||
|
|
||||||
@@ -22,10 +24,16 @@ mod users;
|
|||||||
pub fn router<S>() -> ApiRouter<S>
|
pub fn router<S>() -> ApiRouter<S>
|
||||||
where
|
where
|
||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
|
BoxHomeserverConnection: FromRef<S>,
|
||||||
|
BoxRng: FromRequestParts<S>,
|
||||||
CallContext: FromRequestParts<S>,
|
CallContext: FromRequestParts<S>,
|
||||||
{
|
{
|
||||||
ApiRouter::<S>::new()
|
ApiRouter::<S>::new()
|
||||||
.api_route("/users", get_with(self::users::list, self::users::list_doc))
|
.api_route(
|
||||||
|
"/users",
|
||||||
|
get_with(self::users::list, self::users::list_doc)
|
||||||
|
.post_with(self::users::add, self::users::add_doc),
|
||||||
|
)
|
||||||
.api_route(
|
.api_route(
|
||||||
"/users/:id",
|
"/users/:id",
|
||||||
get_with(self::users::get, self::users::get_doc),
|
get_with(self::users::get, self::users::get_doc),
|
||||||
|
179
crates/handlers/src/admin/v1/users/add.rs
Normal file
179
crates/handlers/src/admin/v1/users/add.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// Copyright 2024 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 aide::{transform::TransformOperation, NoApi, OperationIo};
|
||||||
|
use axum::{extract::State, response::IntoResponse, Json};
|
||||||
|
use hyper::StatusCode;
|
||||||
|
use mas_matrix::BoxHomeserverConnection;
|
||||||
|
use mas_storage::{
|
||||||
|
job::{JobRepositoryExt, ProvisionUserJob},
|
||||||
|
BoxRng,
|
||||||
|
};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
admin::{
|
||||||
|
call_context::CallContext,
|
||||||
|
model::User,
|
||||||
|
response::{ErrorResponse, SingleResponse},
|
||||||
|
},
|
||||||
|
impl_from_error_for_route,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn valid_username_character(c: char) -> bool {
|
||||||
|
c.is_ascii_lowercase()
|
||||||
|
|| c.is_ascii_digit()
|
||||||
|
|| c == '='
|
||||||
|
|| c == '_'
|
||||||
|
|| c == '-'
|
||||||
|
|| c == '.'
|
||||||
|
|| c == '/'
|
||||||
|
|| c == '+'
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX: this should be shared with the graphql handler
|
||||||
|
fn username_valid(username: &str) -> bool {
|
||||||
|
if username.is_empty() || username.len() > 255 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not start with an underscore
|
||||||
|
if username.starts_with('_') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only contain valid characters
|
||||||
|
if !username.chars().all(valid_username_character) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error, OperationIo)]
|
||||||
|
#[aide(output_with = "Json<ErrorResponse>")]
|
||||||
|
pub enum RouteError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Homeserver(anyhow::Error),
|
||||||
|
|
||||||
|
#[error("Username is not valid")]
|
||||||
|
UsernameNotValid,
|
||||||
|
|
||||||
|
#[error("User already exists")]
|
||||||
|
UserAlreadyExists,
|
||||||
|
|
||||||
|
#[error("Username is reserved by the homeserver")]
|
||||||
|
UsernameReserved,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||||
|
|
||||||
|
impl IntoResponse for RouteError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let error = ErrorResponse::from_error(&self);
|
||||||
|
let status = match self {
|
||||||
|
Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Self::UsernameNotValid => StatusCode::BAD_REQUEST,
|
||||||
|
Self::UserAlreadyExists | Self::UsernameReserved => StatusCode::CONFLICT,
|
||||||
|
};
|
||||||
|
(status, Json(error)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct AddUserParams {
|
||||||
|
/// The username of the user to add.
|
||||||
|
username: String,
|
||||||
|
|
||||||
|
/// Skip checking with the homeserver whether the username is available.
|
||||||
|
///
|
||||||
|
/// Use this with caution! The main reason to use this, is when a user used
|
||||||
|
/// by an application service needs to exist in MAS to craft special
|
||||||
|
/// tokens (like with admin access) for them
|
||||||
|
#[serde(default)]
|
||||||
|
skip_homeserver_check: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn doc(operation: TransformOperation) -> TransformOperation {
|
||||||
|
operation
|
||||||
|
.summary("Create a new user")
|
||||||
|
.tag("user")
|
||||||
|
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
|
||||||
|
let [sample, ..] = User::samples();
|
||||||
|
let response = SingleResponse::new_canonical(sample);
|
||||||
|
t.description("User was created").example(response)
|
||||||
|
})
|
||||||
|
.response_with::<400, RouteError, _>(|t| {
|
||||||
|
let response = ErrorResponse::from_error(&RouteError::UsernameNotValid);
|
||||||
|
t.description("Username is not valid").example(response)
|
||||||
|
})
|
||||||
|
.response_with::<409, RouteError, _>(|t| {
|
||||||
|
let response = ErrorResponse::from_error(&RouteError::UserAlreadyExists);
|
||||||
|
t.description("User already exists").example(response)
|
||||||
|
})
|
||||||
|
.response_with::<409, RouteError, _>(|t| {
|
||||||
|
let response = ErrorResponse::from_error(&RouteError::UsernameReserved);
|
||||||
|
t.description("Username is reserved by the homeserver")
|
||||||
|
.example(response)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "handler.admin.v1.users.add", skip_all, err)]
|
||||||
|
pub async fn handler(
|
||||||
|
CallContext {
|
||||||
|
mut repo, clock, ..
|
||||||
|
}: CallContext,
|
||||||
|
NoApi(mut rng): NoApi<BoxRng>,
|
||||||
|
State(homeserver): State<BoxHomeserverConnection>,
|
||||||
|
Json(params): Json<AddUserParams>,
|
||||||
|
) -> Result<Json<SingleResponse<User>>, RouteError> {
|
||||||
|
if repo.user().exists(¶ms.username).await? {
|
||||||
|
return Err(RouteError::UserAlreadyExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do some basic check on the username
|
||||||
|
if !username_valid(¶ms.username) {
|
||||||
|
return Err(RouteError::UsernameNotValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the homeserver if the username is available
|
||||||
|
let homeserver_available = homeserver
|
||||||
|
.is_localpart_available(¶ms.username)
|
||||||
|
.await
|
||||||
|
.map_err(RouteError::Homeserver)?;
|
||||||
|
|
||||||
|
if !homeserver_available {
|
||||||
|
if !params.skip_homeserver_check {
|
||||||
|
return Err(RouteError::UsernameReserved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we skipped the check, we still want to shout about it
|
||||||
|
warn!("Skipped homeserver check for username {}", params.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = repo.user().add(&mut rng, &clock, params.username).await?;
|
||||||
|
|
||||||
|
repo.job()
|
||||||
|
.schedule_job(ProvisionUserJob::new(&user))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
repo.save().await?;
|
||||||
|
|
||||||
|
Ok(Json(SingleResponse::new_canonical(User::from(user))))
|
||||||
|
}
|
@@ -12,11 +12,13 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
mod add;
|
||||||
mod by_username;
|
mod by_username;
|
||||||
mod get;
|
mod get;
|
||||||
mod list;
|
mod list;
|
||||||
|
|
||||||
pub use self::{
|
pub use self::{
|
||||||
|
add::{doc as add_doc, handler as add},
|
||||||
by_username::{doc as by_username_doc, handler as by_username},
|
by_username::{doc as by_username_doc, handler as by_username},
|
||||||
get::{doc as get_doc, handler as get},
|
get::{doc as get_doc, handler as get},
|
||||||
list::{doc as list_doc, handler as list},
|
list::{doc as list_doc, handler as list},
|
||||||
|
@@ -60,9 +60,12 @@ macro_rules! impl_from_ref {
|
|||||||
|
|
||||||
impl_from_request_parts!(mas_storage::BoxRepository);
|
impl_from_request_parts!(mas_storage::BoxRepository);
|
||||||
impl_from_request_parts!(mas_storage::BoxClock);
|
impl_from_request_parts!(mas_storage::BoxClock);
|
||||||
|
impl_from_request_parts!(mas_storage::BoxRng);
|
||||||
impl_from_request_parts!(mas_handlers::BoundActivityTracker);
|
impl_from_request_parts!(mas_handlers::BoundActivityTracker);
|
||||||
impl_from_ref!(mas_router::UrlBuilder);
|
impl_from_ref!(mas_router::UrlBuilder);
|
||||||
impl_from_ref!(mas_templates::Templates);
|
impl_from_ref!(mas_templates::Templates);
|
||||||
|
impl_from_ref!(mas_matrix::BoxHomeserverConnection);
|
||||||
|
impl_from_ref!(mas_keystore::Keystore);
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>();
|
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>();
|
||||||
|
@@ -349,7 +349,7 @@ fn username_valid(username: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should not start with an underscore
|
// Should not start with an underscore
|
||||||
if username.get(0..1) == Some("_") {
|
if username.starts_with('_') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -159,6 +159,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"summary": "Create a new user",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AddUserParams"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "User was created",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SingleResponse_for_User"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"data": {
|
||||||
|
"type": "user",
|
||||||
|
"id": "01040G2081040G2081040G2081",
|
||||||
|
"attributes": {
|
||||||
|
"username": "alice",
|
||||||
|
"created_at": "1970-01-01T00:00:00Z",
|
||||||
|
"locked_at": null,
|
||||||
|
"can_request_admin": false
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Username is not valid",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"title": "Username is not valid"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Username is reserved by the homeserver",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"title": "Username is reserved by the homeserver"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/admin/v1/users/{id}": {
|
"/api/admin/v1/users/{id}": {
|
||||||
@@ -557,15 +637,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"UlidInPath": {
|
"AddUserParams": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"id"
|
"username"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"username": {
|
||||||
"title": "The ID of the resource",
|
"description": "The username of the user to add.",
|
||||||
"$ref": "#/components/schemas/ULID"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"skip_homeserver_check": {
|
||||||
|
"description": "Skip checking with the homeserver whether the username is available.\n\nUse this with caution! The main reason to use this, is when a user used by an application service needs to exist in MAS to craft special tokens (like with admin access) for them",
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -585,6 +670,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"UlidInPath": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"title": "The ID of the resource",
|
||||||
|
"$ref": "#/components/schemas/ULID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UsernamePathParam": {
|
"UsernamePathParam": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
Reference in New Issue
Block a user