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

Commit fa35020

Browse files
committed
admin: set password API
1 parent 65f1c45 commit fa35020

File tree

6 files changed

+282
-2
lines changed

6 files changed

+282
-2
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: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,24 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
use aide::axum::{routing::get_with, ApiRouter};
15+
use aide::axum::{
16+
routing::{get_with, post_with},
17+
ApiRouter,
18+
};
1619
use axum::extract::{FromRef, FromRequestParts};
1720
use mas_matrix::BoxHomeserverConnection;
1821
use mas_storage::BoxRng;
1922

2023
use super::call_context::CallContext;
24+
use crate::passwords::PasswordManager;
2125

2226
mod users;
2327

2428
pub fn router<S>() -> ApiRouter<S>
2529
where
2630
S: Clone + Send + Sync + 'static,
2731
BoxHomeserverConnection: FromRef<S>,
32+
PasswordManager: FromRef<S>,
2833
BoxRng: FromRequestParts<S>,
2934
CallContext: FromRequestParts<S>,
3035
{
@@ -38,6 +43,10 @@ where
3843
"/users/:id",
3944
get_with(self::users::get, self::users::get_doc),
4045
)
46+
.api_route(
47+
"/users/:id/set-password",
48+
post_with(self::users::set_password, self::users::set_password_doc),
49+
)
4150
.api_route(
4251
"/users/by-username/:username",
4352
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
@@ -16,10 +16,12 @@ mod add;
1616
mod by_username;
1717
mod get;
1818
mod list;
19+
mod set_password;
1920

2021
pub use self::{
2122
add::{doc as add_doc, handler as add},
2223
by_username::{doc as by_username_doc, handler as by_username},
2324
get::{doc as get_doc, handler as get},
2425
list::{doc as list_doc, handler as list},
26+
set_password::{doc as set_password_doc, handler as set_password},
2527
};
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": [
@@ -684,6 +744,22 @@
684744
}
685745
}
686746
},
747+
"SetUserPasswordRequest": {
748+
"title": "JSON payload for the `POST /api/admin/v1/users/:id/set-password` endpoint",
749+
"type": "object",
750+
"required": [
751+
"password"
752+
],
753+
"properties": {
754+
"password": {
755+
"description": "The password to set for the user",
756+
"examples": [
757+
"hunter2"
758+
],
759+
"type": "string"
760+
}
761+
}
762+
},
687763
"UsernamePathParam": {
688764
"type": "object",
689765
"required": [

0 commit comments

Comments
 (0)