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