this repo has no description
1use crate::api::error::ApiError;
2use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse};
3use crate::auth::BearerAuth;
4use crate::state::{AppState, RateLimitKind};
5use crate::types::PlainPassword;
6use crate::validation::validate_password;
7use axum::{
8 Json,
9 extract::State,
10 http::HeaderMap,
11 response::{IntoResponse, Response},
12};
13use bcrypt::{DEFAULT_COST, hash, verify};
14use chrono::{Duration, Utc};
15use serde::Deserialize;
16use tracing::{error, info, warn};
17use uuid::Uuid;
18
19fn generate_reset_code() -> String {
20 crate::util::generate_token_code()
21}
22fn extract_client_ip(headers: &HeaderMap) -> String {
23 if let Some(forwarded) = headers.get("x-forwarded-for")
24 && let Ok(value) = forwarded.to_str()
25 && let Some(first_ip) = value.split(',').next()
26 {
27 return first_ip.trim().to_string();
28 }
29 if let Some(real_ip) = headers.get("x-real-ip")
30 && let Ok(value) = real_ip.to_str()
31 {
32 return value.trim().to_string();
33 }
34 "unknown".to_string()
35}
36
37#[derive(Deserialize)]
38pub struct RequestPasswordResetInput {
39 #[serde(alias = "identifier")]
40 pub email: String,
41}
42
43pub async fn request_password_reset(
44 State(state): State<AppState>,
45 headers: HeaderMap,
46 Json(input): Json<RequestPasswordResetInput>,
47) -> Response {
48 let client_ip = extract_client_ip(&headers);
49 if !state
50 .check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
51 .await
52 {
53 warn!(ip = %client_ip, "Password reset rate limit exceeded");
54 return ApiError::RateLimitExceeded(None).into_response();
55 }
56 let identifier = input.email.trim();
57 if identifier.is_empty() {
58 return ApiError::InvalidRequest("email or handle is required".into()).into_response();
59 }
60 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
61 let normalized = identifier.to_lowercase();
62 let normalized = normalized.strip_prefix('@').unwrap_or(&normalized);
63 let normalized_handle = if normalized.contains('@') || normalized.contains('.') {
64 normalized.to_string()
65 } else {
66 format!("{}.{}", normalized, pds_hostname)
67 };
68 let user = sqlx::query!(
69 "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2",
70 normalized,
71 normalized_handle
72 )
73 .fetch_optional(&state.db)
74 .await;
75 let user_id = match user {
76 Ok(Some(row)) => row.id,
77 Ok(None) => {
78 info!("Password reset requested for unknown identifier");
79 return EmptyResponse::ok().into_response();
80 }
81 Err(e) => {
82 error!("DB error in request_password_reset: {:?}", e);
83 return ApiError::InternalError(None).into_response();
84 }
85 };
86 let code = generate_reset_code();
87 let expires_at = Utc::now() + Duration::minutes(10);
88 let update = sqlx::query!(
89 "UPDATE users SET password_reset_code = $1, password_reset_code_expires_at = $2 WHERE id = $3",
90 code,
91 expires_at,
92 user_id
93 )
94 .execute(&state.db)
95 .await;
96 if let Err(e) = update {
97 error!("DB error setting reset code: {:?}", e);
98 return ApiError::InternalError(None).into_response();
99 }
100 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
101 if let Err(e) = crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
102 {
103 warn!("Failed to enqueue password reset notification: {:?}", e);
104 }
105 info!("Password reset requested for user {}", user_id);
106 EmptyResponse::ok().into_response()
107}
108
109#[derive(Deserialize)]
110pub struct ResetPasswordInput {
111 pub token: String,
112 pub password: PlainPassword,
113}
114
115pub async fn reset_password(
116 State(state): State<AppState>,
117 headers: HeaderMap,
118 Json(input): Json<ResetPasswordInput>,
119) -> Response {
120 let client_ip = extract_client_ip(&headers);
121 if !state
122 .check_rate_limit(RateLimitKind::ResetPassword, &client_ip)
123 .await
124 {
125 warn!(ip = %client_ip, "Reset password rate limit exceeded");
126 return ApiError::RateLimitExceeded(None).into_response();
127 }
128 let token = input.token.trim();
129 let password = &input.password;
130 if token.is_empty() {
131 return ApiError::InvalidToken(None).into_response();
132 }
133 if password.is_empty() {
134 return ApiError::InvalidRequest("password is required".into()).into_response();
135 }
136 if let Err(e) = validate_password(password) {
137 return ApiError::InvalidRequest(e.to_string()).into_response();
138 }
139 let user = sqlx::query!(
140 "SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
141 token
142 )
143 .fetch_optional(&state.db)
144 .await;
145 let (user_id, expires_at) = match user {
146 Ok(Some(row)) => {
147 let expires = row.password_reset_code_expires_at;
148 (row.id, expires)
149 }
150 Ok(None) => {
151 return ApiError::InvalidToken(None).into_response();
152 }
153 Err(e) => {
154 error!("DB error in reset_password: {:?}", e);
155 return ApiError::InternalError(None).into_response();
156 }
157 };
158 if let Some(exp) = expires_at {
159 if Utc::now() > exp {
160 if let Err(e) = sqlx::query!(
161 "UPDATE users SET password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $1",
162 user_id
163 )
164 .execute(&state.db)
165 .await
166 {
167 error!("Failed to clear expired reset code: {:?}", e);
168 }
169 return ApiError::ExpiredToken(None).into_response();
170 }
171 } else {
172 return ApiError::InvalidToken(None).into_response();
173 }
174 let password_clone = password.to_string();
175 let password_hash =
176 match tokio::task::spawn_blocking(move || hash(password_clone, DEFAULT_COST)).await {
177 Ok(Ok(h)) => h,
178 Ok(Err(e)) => {
179 error!("Failed to hash password: {:?}", e);
180 return ApiError::InternalError(None).into_response();
181 }
182 Err(e) => {
183 error!("Failed to spawn blocking task: {:?}", e);
184 return ApiError::InternalError(None).into_response();
185 }
186 };
187 let mut tx = match state.db.begin().await {
188 Ok(tx) => tx,
189 Err(e) => {
190 error!("Failed to begin transaction: {:?}", e);
191 return ApiError::InternalError(None).into_response();
192 }
193 };
194 if let Err(e) = sqlx::query!(
195 "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2",
196 password_hash,
197 user_id
198 )
199 .execute(&mut *tx)
200 .await
201 {
202 error!("DB error updating password: {:?}", e);
203 return ApiError::InternalError(None).into_response();
204 }
205 let user_did = match sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", user_id)
206 .fetch_one(&mut *tx)
207 .await
208 {
209 Ok(did) => did,
210 Err(e) => {
211 error!("Failed to get DID for user {}: {:?}", user_id, e);
212 return ApiError::InternalError(None).into_response();
213 }
214 };
215 let session_jtis: Vec<String> = match sqlx::query_scalar!(
216 "SELECT access_jti FROM session_tokens WHERE did = $1",
217 user_did
218 )
219 .fetch_all(&mut *tx)
220 .await
221 {
222 Ok(jtis) => jtis,
223 Err(e) => {
224 error!("Failed to fetch session JTIs: {:?}", e);
225 vec![]
226 }
227 };
228 if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", user_did)
229 .execute(&mut *tx)
230 .await
231 {
232 error!(
233 "Failed to invalidate sessions after password reset: {:?}",
234 e
235 );
236 return ApiError::InternalError(None).into_response();
237 }
238 if let Err(e) = tx.commit().await {
239 error!("Failed to commit password reset transaction: {:?}", e);
240 return ApiError::InternalError(None).into_response();
241 }
242 futures::future::join_all(session_jtis.into_iter().map(|jti| {
243 let cache_key = format!("auth:session:{}:{}", user_did, jti);
244 let cache = state.cache.clone();
245 async move {
246 if let Err(e) = cache.delete(&cache_key).await {
247 warn!(
248 "Failed to invalidate session cache for {}: {:?}",
249 cache_key, e
250 );
251 }
252 }
253 }))
254 .await;
255 info!("Password reset completed for user {}", user_id);
256 EmptyResponse::ok().into_response()
257}
258
259#[derive(Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct ChangePasswordInput {
262 pub current_password: PlainPassword,
263 pub new_password: PlainPassword,
264}
265
266pub async fn change_password(
267 State(state): State<AppState>,
268 auth: BearerAuth,
269 Json(input): Json<ChangePasswordInput>,
270) -> Response {
271 if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
272 return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
273 .await;
274 }
275
276 let current_password = &input.current_password;
277 let new_password = &input.new_password;
278 if current_password.is_empty() {
279 return ApiError::InvalidRequest("currentPassword is required".into()).into_response();
280 }
281 if new_password.is_empty() {
282 return ApiError::InvalidRequest("newPassword is required".into()).into_response();
283 }
284 if let Err(e) = validate_password(new_password) {
285 return ApiError::InvalidRequest(e.to_string()).into_response();
286 }
287 let user =
288 sqlx::query_as::<_, (Uuid, String)>("SELECT id, password_hash FROM users WHERE did = $1")
289 .bind(&auth.0.did)
290 .fetch_optional(&state.db)
291 .await;
292 let (user_id, password_hash) = match user {
293 Ok(Some(row)) => row,
294 Ok(None) => {
295 return ApiError::AccountNotFound.into_response();
296 }
297 Err(e) => {
298 error!("DB error in change_password: {:?}", e);
299 return ApiError::InternalError(None).into_response();
300 }
301 };
302 let valid = match verify(current_password, &password_hash) {
303 Ok(v) => v,
304 Err(e) => {
305 error!("Password verification error: {:?}", e);
306 return ApiError::InternalError(None).into_response();
307 }
308 };
309 if !valid {
310 return ApiError::InvalidPassword("Current password is incorrect".into()).into_response();
311 }
312 let new_password_clone = new_password.to_string();
313 let new_hash =
314 match tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST)).await {
315 Ok(Ok(h)) => h,
316 Ok(Err(e)) => {
317 error!("Failed to hash password: {:?}", e);
318 return ApiError::InternalError(None).into_response();
319 }
320 Err(e) => {
321 error!("Failed to spawn blocking task: {:?}", e);
322 return ApiError::InternalError(None).into_response();
323 }
324 };
325 if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
326 .bind(&new_hash)
327 .bind(user_id)
328 .execute(&state.db)
329 .await
330 {
331 error!("DB error updating password: {:?}", e);
332 return ApiError::InternalError(None).into_response();
333 }
334 info!(did = %&auth.0.did, "Password changed successfully");
335 EmptyResponse::ok().into_response()
336}
337
338pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
339 let user = sqlx::query!(
340 "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1",
341 &auth.0.did
342 )
343 .fetch_optional(&state.db)
344 .await;
345
346 match user {
347 Ok(Some(row)) => {
348 HasPasswordResponse::response(row.has_password.unwrap_or(false)).into_response()
349 }
350 Ok(None) => ApiError::AccountNotFound.into_response(),
351 Err(e) => {
352 error!("DB error: {:?}", e);
353 ApiError::InternalError(None).into_response()
354 }
355 }
356}
357
358pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response {
359 if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
360 return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
361 .await;
362 }
363
364 if crate::api::server::reauth::check_reauth_required_cached(
365 &state.db,
366 &state.cache,
367 &auth.0.did,
368 )
369 .await
370 {
371 return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
372 }
373
374 let has_passkeys =
375 crate::api::server::passkeys::has_passkeys_for_user_db(&state.db, &auth.0.did).await;
376 if !has_passkeys {
377 return ApiError::InvalidRequest(
378 "You must have at least one passkey registered before removing your password".into(),
379 )
380 .into_response();
381 }
382
383 let user = sqlx::query!(
384 "SELECT id, password_hash FROM users WHERE did = $1",
385 &auth.0.did
386 )
387 .fetch_optional(&state.db)
388 .await;
389
390 let user = match user {
391 Ok(Some(u)) => u,
392 Ok(None) => {
393 return ApiError::AccountNotFound.into_response();
394 }
395 Err(e) => {
396 error!("DB error: {:?}", e);
397 return ApiError::InternalError(None).into_response();
398 }
399 };
400
401 if user.password_hash.is_none() {
402 return ApiError::InvalidRequest("Account already has no password".into()).into_response();
403 }
404
405 if let Err(e) = sqlx::query!(
406 "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1",
407 user.id
408 )
409 .execute(&state.db)
410 .await
411 {
412 error!("DB error removing password: {:?}", e);
413 return ApiError::InternalError(None).into_response();
414 }
415
416 info!(did = %&auth.0.did, "Password removed - account is now passkey-only");
417 SuccessResponse::ok().into_response()
418}
419
420#[derive(Deserialize)]
421#[serde(rename_all = "camelCase")]
422pub struct SetPasswordInput {
423 pub new_password: PlainPassword,
424}
425
426pub async fn set_password(
427 State(state): State<AppState>,
428 auth: BearerAuth,
429 Json(input): Json<SetPasswordInput>,
430) -> Response {
431 if crate::api::server::reauth::check_reauth_required_cached(
432 &state.db,
433 &state.cache,
434 &auth.0.did,
435 )
436 .await
437 {
438 return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
439 }
440
441 let new_password = &input.new_password;
442 if new_password.is_empty() {
443 return ApiError::InvalidRequest("newPassword is required".into()).into_response();
444 }
445 if let Err(e) = validate_password(new_password) {
446 return ApiError::InvalidRequest(e.to_string()).into_response();
447 }
448
449 let user = sqlx::query!(
450 "SELECT id, password_hash FROM users WHERE did = $1",
451 &auth.0.did
452 )
453 .fetch_optional(&state.db)
454 .await;
455
456 let user = match user {
457 Ok(Some(u)) => u,
458 Ok(None) => {
459 return ApiError::AccountNotFound.into_response();
460 }
461 Err(e) => {
462 error!("DB error: {:?}", e);
463 return ApiError::InternalError(None).into_response();
464 }
465 };
466
467 if user.password_hash.is_some() {
468 return ApiError::InvalidRequest(
469 "Account already has a password. Use changePassword instead.".into(),
470 )
471 .into_response();
472 }
473
474 let new_password_clone = new_password.to_string();
475 let new_hash =
476 match tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST)).await {
477 Ok(Ok(h)) => h,
478 Ok(Err(e)) => {
479 error!("Failed to hash password: {:?}", e);
480 return ApiError::InternalError(None).into_response();
481 }
482 Err(e) => {
483 error!("Failed to spawn blocking task: {:?}", e);
484 return ApiError::InternalError(None).into_response();
485 }
486 };
487
488 if let Err(e) = sqlx::query!(
489 "UPDATE users SET password_hash = $1, password_required = TRUE WHERE id = $2",
490 new_hash,
491 user.id
492 )
493 .execute(&state.db)
494 .await
495 {
496 error!("DB error setting password: {:?}", e);
497 return ApiError::InternalError(None).into_response();
498 }
499
500 info!(did = %&auth.0.did, "Password set for passkey-only account");
501 SuccessResponse::ok().into_response()
502}