mas_handlers/admin/v1/users/
set_password.rs1use 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#[derive(Deserialize, JsonSchema)]
64#[schemars(rename = "SetUserPasswordRequest")]
65pub struct Request {
66 #[schemars(example = "password_example")]
68 password: String,
69
70 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(¶ms.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 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 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 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 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 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 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 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 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 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 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}