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_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#[derive(Deserialize, JsonSchema)]
62#[schemars(rename = "SetUserPasswordRequest")]
63pub struct Request {
64 #[schemars(example = "password_example")]
66 password: String,
67
68 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(¶ms.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 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 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 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 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 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 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 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 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 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 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}