Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit 4dbb5eb

Browse files
committed
admin: set password API
1 parent 9ea77a9 commit 4dbb5eb

File tree

6 files changed

+278
-1
lines changed

6 files changed

+278
-1
lines changed

crates/handlers/src/admin/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,13 @@ mod schema;
4343
mod v1;
4444

4545
use self::call_context::CallContext;
46+
use crate::passwords::PasswordManager;
4647

4748
pub fn router<S>() -> (OpenApi, Router<S>)
4849
where
4950
S: Clone + Send + Sync + 'static,
5051
BoxHomeserverConnection: FromRef<S>,
52+
PasswordManager: FromRef<S>,
5153
BoxRng: FromRequestParts<S>,
5254
CallContext: FromRequestParts<S>,
5355
Templates: FromRef<S>,

crates/handlers/src/admin/v1/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ use mas_matrix::BoxHomeserverConnection;
2121
use mas_storage::BoxRng;
2222

2323
use super::call_context::CallContext;
24+
use crate::passwords::PasswordManager;
2425

2526
mod users;
2627

2728
pub fn router<S>() -> ApiRouter<S>
2829
where
2930
S: Clone + Send + Sync + 'static,
3031
BoxHomeserverConnection: FromRef<S>,
32+
PasswordManager: FromRef<S>,
3133
BoxRng: FromRequestParts<S>,
3234
CallContext: FromRequestParts<S>,
3335
{
@@ -41,6 +43,10 @@ where
4143
"/users/:id",
4244
get_with(self::users::get, self::users::get_doc),
4345
)
46+
.api_route(
47+
"/users/:id/set-password",
48+
post_with(self::users::set_password, self::users::set_password_doc),
49+
)
4450
.api_route(
4551
"/users/by-username/:username",
4652
get_with(self::users::by_username, self::users::by_username_doc),

crates/handlers/src/admin/v1/users/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mod deactivate;
1818
mod get;
1919
mod list;
2020
mod lock;
21+
mod set_password;
2122
mod unlock;
2223

2324
pub use self::{
@@ -27,5 +28,6 @@ pub use self::{
2728
get::{doc as get_doc, handler as get},
2829
list::{doc as list_doc, handler as list},
2930
lock::{doc as lock_doc, handler as lock},
31+
set_password::{doc as set_password_doc, handler as set_password},
3032
unlock::{doc as unlock_doc, handler as unlock},
3133
};
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright 2024 The Matrix.org Foundation C.I.C.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use aide::{transform::TransformOperation, NoApi, OperationIo};
16+
use axum::{extract::State, response::IntoResponse, Json};
17+
use hyper::StatusCode;
18+
use mas_storage::BoxRng;
19+
use schemars::JsonSchema;
20+
use serde::Deserialize;
21+
use ulid::Ulid;
22+
use zeroize::Zeroizing;
23+
24+
use crate::{
25+
admin::{call_context::CallContext, params::UlidPathParam, response::ErrorResponse},
26+
impl_from_error_for_route,
27+
passwords::PasswordManager,
28+
};
29+
30+
#[derive(Debug, thiserror::Error, OperationIo)]
31+
#[aide(output_with = "Json<ErrorResponse>")]
32+
pub enum RouteError {
33+
#[error(transparent)]
34+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
35+
36+
#[error("Password hashing failed")]
37+
Password(#[source] anyhow::Error),
38+
39+
#[error("User ID {0} not found")]
40+
NotFound(Ulid),
41+
}
42+
43+
impl_from_error_for_route!(mas_storage::RepositoryError);
44+
45+
impl IntoResponse for RouteError {
46+
fn into_response(self) -> axum::response::Response {
47+
let error = ErrorResponse::from_error(&self);
48+
let status = match self {
49+
Self::Internal(_) | Self::Password(_) => StatusCode::INTERNAL_SERVER_ERROR,
50+
Self::NotFound(_) => StatusCode::NOT_FOUND,
51+
};
52+
(status, Json(error)).into_response()
53+
}
54+
}
55+
56+
fn password_example() -> String {
57+
"hunter2".to_owned()
58+
}
59+
60+
/// # JSON payload for the `POST /api/admin/v1/users/:id/set-password` endpoint
61+
#[derive(Deserialize, JsonSchema)]
62+
#[schemars(rename = "SetUserPasswordRequest")]
63+
pub struct Request {
64+
/// The password to set for the user
65+
#[schemars(example = "password_example")]
66+
password: String,
67+
}
68+
69+
pub fn doc(operation: TransformOperation) -> TransformOperation {
70+
operation
71+
.id("setUserPassword")
72+
.summary("Set the password for a user")
73+
.tag("user")
74+
.response_with::<200, StatusCode, _>(|t| t.description("Password was set"))
75+
.response_with::<404, RouteError, _>(|t| {
76+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
77+
t.description("User was not found").example(response)
78+
})
79+
}
80+
81+
#[tracing::instrument(name = "handler.admin.v1.users.set_password", skip_all, err)]
82+
pub async fn handler(
83+
CallContext {
84+
mut repo, clock, ..
85+
}: CallContext,
86+
NoApi(mut rng): NoApi<BoxRng>,
87+
State(password_manager): State<PasswordManager>,
88+
id: UlidPathParam,
89+
Json(params): Json<Request>,
90+
) -> Result<StatusCode, RouteError> {
91+
let user = repo
92+
.user()
93+
.lookup(*id)
94+
.await?
95+
.ok_or(RouteError::NotFound(*id))?;
96+
97+
let password = Zeroizing::new(params.password.into_bytes());
98+
let (version, hashed_password) = password_manager
99+
.hash(&mut rng, password)
100+
.await
101+
.map_err(RouteError::Password)?;
102+
103+
repo.user_password()
104+
.add(&mut rng, &clock, &user, version, hashed_password, None)
105+
.await?;
106+
107+
repo.save().await?;
108+
109+
Ok(StatusCode::NO_CONTENT)
110+
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use hyper::{Request, StatusCode};
115+
use mas_storage::{user::UserPasswordRepository, RepositoryAccess};
116+
use sqlx::PgPool;
117+
use zeroize::Zeroizing;
118+
119+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
120+
121+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
122+
async fn test_set_password(pool: PgPool) {
123+
setup();
124+
let mut state = TestState::from_pool(pool).await.unwrap();
125+
let token = state.token_with_scope("urn:mas:admin").await;
126+
127+
// Create a user
128+
let mut repo = state.repository().await.unwrap();
129+
let user = repo
130+
.user()
131+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
132+
.await
133+
.unwrap();
134+
135+
// Double-check that the user doesn't have a password
136+
let user_password = repo.user_password().active(&user).await.unwrap();
137+
assert!(user_password.is_none());
138+
139+
repo.save().await.unwrap();
140+
141+
let user_id = user.id;
142+
143+
// Set the password through the API
144+
let request = Request::post(format!("/api/admin/v1/users/{user_id}/set-password"))
145+
.bearer(&token)
146+
.json(serde_json::json!({
147+
"password": "hunter2",
148+
}));
149+
150+
let response = state.request(request).await;
151+
response.assert_status(StatusCode::NO_CONTENT);
152+
153+
// Check that the user now has a password
154+
let mut repo = state.repository().await.unwrap();
155+
let user_password = repo.user_password().active(&user).await.unwrap().unwrap();
156+
let password = Zeroizing::new(b"hunter2".to_vec());
157+
state
158+
.password_manager
159+
.verify(
160+
user_password.version,
161+
password,
162+
user_password.hashed_password,
163+
)
164+
.await
165+
.unwrap();
166+
}
167+
168+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
169+
async fn test_unknown_user(pool: PgPool) {
170+
setup();
171+
let mut state = TestState::from_pool(pool).await.unwrap();
172+
let token = state.token_with_scope("urn:mas:admin").await;
173+
174+
// Set the password through the API
175+
let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/set-password")
176+
.bearer(&token)
177+
.json(serde_json::json!({
178+
"password": "hunter2",
179+
}));
180+
181+
let response = state.request(request).await;
182+
response.assert_status(StatusCode::NOT_FOUND);
183+
184+
let body: serde_json::Value = response.json();
185+
assert_eq!(
186+
body["errors"][0]["title"],
187+
"User ID 01040G2081040G2081040G2081 not found"
188+
);
189+
}
190+
}

crates/handlers/src/bin/api-schema.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2022 The Matrix.org Foundation C.I.C.
1+
// Copyright 2024 The Matrix.org Foundation C.I.C.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -66,6 +66,7 @@ impl_from_ref!(mas_router::UrlBuilder);
6666
impl_from_ref!(mas_templates::Templates);
6767
impl_from_ref!(mas_matrix::BoxHomeserverConnection);
6868
impl_from_ref!(mas_keystore::Keystore);
69+
impl_from_ref!(mas_handlers::passwords::PasswordManager);
6970

7071
fn main() -> Result<(), Box<dyn std::error::Error>> {
7172
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>();

docs/api/spec.json

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,66 @@
310310
}
311311
}
312312
},
313+
"/api/admin/v1/users/{id}/set-password": {
314+
"post": {
315+
"tags": [
316+
"user"
317+
],
318+
"summary": "Set the password for a user",
319+
"operationId": "setUserPassword",
320+
"parameters": [
321+
{
322+
"in": "path",
323+
"name": "id",
324+
"required": true,
325+
"schema": {
326+
"title": "The ID of the resource",
327+
"$ref": "#/components/schemas/ULID"
328+
},
329+
"style": "simple"
330+
}
331+
],
332+
"requestBody": {
333+
"content": {
334+
"application/json": {
335+
"schema": {
336+
"$ref": "#/components/schemas/SetUserPasswordRequest"
337+
}
338+
}
339+
},
340+
"required": true
341+
},
342+
"responses": {
343+
"200": {
344+
"description": "",
345+
"content": {
346+
"application/json": {
347+
"schema": {
348+
"$ref": "#/components/schemas/ErrorResponse"
349+
}
350+
}
351+
}
352+
},
353+
"404": {
354+
"description": "User was not found",
355+
"content": {
356+
"application/json": {
357+
"schema": {
358+
"$ref": "#/components/schemas/ErrorResponse"
359+
},
360+
"example": {
361+
"errors": [
362+
{
363+
"title": "User ID 00000000000000000000000000 not found"
364+
}
365+
]
366+
}
367+
}
368+
}
369+
}
370+
}
371+
}
372+
},
313373
"/api/admin/v1/users/by-username/{username}": {
314374
"get": {
315375
"tags": [
@@ -890,6 +950,22 @@
890950
}
891951
}
892952
},
953+
"SetUserPasswordRequest": {
954+
"title": "JSON payload for the `POST /api/admin/v1/users/:id/set-password` endpoint",
955+
"type": "object",
956+
"required": [
957+
"password"
958+
],
959+
"properties": {
960+
"password": {
961+
"description": "The password to set for the user",
962+
"examples": [
963+
"hunter2"
964+
],
965+
"type": "string"
966+
}
967+
}
968+
},
893969
"UsernamePathParam": {
894970
"type": "object",
895971
"required": [

0 commit comments

Comments
 (0)