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