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_hash = match hash(password, DEFAULT_COST) {
230 Ok(h) => h,
231 Err(e) => {
232 error!("Failed to hash password: {:?}", e);
233 return (
234 StatusCode::INTERNAL_SERVER_ERROR,
235 Json(json!({"error": "InternalError"})),
236 )
237 .into_response();
238 }
239 };
240 let mut tx = match state.db.begin().await {
241 Ok(tx) => tx,
242 Err(e) => {
243 error!("Failed to begin transaction: {:?}", e);
244 return (
245 StatusCode::INTERNAL_SERVER_ERROR,
246 Json(json!({"error": "InternalError"})),
247 )
248 .into_response();
249 }
250 };
251 if let Err(e) = sqlx::query!(
252 "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2",
253 password_hash,
254 user_id
255 )
256 .execute(&mut *tx)
257 .await
258 {
259 error!("DB error updating password: {:?}", e);
260 return (
261 StatusCode::INTERNAL_SERVER_ERROR,
262 Json(json!({"error": "InternalError"})),
263 )
264 .into_response();
265 }
266 let user_did = match sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", user_id)
267 .fetch_one(&mut *tx)
268 .await
269 {
270 Ok(did) => did,
271 Err(e) => {
272 error!("Failed to get DID for user {}: {:?}", user_id, e);
273 return (
274 StatusCode::INTERNAL_SERVER_ERROR,
275 Json(json!({"error": "InternalError"})),
276 )
277 .into_response();
278 }
279 };
280 let session_jtis: Vec<String> = match sqlx::query_scalar!(
281 "SELECT access_jti FROM session_tokens WHERE did = $1",
282 user_did
283 )
284 .fetch_all(&mut *tx)
285 .await
286 {
287 Ok(jtis) => jtis,
288 Err(e) => {
289 error!("Failed to fetch session JTIs: {:?}", e);
290 vec![]
291 }
292 };
293 if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", user_did)
294 .execute(&mut *tx)
295 .await
296 {
297 error!(
298 "Failed to invalidate sessions after password reset: {:?}",
299 e
300 );
301 return (
302 StatusCode::INTERNAL_SERVER_ERROR,
303 Json(json!({"error": "InternalError"})),
304 )
305 .into_response();
306 }
307 if let Err(e) = tx.commit().await {
308 error!("Failed to commit password reset transaction: {:?}", e);
309 return (
310 StatusCode::INTERNAL_SERVER_ERROR,
311 Json(json!({"error": "InternalError"})),
312 )
313 .into_response();
314 }
315 for jti in session_jtis {
316 let cache_key = format!("auth:session:{}:{}", user_did, jti);
317 if let Err(e) = state.cache.delete(&cache_key).await {
318 warn!(
319 "Failed to invalidate session cache for {}: {:?}",
320 cache_key, e
321 );
322 }
323 }
324 info!("Password reset completed for user {}", user_id);
325 (StatusCode::OK, Json(json!({}))).into_response()
326}
327
328#[derive(Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct ChangePasswordInput {
331 pub current_password: String,
332 pub new_password: String,
333}
334
335pub async fn change_password(
336 State(state): State<AppState>,
337 auth: BearerAuth,
338 Json(input): Json<ChangePasswordInput>,
339) -> Response {
340 if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
341 return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
342 .await;
343 }
344
345 let current_password = &input.current_password;
346 let new_password = &input.new_password;
347 if current_password.is_empty() {
348 return (
349 StatusCode::BAD_REQUEST,
350 Json(json!({"error": "InvalidRequest", "message": "currentPassword is required"})),
351 )
352 .into_response();
353 }
354 if new_password.is_empty() {
355 return (
356 StatusCode::BAD_REQUEST,
357 Json(json!({"error": "InvalidRequest", "message": "newPassword is required"})),
358 )
359 .into_response();
360 }
361 if let Err(e) = validate_password(new_password) {
362 return (
363 StatusCode::BAD_REQUEST,
364 Json(json!({
365 "error": "InvalidPassword",
366 "message": e.to_string()
367 })),
368 )
369 .into_response();
370 }
371 let user =
372 sqlx::query_as::<_, (Uuid, String)>("SELECT id, password_hash FROM users WHERE did = $1")
373 .bind(&auth.0.did)
374 .fetch_optional(&state.db)
375 .await;
376 let (user_id, password_hash) = match user {
377 Ok(Some(row)) => row,
378 Ok(None) => {
379 return (
380 StatusCode::NOT_FOUND,
381 Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
382 )
383 .into_response();
384 }
385 Err(e) => {
386 error!("DB error in change_password: {:?}", e);
387 return (
388 StatusCode::INTERNAL_SERVER_ERROR,
389 Json(json!({"error": "InternalError"})),
390 )
391 .into_response();
392 }
393 };
394 let valid = match verify(current_password, &password_hash) {
395 Ok(v) => v,
396 Err(e) => {
397 error!("Password verification error: {:?}", e);
398 return (
399 StatusCode::INTERNAL_SERVER_ERROR,
400 Json(json!({"error": "InternalError"})),
401 )
402 .into_response();
403 }
404 };
405 if !valid {
406 return (
407 StatusCode::UNAUTHORIZED,
408 Json(json!({"error": "InvalidPassword", "message": "Current password is incorrect"})),
409 )
410 .into_response();
411 }
412 let new_hash = match hash(new_password, DEFAULT_COST) {
413 Ok(h) => h,
414 Err(e) => {
415 error!("Failed to hash password: {:?}", e);
416 return (
417 StatusCode::INTERNAL_SERVER_ERROR,
418 Json(json!({"error": "InternalError"})),
419 )
420 .into_response();
421 }
422 };
423 if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
424 .bind(&new_hash)
425 .bind(user_id)
426 .execute(&state.db)
427 .await
428 {
429 error!("DB error updating password: {:?}", e);
430 return (
431 StatusCode::INTERNAL_SERVER_ERROR,
432 Json(json!({"error": "InternalError"})),
433 )
434 .into_response();
435 }
436 info!(did = %auth.0.did, "Password changed successfully");
437 (StatusCode::OK, Json(json!({}))).into_response()
438}
439
440pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
441 let user = sqlx::query!(
442 "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1",
443 auth.0.did
444 )
445 .fetch_optional(&state.db)
446 .await;
447
448 match user {
449 Ok(Some(row)) => {
450 Json(json!({"hasPassword": row.has_password.unwrap_or(false)})).into_response()
451 }
452 Ok(None) => (
453 StatusCode::NOT_FOUND,
454 Json(json!({"error": "AccountNotFound"})),
455 )
456 .into_response(),
457 Err(e) => {
458 error!("DB error: {:?}", e);
459 (
460 StatusCode::INTERNAL_SERVER_ERROR,
461 Json(json!({"error": "InternalError"})),
462 )
463 .into_response()
464 }
465 }
466}
467
468pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response {
469 if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
470 return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
471 .await;
472 }
473
474 if crate::api::server::reauth::check_reauth_required_cached(
475 &state.db,
476 &state.cache,
477 &auth.0.did,
478 )
479 .await
480 {
481 return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
482 }
483
484 let has_passkeys =
485 crate::api::server::passkeys::has_passkeys_for_user_db(&state.db, &auth.0.did).await;
486 if !has_passkeys {
487 return (
488 StatusCode::BAD_REQUEST,
489 Json(json!({
490 "error": "NoPasskeys",
491 "message": "You must have at least one passkey registered before removing your password"
492 })),
493 )
494 .into_response();
495 }
496
497 let user = sqlx::query!(
498 "SELECT id, password_hash FROM users WHERE did = $1",
499 auth.0.did
500 )
501 .fetch_optional(&state.db)
502 .await;
503
504 let user = match user {
505 Ok(Some(u)) => u,
506 Ok(None) => {
507 return (
508 StatusCode::NOT_FOUND,
509 Json(json!({"error": "AccountNotFound"})),
510 )
511 .into_response();
512 }
513 Err(e) => {
514 error!("DB error: {:?}", e);
515 return (
516 StatusCode::INTERNAL_SERVER_ERROR,
517 Json(json!({"error": "InternalError"})),
518 )
519 .into_response();
520 }
521 };
522
523 if user.password_hash.is_none() {
524 return (
525 StatusCode::BAD_REQUEST,
526 Json(json!({
527 "error": "NoPassword",
528 "message": "Account already has no password"
529 })),
530 )
531 .into_response();
532 }
533
534 if let Err(e) = sqlx::query!(
535 "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1",
536 user.id
537 )
538 .execute(&state.db)
539 .await
540 {
541 error!("DB error removing password: {:?}", e);
542 return (
543 StatusCode::INTERNAL_SERVER_ERROR,
544 Json(json!({"error": "InternalError"})),
545 )
546 .into_response();
547 }
548
549 info!(did = %auth.0.did, "Password removed - account is now passkey-only");
550 (StatusCode::OK, Json(json!({"success": true}))).into_response()
551}