this repo has no description
1use axum::{
2 Json,
3 extract::State,
4 http::StatusCode,
5 response::{IntoResponse, Response},
6};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use sqlx::PgPool;
11use tracing::{error, info, warn};
12
13use crate::auth::BearerAuth;
14use crate::state::{AppState, RateLimitKind};
15
16const REAUTH_WINDOW_SECONDS: i64 = 300;
17
18#[derive(Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct ReauthStatusResponse {
21 pub last_reauth_at: Option<DateTime<Utc>>,
22 pub reauth_required: bool,
23 pub available_methods: Vec<String>,
24}
25
26pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
27 let session = sqlx::query!(
28 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
29 auth.0.did
30 )
31 .fetch_optional(&state.db)
32 .await;
33
34 let last_reauth_at = match session {
35 Ok(Some(row)) => row.last_reauth_at,
36 Ok(None) => None,
37 Err(e) => {
38 error!("DB error: {:?}", e);
39 return (
40 StatusCode::INTERNAL_SERVER_ERROR,
41 Json(json!({"error": "InternalError"})),
42 )
43 .into_response();
44 }
45 };
46
47 let reauth_required = is_reauth_required(last_reauth_at);
48 let available_methods = get_available_reauth_methods(&state.db, &auth.0.did).await;
49
50 Json(ReauthStatusResponse {
51 last_reauth_at,
52 reauth_required,
53 available_methods,
54 })
55 .into_response()
56}
57
58#[derive(Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct PasswordReauthInput {
61 pub password: String,
62}
63
64#[derive(Serialize)]
65#[serde(rename_all = "camelCase")]
66pub struct ReauthResponse {
67 pub reauthed_at: DateTime<Utc>,
68}
69
70pub async fn reauth_password(
71 State(state): State<AppState>,
72 auth: BearerAuth,
73 Json(input): Json<PasswordReauthInput>,
74) -> Response {
75 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
76 .fetch_optional(&state.db)
77 .await;
78
79 let password_hash = match user {
80 Ok(Some(row)) => row.password_hash,
81 Ok(None) => {
82 return (
83 StatusCode::NOT_FOUND,
84 Json(json!({"error": "AccountNotFound"})),
85 )
86 .into_response();
87 }
88 Err(e) => {
89 error!("DB error: {:?}", e);
90 return (
91 StatusCode::INTERNAL_SERVER_ERROR,
92 Json(json!({"error": "InternalError"})),
93 )
94 .into_response();
95 }
96 };
97
98 let password_valid = password_hash
99 .as_ref()
100 .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
101 .unwrap_or(false);
102
103 if !password_valid {
104 let app_passwords = sqlx::query!(
105 "SELECT ap.password_hash FROM app_passwords ap
106 JOIN users u ON ap.user_id = u.id
107 WHERE u.did = $1",
108 auth.0.did
109 )
110 .fetch_all(&state.db)
111 .await
112 .unwrap_or_default();
113
114 let app_password_valid = app_passwords
115 .iter()
116 .any(|ap| bcrypt::verify(&input.password, &ap.password_hash).unwrap_or(false));
117
118 if !app_password_valid {
119 warn!(did = %auth.0.did, "Re-auth failed: invalid password");
120 return (
121 StatusCode::UNAUTHORIZED,
122 Json(json!({
123 "error": "InvalidPassword",
124 "message": "Password is incorrect"
125 })),
126 )
127 .into_response();
128 }
129 }
130
131 match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
132 Ok(reauthed_at) => {
133 info!(did = %auth.0.did, "Re-auth successful via password");
134 Json(ReauthResponse { reauthed_at }).into_response()
135 }
136 Err(e) => {
137 error!("DB error updating reauth: {:?}", e);
138 (
139 StatusCode::INTERNAL_SERVER_ERROR,
140 Json(json!({"error": "InternalError"})),
141 )
142 .into_response()
143 }
144 }
145}
146
147#[derive(Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct TotpReauthInput {
150 pub code: String,
151}
152
153pub async fn reauth_totp(
154 State(state): State<AppState>,
155 auth: BearerAuth,
156 Json(input): Json<TotpReauthInput>,
157) -> Response {
158 if !state
159 .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
160 .await
161 {
162 warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
163 return (
164 StatusCode::TOO_MANY_REQUESTS,
165 Json(json!({
166 "error": "RateLimitExceeded",
167 "message": "Too many verification attempts. Please try again in a few minutes."
168 })),
169 )
170 .into_response();
171 }
172
173 let valid =
174 crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code)
175 .await;
176
177 if !valid {
178 warn!(did = %auth.0.did, "Re-auth failed: invalid TOTP code");
179 return (
180 StatusCode::UNAUTHORIZED,
181 Json(json!({
182 "error": "InvalidCode",
183 "message": "Invalid TOTP or backup code"
184 })),
185 )
186 .into_response();
187 }
188
189 match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
190 Ok(reauthed_at) => {
191 info!(did = %auth.0.did, "Re-auth successful via TOTP");
192 Json(ReauthResponse { reauthed_at }).into_response()
193 }
194 Err(e) => {
195 error!("DB error updating reauth: {:?}", e);
196 (
197 StatusCode::INTERNAL_SERVER_ERROR,
198 Json(json!({"error": "InternalError"})),
199 )
200 .into_response()
201 }
202 }
203}
204
205#[derive(Serialize)]
206#[serde(rename_all = "camelCase")]
207pub struct PasskeyReauthStartResponse {
208 pub options: serde_json::Value,
209}
210
211pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAuth) -> Response {
212 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
213
214 let stored_passkeys =
215 match crate::auth::webauthn::get_passkeys_for_user(&state.db, &auth.0.did).await {
216 Ok(pks) => pks,
217 Err(e) => {
218 error!("Failed to get passkeys: {:?}", e);
219 return (
220 StatusCode::INTERNAL_SERVER_ERROR,
221 Json(json!({"error": "InternalError"})),
222 )
223 .into_response();
224 }
225 };
226
227 if stored_passkeys.is_empty() {
228 return (
229 StatusCode::BAD_REQUEST,
230 Json(json!({
231 "error": "NoPasskeys",
232 "message": "No passkeys registered for this account"
233 })),
234 )
235 .into_response();
236 }
237
238 let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys
239 .iter()
240 .filter_map(|sp| sp.to_security_key().ok())
241 .collect();
242
243 if passkeys.is_empty() {
244 return (
245 StatusCode::INTERNAL_SERVER_ERROR,
246 Json(json!({"error": "InternalError", "message": "Failed to load passkeys"})),
247 )
248 .into_response();
249 }
250
251 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
252 Ok(w) => w,
253 Err(e) => {
254 error!("Failed to create WebAuthn config: {:?}", e);
255 return (
256 StatusCode::INTERNAL_SERVER_ERROR,
257 Json(json!({"error": "InternalError"})),
258 )
259 .into_response();
260 }
261 };
262
263 let (rcr, auth_state) = match webauthn.start_authentication(passkeys) {
264 Ok(result) => result,
265 Err(e) => {
266 error!("Failed to start passkey authentication: {:?}", e);
267 return (
268 StatusCode::INTERNAL_SERVER_ERROR,
269 Json(json!({"error": "InternalError"})),
270 )
271 .into_response();
272 }
273 };
274
275 if let Err(e) =
276 crate::auth::webauthn::save_authentication_state(&state.db, &auth.0.did, &auth_state).await
277 {
278 error!("Failed to save authentication state: {:?}", e);
279 return (
280 StatusCode::INTERNAL_SERVER_ERROR,
281 Json(json!({"error": "InternalError"})),
282 )
283 .into_response();
284 }
285
286 let options = serde_json::to_value(&rcr).unwrap_or(json!({}));
287 Json(PasskeyReauthStartResponse { options }).into_response()
288}
289
290#[derive(Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct PasskeyReauthFinishInput {
293 pub credential: serde_json::Value,
294}
295
296pub async fn reauth_passkey_finish(
297 State(state): State<AppState>,
298 auth: BearerAuth,
299 Json(input): Json<PasskeyReauthFinishInput>,
300) -> Response {
301 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
302
303 let auth_state =
304 match crate::auth::webauthn::load_authentication_state(&state.db, &auth.0.did).await {
305 Ok(Some(s)) => s,
306 Ok(None) => {
307 return (
308 StatusCode::BAD_REQUEST,
309 Json(json!({
310 "error": "NoChallengeInProgress",
311 "message": "No passkey authentication in progress or challenge expired"
312 })),
313 )
314 .into_response();
315 }
316 Err(e) => {
317 error!("Failed to load authentication state: {:?}", e);
318 return (
319 StatusCode::INTERNAL_SERVER_ERROR,
320 Json(json!({"error": "InternalError"})),
321 )
322 .into_response();
323 }
324 };
325
326 let credential: webauthn_rs::prelude::PublicKeyCredential =
327 match serde_json::from_value(input.credential) {
328 Ok(c) => c,
329 Err(e) => {
330 warn!("Failed to parse credential: {:?}", e);
331 return (
332 StatusCode::BAD_REQUEST,
333 Json(json!({
334 "error": "InvalidCredential",
335 "message": "Failed to parse credential response"
336 })),
337 )
338 .into_response();
339 }
340 };
341
342 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
343 Ok(w) => w,
344 Err(e) => {
345 error!("Failed to create WebAuthn config: {:?}", e);
346 return (
347 StatusCode::INTERNAL_SERVER_ERROR,
348 Json(json!({"error": "InternalError"})),
349 )
350 .into_response();
351 }
352 };
353
354 let auth_result = match webauthn.finish_authentication(&credential, &auth_state) {
355 Ok(r) => r,
356 Err(e) => {
357 warn!(did = %auth.0.did, "Passkey re-auth failed: {:?}", e);
358 return (
359 StatusCode::UNAUTHORIZED,
360 Json(json!({
361 "error": "AuthenticationFailed",
362 "message": "Passkey authentication failed"
363 })),
364 )
365 .into_response();
366 }
367 };
368
369 let cred_id_bytes = auth_result.cred_id().as_ref();
370 match crate::auth::webauthn::update_passkey_counter(
371 &state.db,
372 cred_id_bytes,
373 auth_result.counter(),
374 )
375 .await
376 {
377 Ok(false) => {
378 warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key");
379 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
380 return (
381 StatusCode::UNAUTHORIZED,
382 Json(json!({
383 "error": "PasskeyCounterAnomaly",
384 "message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key."
385 })),
386 )
387 .into_response();
388 }
389 Err(e) => {
390 error!("Failed to update passkey counter: {:?}", e);
391 }
392 Ok(true) => {}
393 }
394
395 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
396
397 match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
398 Ok(reauthed_at) => {
399 info!(did = %auth.0.did, "Re-auth successful via passkey");
400 Json(ReauthResponse { reauthed_at }).into_response()
401 }
402 Err(e) => {
403 error!("DB error updating reauth: {:?}", e);
404 (
405 StatusCode::INTERNAL_SERVER_ERROR,
406 Json(json!({"error": "InternalError"})),
407 )
408 .into_response()
409 }
410 }
411}
412
413pub async fn update_last_reauth_cached(
414 db: &PgPool,
415 cache: &std::sync::Arc<dyn crate::cache::Cache>,
416 did: &str,
417) -> Result<DateTime<Utc>, sqlx::Error> {
418 let now = Utc::now();
419 sqlx::query!(
420 "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2",
421 now,
422 did
423 )
424 .execute(db)
425 .await?;
426 let cache_key = format!("reauth:{}", did);
427 let _ = cache
428 .set(
429 &cache_key,
430 &now.timestamp().to_string(),
431 std::time::Duration::from_secs(REAUTH_WINDOW_SECONDS as u64),
432 )
433 .await;
434 Ok(now)
435}
436
437fn is_reauth_required(last_reauth_at: Option<DateTime<Utc>>) -> bool {
438 match last_reauth_at {
439 None => true,
440 Some(t) => {
441 let elapsed = Utc::now().signed_duration_since(t);
442 elapsed.num_seconds() > REAUTH_WINDOW_SECONDS
443 }
444 }
445}
446
447async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> {
448 let mut methods = Vec::new();
449
450 let has_password = sqlx::query_scalar!(
451 "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1",
452 did
453 )
454 .fetch_optional(db)
455 .await
456 .ok()
457 .flatten()
458 .unwrap_or(Some(false));
459
460 if has_password == Some(true) {
461 methods.push("password".to_string());
462 }
463
464 let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await;
465 if has_totp {
466 methods.push("totp".to_string());
467 }
468
469 let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(db, did).await;
470 if has_passkeys {
471 methods.push("passkey".to_string());
472 }
473
474 methods
475}
476
477pub async fn check_reauth_required(db: &PgPool, did: &str) -> bool {
478 let session = sqlx::query!(
479 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
480 did
481 )
482 .fetch_optional(db)
483 .await;
484
485 match session {
486 Ok(Some(row)) => is_reauth_required(row.last_reauth_at),
487 _ => true,
488 }
489}
490
491pub async fn check_reauth_required_cached(
492 db: &PgPool,
493 cache: &std::sync::Arc<dyn crate::cache::Cache>,
494 did: &str,
495) -> bool {
496 let cache_key = format!("reauth:{}", did);
497 if let Some(timestamp_str) = cache.get(&cache_key).await {
498 if let Ok(timestamp) = timestamp_str.parse::<i64>() {
499 let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0);
500 if let Some(t) = reauth_time {
501 let elapsed = Utc::now().signed_duration_since(t);
502 if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
503 return false;
504 }
505 }
506 }
507 }
508 let session = sqlx::query!(
509 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
510 did
511 )
512 .fetch_optional(db)
513 .await;
514
515 match session {
516 Ok(Some(row)) => is_reauth_required(row.last_reauth_at),
517 _ => true,
518 }
519}
520
521#[derive(Serialize)]
522#[serde(rename_all = "camelCase")]
523pub struct ReauthRequiredError {
524 pub error: String,
525 pub message: String,
526 pub reauth_methods: Vec<String>,
527}
528
529pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response {
530 let methods = get_available_reauth_methods(db, did).await;
531 (
532 StatusCode::UNAUTHORIZED,
533 Json(ReauthRequiredError {
534 error: "ReauthRequired".to_string(),
535 message: "Re-authentication required for this action".to_string(),
536 reauth_methods: methods,
537 }),
538 )
539 .into_response()
540}
541
542pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool {
543 let session = sqlx::query!(
544 "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
545 did
546 )
547 .fetch_optional(db)
548 .await;
549
550 match session {
551 Ok(Some(row)) => {
552 if !row.legacy_login {
553 return true;
554 }
555 if row.mfa_verified {
556 return true;
557 }
558 if let Some(last_reauth) = row.last_reauth_at {
559 let elapsed = chrono::Utc::now().signed_duration_since(last_reauth);
560 if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
561 return true;
562 }
563 }
564 false
565 }
566 _ => true,
567 }
568}
569
570pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> {
571 sqlx::query!(
572 "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1",
573 did
574 )
575 .execute(db)
576 .await?;
577 Ok(())
578}
579
580pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response {
581 let methods = get_available_reauth_methods(db, did).await;
582 (
583 StatusCode::FORBIDDEN,
584 Json(serde_json::json!({
585 "error": "MfaVerificationRequired",
586 "message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.",
587 "reauthMethods": methods
588 })),
589 )
590 .into_response()
591}