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

Commit b2dbcb0

Browse files
committed
admin: set password API
1 parent 82ce8a7 commit b2dbcb0

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
@@ -42,11 +42,13 @@ mod response;
4242
mod v1;
4343

4444
use self::call_context::CallContext;
45+
use crate::passwords::PasswordManager;
4546

4647
pub fn router<S>() -> (OpenApi, Router<S>)
4748
where
4849
S: Clone + Send + Sync + 'static,
4950
BoxHomeserverConnection: FromRef<S>,
51+
PasswordManager: FromRef<S>,
5052
BoxRng: FromRequestParts<S>,
5153
CallContext: FromRequestParts<S>,
5254
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: 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.
@@ -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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,68 @@
322322
}
323323
}
324324
},
325+
"/api/admin/v1/users/{id}/set-password": {
326+
"post": {
327+
"tags": [
328+
"user"
329+
],
330+
"summary": "Set the password for a user",
331+
"parameters": [
332+
{
333+
"in": "path",
334+
"name": "id",
335+
"description": "A ULID as per https://github.com/ulid/spec",
336+
"required": true,
337+
"schema": {
338+
"title": "ULID",
339+
"description": "A ULID as per https://github.com/ulid/spec",
340+
"type": "string",
341+
"pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"
342+
},
343+
"style": "simple"
344+
}
345+
],
346+
"requestBody": {
347+
"content": {
348+
"application/json": {
349+
"schema": {
350+
"$ref": "#/components/schemas/SetUserPasswordPayload"
351+
}
352+
}
353+
},
354+
"required": true
355+
},
356+
"responses": {
357+
"200": {
358+
"description": "",
359+
"content": {
360+
"application/json": {
361+
"schema": {
362+
"$ref": "#/components/schemas/ErrorResponse"
363+
}
364+
}
365+
}
366+
},
367+
"404": {
368+
"description": "User was not found",
369+
"content": {
370+
"application/json": {
371+
"schema": {
372+
"$ref": "#/components/schemas/ErrorResponse"
373+
},
374+
"example": {
375+
"errors": [
376+
{
377+
"title": "User ID 00000000000000000000000000 not found"
378+
}
379+
]
380+
}
381+
}
382+
}
383+
}
384+
}
385+
}
386+
},
325387
"/api/admin/v1/users/by-username/{username}": {
326388
"get": {
327389
"tags": [
@@ -718,6 +780,22 @@
718780
}
719781
}
720782
},
783+
"SetUserPasswordPayload": {
784+
"title": "JSON payload for the `POST /api/admin/v1/users/:id/set-password` endpoint",
785+
"type": "object",
786+
"required": [
787+
"password"
788+
],
789+
"properties": {
790+
"password": {
791+
"description": "The password to set for the user",
792+
"examples": [
793+
"hunter2"
794+
],
795+
"type": "string"
796+
}
797+
}
798+
},
721799
"UsernamePathParam": {
722800
"type": "object",
723801
"required": [

0 commit comments

Comments
 (0)