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 _ =
380 crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
381 return (
382 StatusCode::UNAUTHORIZED,
383 Json(json!({
384 "error": "PasskeyCounterAnomaly",
385 "message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key."
386 })),
387 )
388 .into_response();
389 }
390 Err(e) => {
391 error!("Failed to update passkey counter: {:?}", e);
392 }
393 Ok(true) => {}
394 }
395
396 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
397
398 match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
399 Ok(reauthed_at) => {
400 info!(did = %auth.0.did, "Re-auth successful via passkey");
401 Json(ReauthResponse { reauthed_at }).into_response()
402 }
403 Err(e) => {
404 error!("DB error updating reauth: {:?}", e);
405 (
406 StatusCode::INTERNAL_SERVER_ERROR,
407 Json(json!({"error": "InternalError"})),
408 )
409 .into_response()
410 }
411 }
412}
413
414pub async fn update_last_reauth_cached(
415 db: &PgPool,
416 cache: &std::sync::Arc<dyn crate::cache::Cache>,
417 did: &str,
418) -> Result<DateTime<Utc>, sqlx::Error> {
419 let now = Utc::now();
420 sqlx::query!(
421 "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2",
422 now,
423 did
424 )
425 .execute(db)
426 .await?;
427 let cache_key = format!("reauth:{}", did);
428 let _ = cache
429 .set(
430 &cache_key,
431 &now.timestamp().to_string(),
432 std::time::Duration::from_secs(REAUTH_WINDOW_SECONDS as u64),
433 )
434 .await;
435 Ok(now)
436}
437
438fn is_reauth_required(last_reauth_at: Option<DateTime<Utc>>) -> bool {
439 match last_reauth_at {
440 None => true,
441 Some(t) => {
442 let elapsed = Utc::now().signed_duration_since(t);
443 elapsed.num_seconds() > REAUTH_WINDOW_SECONDS
444 }
445 }
446}
447
448async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> {
449 let mut methods = Vec::new();
450
451 let has_password = sqlx::query_scalar!(
452 "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1",
453 did
454 )
455 .fetch_optional(db)
456 .await
457 .ok()
458 .flatten()
459 .unwrap_or(Some(false));
460
461 if has_password == Some(true) {
462 methods.push("password".to_string());
463 }
464
465 let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await;
466 if has_totp {
467 methods.push("totp".to_string());
468 }
469
470 let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(db, did).await;
471 if has_passkeys {
472 methods.push("passkey".to_string());
473 }
474
475 methods
476}
477
478pub async fn check_reauth_required(db: &PgPool, did: &str) -> bool {
479 let session = sqlx::query!(
480 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
481 did
482 )
483 .fetch_optional(db)
484 .await;
485
486 match session {
487 Ok(Some(row)) => is_reauth_required(row.last_reauth_at),
488 _ => true,
489 }
490}
491
492pub async fn check_reauth_required_cached(
493 db: &PgPool,
494 cache: &std::sync::Arc<dyn crate::cache::Cache>,
495 did: &str,
496) -> bool {
497 let cache_key = format!("reauth:{}", did);
498 if let Some(timestamp_str) = cache.get(&cache_key).await
499 && let Ok(timestamp) = timestamp_str.parse::<i64>()
500 {
501 let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0);
502 if let Some(t) = reauth_time {
503 let elapsed = Utc::now().signed_duration_since(t);
504 if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
505 return false;
506 }
507 }
508 }
509 let session = sqlx::query!(
510 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
511 did
512 )
513 .fetch_optional(db)
514 .await;
515
516 match session {
517 Ok(Some(row)) => is_reauth_required(row.last_reauth_at),
518 _ => true,
519 }
520}
521
522#[derive(Serialize)]
523#[serde(rename_all = "camelCase")]
524pub struct ReauthRequiredError {
525 pub error: String,
526 pub message: String,
527 pub reauth_methods: Vec<String>,
528}
529
530pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response {
531 let methods = get_available_reauth_methods(db, did).await;
532 (
533 StatusCode::UNAUTHORIZED,
534 Json(ReauthRequiredError {
535 error: "ReauthRequired".to_string(),
536 message: "Re-authentication required for this action".to_string(),
537 reauth_methods: methods,
538 }),
539 )
540 .into_response()
541}
542
543pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool {
544 let session = sqlx::query!(
545 "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
546 did
547 )
548 .fetch_optional(db)
549 .await;
550
551 match session {
552 Ok(Some(row)) => {
553 if !row.legacy_login {
554 return true;
555 }
556 if row.mfa_verified {
557 return true;
558 }
559 if let Some(last_reauth) = row.last_reauth_at {
560 let elapsed = chrono::Utc::now().signed_duration_since(last_reauth);
561 if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
562 return true;
563 }
564 }
565 false
566 }
567 _ => true,
568 }
569}
570
571pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> {
572 sqlx::query!(
573 "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1",
574 did
575 )
576 .execute(db)
577 .await?;
578 Ok(())
579}
580
581pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response {
582 let methods = get_available_reauth_methods(db, did).await;
583 (
584 StatusCode::FORBIDDEN,
585 Json(serde_json::json!({
586 "error": "MfaVerificationRequired",
587 "message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.",
588 "reauthMethods": methods
589 })),
590 )
591 .into_response()
592}