mas_handlers/admin/v1/users/
set_password.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use aide::{NoApi, OperationIo, transform::TransformOperation};
8use axum::{Json, extract::State, response::IntoResponse};
9use hyper::StatusCode;
10use mas_storage::BoxRng;
11use schemars::JsonSchema;
12use serde::Deserialize;
13use ulid::Ulid;
14use zeroize::Zeroizing;
15
16use crate::{
17    admin::{call_context::CallContext, params::UlidPathParam, response::ErrorResponse},
18    impl_from_error_for_route,
19    passwords::PasswordManager,
20};
21
22#[derive(Debug, thiserror::Error, OperationIo)]
23#[aide(output_with = "Json<ErrorResponse>")]
24pub enum RouteError {
25    #[error(transparent)]
26    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
27
28    #[error("Password is too weak")]
29    PasswordTooWeak,
30
31    #[error("Password auth is disabled")]
32    PasswordAuthDisabled,
33
34    #[error("Password hashing failed")]
35    Password(#[source] anyhow::Error),
36
37    #[error("User ID {0} not found")]
38    NotFound(Ulid),
39}
40
41impl_from_error_for_route!(mas_storage::RepositoryError);
42
43impl IntoResponse for RouteError {
44    fn into_response(self) -> axum::response::Response {
45        let error = ErrorResponse::from_error(&self);
46        let status = match self {
47            Self::Internal(_) | Self::Password(_) => StatusCode::INTERNAL_SERVER_ERROR,
48            Self::PasswordAuthDisabled => StatusCode::FORBIDDEN,
49            Self::PasswordTooWeak => StatusCode::BAD_REQUEST,
50            Self::NotFound(_) => StatusCode::NOT_FOUND,
51        };
52        (status, Json(error)).into_response()
53    }
54}
55
56fn 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")]
63pub struct Request {
64    /// The password to set for the user
65    #[schemars(example = "password_example")]
66    password: String,
67
68    /// Skip the password complexity check
69    skip_password_check: Option<bool>,
70}
71
72pub fn doc(operation: TransformOperation) -> TransformOperation {
73    operation
74        .id("setUserPassword")
75        .summary("Set the password for a user")
76        .tag("user")
77        .response_with::<204, (), _>(|t| t.description("Password was set"))
78        .response_with::<400, RouteError, _>(|t| {
79            let response = ErrorResponse::from_error(&RouteError::PasswordTooWeak);
80            t.description("Password is too weak").example(response)
81        })
82        .response_with::<403, RouteError, _>(|t| {
83            let response = ErrorResponse::from_error(&RouteError::PasswordAuthDisabled);
84            t.description("Password auth is disabled in the server configuration")
85                .example(response)
86        })
87        .response_with::<404, RouteError, _>(|t| {
88            let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
89            t.description("User was not found").example(response)
90        })
91}
92
93#[tracing::instrument(name = "handler.admin.v1.users.set_password", skip_all, err)]
94pub async fn handler(
95    CallContext {
96        mut repo, clock, ..
97    }: CallContext,
98    NoApi(mut rng): NoApi<BoxRng>,
99    State(password_manager): State<PasswordManager>,
100    id: UlidPathParam,
101    Json(params): Json<Request>,
102) -> Result<StatusCode, RouteError> {
103    if !password_manager.is_enabled() {
104        return Err(RouteError::PasswordAuthDisabled);
105    }
106
107    let user = repo
108        .user()
109        .lookup(*id)
110        .await?
111        .ok_or(RouteError::NotFound(*id))?;
112
113    let skip_password_check = params.skip_password_check.unwrap_or(false);
114    tracing::info!(skip_password_check, "skip_password_check");
115    if !skip_password_check
116        && !password_manager
117            .is_password_complex_enough(&params.password)
118            .unwrap_or(false)
119    {
120        return Err(RouteError::PasswordTooWeak);
121    }
122
123    let password = Zeroizing::new(params.password.into_bytes());
124    let (version, hashed_password) = password_manager
125        .hash(&mut rng, password)
126        .await
127        .map_err(RouteError::Password)?;
128
129    repo.user_password()
130        .add(&mut rng, &clock, &user, version, hashed_password, None)
131        .await?;
132
133    repo.save().await?;
134
135    Ok(StatusCode::NO_CONTENT)
136}
137
138#[cfg(test)]
139mod tests {
140    use hyper::{Request, StatusCode};
141    use mas_storage::{RepositoryAccess, user::UserPasswordRepository};
142    use sqlx::PgPool;
143    use zeroize::Zeroizing;
144
145    use crate::{
146        passwords::PasswordManager,
147        test_utils::{RequestBuilderExt, ResponseExt, TestState, setup},
148    };
149
150    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
151    async fn test_set_password(pool: PgPool) {
152        setup();
153        let mut state = TestState::from_pool(pool).await.unwrap();
154        let token = state.token_with_scope("urn:mas:admin").await;
155
156        // Create a user
157        let mut repo = state.repository().await.unwrap();
158        let user = repo
159            .user()
160            .add(&mut state.rng(), &state.clock, "alice".to_owned())
161            .await
162            .unwrap();
163
164        // Double-check that the user doesn't have a password
165        let user_password = repo.user_password().active(&user).await.unwrap();
166        assert!(user_password.is_none());
167
168        repo.save().await.unwrap();
169
170        let user_id = user.id;
171
172        // Set the password through the API
173        let request = Request::post(format!("/api/admin/v1/users/{user_id}/set-password"))
174            .bearer(&token)
175            .json(serde_json::json!({
176                "password": "this is a good enough password",
177            }));
178
179        let response = state.request(request).await;
180        response.assert_status(StatusCode::NO_CONTENT);
181
182        // Check that the user now has a password
183        let mut repo = state.repository().await.unwrap();
184        let user_password = repo.user_password().active(&user).await.unwrap().unwrap();
185        let password = Zeroizing::new(b"this is a good enough password".to_vec());
186        state
187            .password_manager
188            .verify(
189                user_password.version,
190                password,
191                user_password.hashed_password,
192            )
193            .await
194            .unwrap();
195    }
196
197    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
198    async fn test_weak_password(pool: PgPool) {
199        setup();
200        let mut state = TestState::from_pool(pool).await.unwrap();
201        let token = state.token_with_scope("urn:mas:admin").await;
202
203        // Create a user
204        let mut repo = state.repository().await.unwrap();
205        let user = repo
206            .user()
207            .add(&mut state.rng(), &state.clock, "alice".to_owned())
208            .await
209            .unwrap();
210        repo.save().await.unwrap();
211
212        let user_id = user.id;
213
214        // Set a weak password through the API
215        let request = Request::post(format!("/api/admin/v1/users/{user_id}/set-password"))
216            .bearer(&token)
217            .json(serde_json::json!({
218                "password": "password",
219            }));
220
221        let response = state.request(request).await;
222        response.assert_status(StatusCode::BAD_REQUEST);
223
224        // Check that the user still has a password
225        let mut repo = state.repository().await.unwrap();
226        let user_password = repo.user_password().active(&user).await.unwrap();
227        assert!(user_password.is_none());
228        repo.save().await.unwrap();
229
230        // Now try with the skip_password_check flag
231        let request = Request::post(format!("/api/admin/v1/users/{user_id}/set-password"))
232            .bearer(&token)
233            .json(serde_json::json!({
234                "password": "password",
235                "skip_password_check": true,
236            }));
237
238        let response = state.request(request).await;
239        response.assert_status(StatusCode::NO_CONTENT);
240
241        // Check that the user now has a password
242        let mut repo = state.repository().await.unwrap();
243        let user_password = repo.user_password().active(&user).await.unwrap().unwrap();
244        let password = Zeroizing::new(b"password".to_vec());
245        state
246            .password_manager
247            .verify(
248                user_password.version,
249                password,
250                user_password.hashed_password,
251            )
252            .await
253            .unwrap();
254    }
255
256    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
257    async fn test_unknown_user(pool: PgPool) {
258        setup();
259        let mut state = TestState::from_pool(pool).await.unwrap();
260        let token = state.token_with_scope("urn:mas:admin").await;
261
262        // Set the password through the API
263        let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/set-password")
264            .bearer(&token)
265            .json(serde_json::json!({
266                "password": "this is a good enough password",
267            }));
268
269        let response = state.request(request).await;
270        response.assert_status(StatusCode::NOT_FOUND);
271
272        let body: serde_json::Value = response.json();
273        assert_eq!(
274            body["errors"][0]["title"],
275            "User ID 01040G2081040G2081040G2081 not found"
276        );
277    }
278
279    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
280    async fn test_disabled(pool: PgPool) {
281        setup();
282        let mut state = TestState::from_pool(pool).await.unwrap();
283        state.password_manager = PasswordManager::disabled();
284        let token = state.token_with_scope("urn:mas:admin").await;
285
286        let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/set-password")
287            .bearer(&token)
288            .json(serde_json::json!({
289                "password": "hunter2",
290            }));
291
292        let response = state.request(request).await;
293        response.assert_status(StatusCode::FORBIDDEN);
294
295        let body: serde_json::Value = response.json();
296        assert_eq!(body["errors"][0]["title"], "Password auth is disabled");
297    }
298}