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

Commit 4d6732b

Browse files
committed
admin: set password API
1 parent c0c88a6 commit 4d6732b

File tree

6 files changed

+283
-2
lines changed

6 files changed

+283
-2
lines changed

crates/handlers/src/admin/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@ mod response;
3535
mod v1;
3636

3737
use self::call_context::CallContext;
38+
use crate::passwords::PasswordManager;
3839

3940
pub fn router<S>() -> (OpenApi, Router<S>)
4041
where
4142
S: Clone + Send + Sync + 'static,
4243
BoxHomeserverConnection: FromRef<S>,
44+
PasswordManager: FromRef<S>,
4345
BoxRng: FromRequestParts<S>,
4446
CallContext: FromRequestParts<S>,
4547
{

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: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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 = "SetUserPasswordPayload")]
63+
pub struct Payload {
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+
.summary("Set the password for a user")
72+
.tag("user")
73+
.response_with::<200, StatusCode, _>(|t| t.description("Password was set"))
74+
.response_with::<404, RouteError, _>(|t| {
75+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
76+
t.description("User was not found").example(response)
77+
})
78+
}
79+
80+
#[tracing::instrument(name = "handler.admin.v1.users.set_password", skip_all, err)]
81+
pub async fn handler(
82+
CallContext {
83+
mut repo, clock, ..
84+
}: CallContext,
85+
NoApi(mut rng): NoApi<BoxRng>,
86+
State(password_manager): State<PasswordManager>,
87+
id: UlidPathParam,
88+
Json(params): Json<Payload>,
89+
) -> Result<StatusCode, RouteError> {
90+
let user = repo
91+
.user()
92+
.lookup(*id)
93+
.await?
94+
.ok_or(RouteError::NotFound(*id))?;
95+
96+
let password = Zeroizing::new(params.password.into_bytes());
97+
let (version, hashed_password) = password_manager
98+
.hash(&mut rng, password)
99+
.await
100+
.map_err(RouteError::Password)?;
101+
102+
repo.user_password()
103+
.add(&mut rng, &clock, &user, version, hashed_password, None)
104+
.await?;
105+
106+
repo.save().await?;
107+
108+
Ok(StatusCode::NO_CONTENT)
109+
}
110+
111+
#[cfg(test)]
112+
mod tests {
113+
use hyper::{Request, StatusCode};
114+
use mas_storage::{user::UserPasswordRepository, RepositoryAccess};
115+
use sqlx::PgPool;
116+
use zeroize::Zeroizing;
117+
118+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
119+
120+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
121+
async fn test_set_password(pool: PgPool) {
122+
setup();
123+
let mut state = TestState::from_pool(pool).await.unwrap();
124+
let token = state.token_with_scope("urn:mas:admin").await;
125+
126+
// Create a user
127+
let mut repo = state.repository().await.unwrap();
128+
let user = repo
129+
.user()
130+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
131+
.await
132+
.unwrap();
133+
134+
// Double-check that the user doesn't have a password
135+
let user_password = repo.user_password().active(&user).await.unwrap();
136+
assert!(user_password.is_none());
137+
138+
repo.save().await.unwrap();
139+
140+
let user_id = user.id;
141+
142+
// Set the password through the API
143+
let request = Request::post(format!("/api/admin/v1/users/{user_id}/set-password"))
144+
.bearer(&token)
145+
.json(serde_json::json!({
146+
"password": "hunter2",
147+
}));
148+
149+
let response = state.request(request).await;
150+
response.assert_status(StatusCode::NO_CONTENT);
151+
152+
// Check that the user now has a password
153+
let mut repo = state.repository().await.unwrap();
154+
let user_password = repo.user_password().active(&user).await.unwrap().unwrap();
155+
let password = Zeroizing::new(b"hunter2".to_vec());
156+
state
157+
.password_manager
158+
.verify(
159+
user_password.version,
160+
password,
161+
user_password.hashed_password,
162+
)
163+
.await
164+
.unwrap();
165+
}
166+
167+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
168+
async fn test_unknown_user(pool: PgPool) {
169+
setup();
170+
let mut state = TestState::from_pool(pool).await.unwrap();
171+
let token = state.token_with_scope("urn:mas:admin").await;
172+
173+
// Set the password through the API
174+
let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/set-password")
175+
.bearer(&token)
176+
.json(serde_json::json!({
177+
"password": "hunter2",
178+
}));
179+
180+
let response = state.request(request).await;
181+
response.assert_status(StatusCode::NOT_FOUND);
182+
183+
let body: serde_json::Value = response.json();
184+
assert_eq!(
185+
body["errors"][0]["title"],
186+
"User ID 01040G2081040G2081040G2081 not found"
187+
);
188+
}
189+
}

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.
@@ -61,6 +61,7 @@ impl_from_request_parts!(mas_storage::BoxRng);
6161
impl_from_request_parts!(mas_handlers::BoundActivityTracker);
6262
impl_from_ref!(mas_matrix::BoxHomeserverConnection);
6363
impl_from_ref!(mas_keystore::Keystore);
64+
impl_from_ref!(mas_handlers::passwords::PasswordManager);
6465

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

docs/api.schema.json

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,68 @@
316316
}
317317
}
318318
},
319+
"/api/admin/v1/users/{id}/set-password": {
320+
"post": {
321+
"tags": [
322+
"user"
323+
],
324+
"summary": "Set the password for a user",
325+
"parameters": [
326+
{
327+
"in": "path",
328+
"name": "id",
329+
"description": "A ULID as per https://github.com/ulid/spec",
330+
"required": true,
331+
"schema": {
332+
"title": "ULID",
333+
"description": "A ULID as per https://github.com/ulid/spec",
334+
"type": "string",
335+
"pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"
336+
},
337+
"style": "simple"
338+
}
339+
],
340+
"requestBody": {
341+
"content": {
342+
"application/json": {
343+
"schema": {
344+
"$ref": "#/components/schemas/SetUserPasswordPayload"
345+
}
346+
}
347+
},
348+
"required": true
349+
},
350+
"responses": {
351+
"200": {
352+
"description": "",
353+
"content": {
354+
"application/json": {
355+
"schema": {
356+
"$ref": "#/components/schemas/ErrorResponse"
357+
}
358+
}
359+
}
360+
},
361+
"404": {
362+
"description": "User was not found",
363+
"content": {
364+
"application/json": {
365+
"schema": {
366+
"$ref": "#/components/schemas/ErrorResponse"
367+
},
368+
"example": {
369+
"errors": [
370+
{
371+
"title": "User ID 00000000000000000000000000 not found"
372+
}
373+
]
374+
}
375+
}
376+
}
377+
}
378+
}
379+
}
380+
},
319381
"/api/admin/v1/users/by-username/{username}": {
320382
"get": {
321383
"description": "Get a user by its username (localpart)",
@@ -709,6 +771,22 @@
709771
}
710772
}
711773
},
774+
"SetUserPasswordPayload": {
775+
"title": "JSON payload for the `POST /api/admin/v1/users/:id/set-password` endpoint",
776+
"type": "object",
777+
"required": [
778+
"password"
779+
],
780+
"properties": {
781+
"password": {
782+
"description": "The password to set for the user",
783+
"examples": [
784+
"hunter2"
785+
],
786+
"type": "string"
787+
}
788+
}
789+
},
712790
"UsernamePathParam": {
713791
"type": "object",
714792
"required": [

0 commit comments

Comments
 (0)