···1+CREATE TABLE user_totp (
2+ did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE,
3+ secret_encrypted BYTEA NOT NULL,
4+ encryption_version INTEGER NOT NULL DEFAULT 1,
5+ verified BOOLEAN NOT NULL DEFAULT FALSE,
6+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7+ last_used TIMESTAMPTZ
8+);
9+10+CREATE TABLE backup_codes (
11+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
12+ did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
13+ code_hash TEXT NOT NULL,
14+ used_at TIMESTAMPTZ,
15+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
16+);
17+CREATE INDEX idx_backup_codes_did ON backup_codes(did);
18+19+CREATE TABLE passkeys (
20+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
21+ did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
22+ credential_id BYTEA NOT NULL UNIQUE,
23+ public_key BYTEA NOT NULL,
24+ sign_count INTEGER NOT NULL DEFAULT 0,
25+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
26+ last_used TIMESTAMPTZ,
27+ friendly_name TEXT,
28+ aaguid BYTEA,
29+ transports TEXT[]
30+);
31+CREATE INDEX idx_passkeys_did ON passkeys(did);
32+33+CREATE TABLE webauthn_challenges (
34+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
35+ did TEXT NOT NULL,
36+ challenge BYTEA NOT NULL,
37+ challenge_type TEXT NOT NULL,
38+ state_json TEXT NOT NULL,
39+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
40+ expires_at TIMESTAMPTZ NOT NULL
41+);
42+CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
+10
src/api/server/mod.rs
···3pub mod email;
4pub mod invite;
5pub mod meta;
06pub mod password;
7pub mod service_auth;
8pub mod session;
9pub mod signing_key;
01011pub use account_status::{
12 activate_account, check_account_status, deactivate_account, delete_account,
···16pub use email::{confirm_email, request_email_update, update_email};
17pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
18pub use meta::{describe_server, health, robots_txt};
000019pub use password::{change_password, request_password_reset, reset_password};
20pub use service_auth::get_service_auth;
21pub use session::{
···23 resend_verification, revoke_session,
24};
25pub use signing_key::reserve_signing_key;
0000
···3pub mod email;
4pub mod invite;
5pub mod meta;
6+pub mod passkeys;
7pub mod password;
8pub mod service_auth;
9pub mod session;
10pub mod signing_key;
11+pub mod totp;
1213pub use account_status::{
14 activate_account, check_account_status, deactivate_account, delete_account,
···18pub use email::{confirm_email, request_email_update, update_email};
19pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
20pub use meta::{describe_server, health, robots_txt};
21+pub use passkeys::{
22+ delete_passkey, finish_passkey_registration, has_passkeys_for_user, list_passkeys,
23+ start_passkey_registration, update_passkey,
24+};
25pub use password::{change_password, request_password_reset, reset_password};
26pub use service_auth::get_service_auth;
27pub use session::{
···29 resend_verification, revoke_session,
30};
31pub use signing_key::reserve_signing_key;
32+pub use totp::{
33+ create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled,
34+ regenerate_backup_codes, verify_totp_or_backup_for_user,
35+};
···1+use crate::auth::BearerAuth;
2+use crate::auth::totp::{
3+ decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
4+ generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
5+ verify_backup_code, verify_totp_code,
6+};
7+use crate::state::AppState;
8+use axum::{
9+ Json,
10+ extract::State,
11+ http::StatusCode,
12+ response::{IntoResponse, Response},
13+};
14+use chrono::Utc;
15+use serde::{Deserialize, Serialize};
16+use serde_json::json;
17+use tracing::{error, info, warn};
18+19+const ENCRYPTION_VERSION: i32 = 1;
20+21+#[derive(Serialize)]
22+#[serde(rename_all = "camelCase")]
23+pub struct CreateTotpSecretResponse {
24+ pub secret: String,
25+ pub uri: String,
26+ pub qr_base64: String,
27+}
28+29+pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response {
30+ let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
31+ .fetch_optional(&state.db)
32+ .await;
33+34+ if let Ok(Some(true)) = existing {
35+ return (
36+ StatusCode::CONFLICT,
37+ Json(json!({
38+ "error": "TotpAlreadyEnabled",
39+ "message": "TOTP is already enabled for this account"
40+ })),
41+ )
42+ .into_response();
43+ }
44+45+ let secret = generate_totp_secret();
46+47+ let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth.0.did)
48+ .fetch_optional(&state.db)
49+ .await;
50+51+ let handle = match handle {
52+ Ok(Some(h)) => h,
53+ Ok(None) => {
54+ return (
55+ StatusCode::NOT_FOUND,
56+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
57+ )
58+ .into_response();
59+ }
60+ Err(e) => {
61+ error!("DB error fetching handle: {:?}", e);
62+ return (
63+ StatusCode::INTERNAL_SERVER_ERROR,
64+ Json(json!({"error": "InternalError"})),
65+ )
66+ .into_response();
67+ }
68+ };
69+70+ let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
71+ let uri = generate_totp_uri(&secret, &handle, &hostname);
72+73+ let qr_code = match generate_qr_png_base64(&secret, &handle, &hostname) {
74+ Ok(qr) => qr,
75+ Err(e) => {
76+ error!("Failed to generate QR code: {:?}", e);
77+ return (
78+ StatusCode::INTERNAL_SERVER_ERROR,
79+ Json(json!({"error": "InternalError", "message": "Failed to generate QR code"})),
80+ )
81+ .into_response();
82+ }
83+ };
84+85+ let encrypted_secret = match encrypt_totp_secret(&secret) {
86+ Ok(enc) => enc,
87+ Err(e) => {
88+ error!("Failed to encrypt TOTP secret: {:?}", e);
89+ return (
90+ StatusCode::INTERNAL_SERVER_ERROR,
91+ Json(json!({"error": "InternalError"})),
92+ )
93+ .into_response();
94+ }
95+ };
96+97+ let result = sqlx::query!(
98+ r#"
99+ INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)
100+ VALUES ($1, $2, $3, false, NOW())
101+ ON CONFLICT (did) DO UPDATE SET
102+ secret_encrypted = $2,
103+ encryption_version = $3,
104+ verified = false,
105+ created_at = NOW(),
106+ last_used = NULL
107+ "#,
108+ auth.0.did,
109+ encrypted_secret,
110+ ENCRYPTION_VERSION
111+ )
112+ .execute(&state.db)
113+ .await;
114+115+ if let Err(e) = result {
116+ error!("Failed to store TOTP secret: {:?}", e);
117+ return (
118+ StatusCode::INTERNAL_SERVER_ERROR,
119+ Json(json!({"error": "InternalError"})),
120+ )
121+ .into_response();
122+ }
123+124+ let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
125+126+ info!(did = %auth.0.did, "TOTP secret created (pending verification)");
127+128+ Json(CreateTotpSecretResponse {
129+ secret: secret_base32,
130+ uri,
131+ qr_base64: qr_code,
132+ })
133+ .into_response()
134+}
135+136+#[derive(Deserialize)]
137+pub struct EnableTotpInput {
138+ pub code: String,
139+}
140+141+#[derive(Serialize)]
142+#[serde(rename_all = "camelCase")]
143+pub struct EnableTotpResponse {
144+ pub backup_codes: Vec<String>,
145+}
146+147+pub async fn enable_totp(
148+ State(state): State<AppState>,
149+ auth: BearerAuth,
150+ Json(input): Json<EnableTotpInput>,
151+) -> Response {
152+ let totp_row = sqlx::query!(
153+ "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
154+ auth.0.did
155+ )
156+ .fetch_optional(&state.db)
157+ .await;
158+159+ let totp_row = match totp_row {
160+ Ok(Some(row)) => row,
161+ Ok(None) => {
162+ return (
163+ StatusCode::BAD_REQUEST,
164+ Json(json!({
165+ "error": "TotpNotSetup",
166+ "message": "Please call createTotpSecret first"
167+ })),
168+ )
169+ .into_response();
170+ }
171+ Err(e) => {
172+ error!("DB error fetching TOTP: {:?}", e);
173+ return (
174+ StatusCode::INTERNAL_SERVER_ERROR,
175+ Json(json!({"error": "InternalError"})),
176+ )
177+ .into_response();
178+ }
179+ };
180+181+ if totp_row.verified {
182+ return (
183+ StatusCode::CONFLICT,
184+ Json(json!({
185+ "error": "TotpAlreadyEnabled",
186+ "message": "TOTP is already enabled"
187+ })),
188+ )
189+ .into_response();
190+ }
191+192+ let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
193+ {
194+ Ok(s) => s,
195+ Err(e) => {
196+ error!("Failed to decrypt TOTP secret: {:?}", e);
197+ return (
198+ StatusCode::INTERNAL_SERVER_ERROR,
199+ Json(json!({"error": "InternalError"})),
200+ )
201+ .into_response();
202+ }
203+ };
204+205+ let code = input.code.trim();
206+ if !verify_totp_code(&secret, code) {
207+ return (
208+ StatusCode::UNAUTHORIZED,
209+ Json(json!({
210+ "error": "InvalidCode",
211+ "message": "Invalid verification code"
212+ })),
213+ )
214+ .into_response();
215+ }
216+217+ let backup_codes = generate_backup_codes();
218+ let mut tx = match state.db.begin().await {
219+ Ok(tx) => tx,
220+ Err(e) => {
221+ error!("Failed to begin transaction: {:?}", e);
222+ return (
223+ StatusCode::INTERNAL_SERVER_ERROR,
224+ Json(json!({"error": "InternalError"})),
225+ )
226+ .into_response();
227+ }
228+ };
229+230+ if let Err(e) = sqlx::query!(
231+ "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1",
232+ auth.0.did
233+ )
234+ .execute(&mut *tx)
235+ .await
236+ {
237+ error!("Failed to enable TOTP: {:?}", e);
238+ return (
239+ StatusCode::INTERNAL_SERVER_ERROR,
240+ Json(json!({"error": "InternalError"})),
241+ )
242+ .into_response();
243+ }
244+245+ if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
246+ .execute(&mut *tx)
247+ .await
248+ {
249+ error!("Failed to clear old backup codes: {:?}", e);
250+ return (
251+ StatusCode::INTERNAL_SERVER_ERROR,
252+ Json(json!({"error": "InternalError"})),
253+ )
254+ .into_response();
255+ }
256+257+ for code in &backup_codes {
258+ let hash = match hash_backup_code(code) {
259+ Ok(h) => h,
260+ Err(e) => {
261+ error!("Failed to hash backup code: {:?}", e);
262+ return (
263+ StatusCode::INTERNAL_SERVER_ERROR,
264+ Json(json!({"error": "InternalError"})),
265+ )
266+ .into_response();
267+ }
268+ };
269+270+ if let Err(e) = sqlx::query!(
271+ "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
272+ auth.0.did,
273+ hash
274+ )
275+ .execute(&mut *tx)
276+ .await
277+ {
278+ error!("Failed to store backup code: {:?}", e);
279+ return (
280+ StatusCode::INTERNAL_SERVER_ERROR,
281+ Json(json!({"error": "InternalError"})),
282+ )
283+ .into_response();
284+ }
285+ }
286+287+ if let Err(e) = tx.commit().await {
288+ error!("Failed to commit transaction: {:?}", e);
289+ return (
290+ StatusCode::INTERNAL_SERVER_ERROR,
291+ Json(json!({"error": "InternalError"})),
292+ )
293+ .into_response();
294+ }
295+296+ info!(did = %auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
297+298+ Json(EnableTotpResponse { backup_codes }).into_response()
299+}
300+301+#[derive(Deserialize)]
302+pub struct DisableTotpInput {
303+ pub password: String,
304+ pub code: String,
305+}
306+307+pub async fn disable_totp(
308+ State(state): State<AppState>,
309+ auth: BearerAuth,
310+ Json(input): Json<DisableTotpInput>,
311+) -> Response {
312+ let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
313+ .fetch_optional(&state.db)
314+ .await;
315+316+ let password_hash = match user {
317+ Ok(Some(row)) => row.password_hash,
318+ Ok(None) => {
319+ return (
320+ StatusCode::NOT_FOUND,
321+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
322+ )
323+ .into_response();
324+ }
325+ Err(e) => {
326+ error!("DB error fetching user: {:?}", e);
327+ return (
328+ StatusCode::INTERNAL_SERVER_ERROR,
329+ Json(json!({"error": "InternalError"})),
330+ )
331+ .into_response();
332+ }
333+ };
334+335+ let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
336+ if !password_valid {
337+ return (
338+ StatusCode::UNAUTHORIZED,
339+ Json(json!({
340+ "error": "InvalidPassword",
341+ "message": "Password is incorrect"
342+ })),
343+ )
344+ .into_response();
345+ }
346+347+ let totp_row = sqlx::query!(
348+ "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
349+ auth.0.did
350+ )
351+ .fetch_optional(&state.db)
352+ .await;
353+354+ let totp_row = match totp_row {
355+ Ok(Some(row)) if row.verified => row,
356+ Ok(Some(_)) | Ok(None) => {
357+ return (
358+ StatusCode::BAD_REQUEST,
359+ Json(json!({
360+ "error": "TotpNotEnabled",
361+ "message": "TOTP is not enabled for this account"
362+ })),
363+ )
364+ .into_response();
365+ }
366+ Err(e) => {
367+ error!("DB error fetching TOTP: {:?}", e);
368+ return (
369+ StatusCode::INTERNAL_SERVER_ERROR,
370+ Json(json!({"error": "InternalError"})),
371+ )
372+ .into_response();
373+ }
374+ };
375+376+ let code = input.code.trim();
377+ let code_valid = if is_backup_code_format(code) {
378+ verify_backup_code_for_user(&state, &auth.0.did, code).await
379+ } else {
380+ let secret =
381+ match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) {
382+ Ok(s) => s,
383+ Err(e) => {
384+ error!("Failed to decrypt TOTP secret: {:?}", e);
385+ return (
386+ StatusCode::INTERNAL_SERVER_ERROR,
387+ Json(json!({"error": "InternalError"})),
388+ )
389+ .into_response();
390+ }
391+ };
392+ verify_totp_code(&secret, code)
393+ };
394+395+ if !code_valid {
396+ return (
397+ StatusCode::UNAUTHORIZED,
398+ Json(json!({
399+ "error": "InvalidCode",
400+ "message": "Invalid verification code"
401+ })),
402+ )
403+ .into_response();
404+ }
405+406+ let mut tx = match state.db.begin().await {
407+ Ok(tx) => tx,
408+ Err(e) => {
409+ error!("Failed to begin transaction: {:?}", e);
410+ return (
411+ StatusCode::INTERNAL_SERVER_ERROR,
412+ Json(json!({"error": "InternalError"})),
413+ )
414+ .into_response();
415+ }
416+ };
417+418+ if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did)
419+ .execute(&mut *tx)
420+ .await
421+ {
422+ error!("Failed to delete TOTP: {:?}", e);
423+ return (
424+ StatusCode::INTERNAL_SERVER_ERROR,
425+ Json(json!({"error": "InternalError"})),
426+ )
427+ .into_response();
428+ }
429+430+ if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
431+ .execute(&mut *tx)
432+ .await
433+ {
434+ error!("Failed to delete backup codes: {:?}", e);
435+ return (
436+ StatusCode::INTERNAL_SERVER_ERROR,
437+ Json(json!({"error": "InternalError"})),
438+ )
439+ .into_response();
440+ }
441+442+ if let Err(e) = tx.commit().await {
443+ error!("Failed to commit transaction: {:?}", e);
444+ return (
445+ StatusCode::INTERNAL_SERVER_ERROR,
446+ Json(json!({"error": "InternalError"})),
447+ )
448+ .into_response();
449+ }
450+451+ info!(did = %auth.0.did, "TOTP disabled");
452+453+ (StatusCode::OK, Json(json!({}))).into_response()
454+}
455+456+#[derive(Serialize)]
457+#[serde(rename_all = "camelCase")]
458+pub struct GetTotpStatusResponse {
459+ pub enabled: bool,
460+ pub has_backup_codes: bool,
461+ pub backup_codes_remaining: i64,
462+}
463+464+pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
465+ let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
466+ .fetch_optional(&state.db)
467+ .await;
468+469+ let enabled = match totp_row {
470+ Ok(Some(row)) => row.verified,
471+ Ok(None) => false,
472+ Err(e) => {
473+ error!("DB error fetching TOTP status: {:?}", e);
474+ return (
475+ StatusCode::INTERNAL_SERVER_ERROR,
476+ Json(json!({"error": "InternalError"})),
477+ )
478+ .into_response();
479+ }
480+ };
481+482+ let backup_count_row = sqlx::query!(
483+ "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
484+ auth.0.did
485+ )
486+ .fetch_one(&state.db)
487+ .await;
488+489+ let backup_count = backup_count_row.map(|r| r.count.unwrap_or(0)).unwrap_or(0);
490+491+ Json(GetTotpStatusResponse {
492+ enabled,
493+ has_backup_codes: backup_count > 0,
494+ backup_codes_remaining: backup_count,
495+ })
496+ .into_response()
497+}
498+499+#[derive(Deserialize)]
500+pub struct RegenerateBackupCodesInput {
501+ pub password: String,
502+ pub code: String,
503+}
504+505+#[derive(Serialize)]
506+#[serde(rename_all = "camelCase")]
507+pub struct RegenerateBackupCodesResponse {
508+ pub backup_codes: Vec<String>,
509+}
510+511+pub async fn regenerate_backup_codes(
512+ State(state): State<AppState>,
513+ auth: BearerAuth,
514+ Json(input): Json<RegenerateBackupCodesInput>,
515+) -> Response {
516+ let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
517+ .fetch_optional(&state.db)
518+ .await;
519+520+ let password_hash = match user {
521+ Ok(Some(row)) => row.password_hash,
522+ Ok(None) => {
523+ return (
524+ StatusCode::NOT_FOUND,
525+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
526+ )
527+ .into_response();
528+ }
529+ Err(e) => {
530+ error!("DB error fetching user: {:?}", e);
531+ return (
532+ StatusCode::INTERNAL_SERVER_ERROR,
533+ Json(json!({"error": "InternalError"})),
534+ )
535+ .into_response();
536+ }
537+ };
538+539+ let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
540+ if !password_valid {
541+ return (
542+ StatusCode::UNAUTHORIZED,
543+ Json(json!({
544+ "error": "InvalidPassword",
545+ "message": "Password is incorrect"
546+ })),
547+ )
548+ .into_response();
549+ }
550+551+ let totp_row = sqlx::query!(
552+ "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
553+ auth.0.did
554+ )
555+ .fetch_optional(&state.db)
556+ .await;
557+558+ let totp_row = match totp_row {
559+ Ok(Some(row)) if row.verified => row,
560+ Ok(Some(_)) | Ok(None) => {
561+ return (
562+ StatusCode::BAD_REQUEST,
563+ Json(json!({
564+ "error": "TotpNotEnabled",
565+ "message": "TOTP must be enabled to regenerate backup codes"
566+ })),
567+ )
568+ .into_response();
569+ }
570+ Err(e) => {
571+ error!("DB error fetching TOTP: {:?}", e);
572+ return (
573+ StatusCode::INTERNAL_SERVER_ERROR,
574+ Json(json!({"error": "InternalError"})),
575+ )
576+ .into_response();
577+ }
578+ };
579+580+ let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
581+ {
582+ Ok(s) => s,
583+ Err(e) => {
584+ error!("Failed to decrypt TOTP secret: {:?}", e);
585+ return (
586+ StatusCode::INTERNAL_SERVER_ERROR,
587+ Json(json!({"error": "InternalError"})),
588+ )
589+ .into_response();
590+ }
591+ };
592+593+ let code = input.code.trim();
594+ if !verify_totp_code(&secret, code) {
595+ return (
596+ StatusCode::UNAUTHORIZED,
597+ Json(json!({
598+ "error": "InvalidCode",
599+ "message": "Invalid verification code"
600+ })),
601+ )
602+ .into_response();
603+ }
604+605+ let backup_codes = generate_backup_codes();
606+ let mut tx = match state.db.begin().await {
607+ Ok(tx) => tx,
608+ Err(e) => {
609+ error!("Failed to begin transaction: {:?}", e);
610+ return (
611+ StatusCode::INTERNAL_SERVER_ERROR,
612+ Json(json!({"error": "InternalError"})),
613+ )
614+ .into_response();
615+ }
616+ };
617+618+ if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
619+ .execute(&mut *tx)
620+ .await
621+ {
622+ error!("Failed to clear old backup codes: {:?}", e);
623+ return (
624+ StatusCode::INTERNAL_SERVER_ERROR,
625+ Json(json!({"error": "InternalError"})),
626+ )
627+ .into_response();
628+ }
629+630+ for code in &backup_codes {
631+ let hash = match hash_backup_code(code) {
632+ Ok(h) => h,
633+ Err(e) => {
634+ error!("Failed to hash backup code: {:?}", e);
635+ return (
636+ StatusCode::INTERNAL_SERVER_ERROR,
637+ Json(json!({"error": "InternalError"})),
638+ )
639+ .into_response();
640+ }
641+ };
642+643+ if let Err(e) = sqlx::query!(
644+ "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
645+ auth.0.did,
646+ hash
647+ )
648+ .execute(&mut *tx)
649+ .await
650+ {
651+ error!("Failed to store backup code: {:?}", e);
652+ return (
653+ StatusCode::INTERNAL_SERVER_ERROR,
654+ Json(json!({"error": "InternalError"})),
655+ )
656+ .into_response();
657+ }
658+ }
659+660+ if let Err(e) = tx.commit().await {
661+ error!("Failed to commit transaction: {:?}", e);
662+ return (
663+ StatusCode::INTERNAL_SERVER_ERROR,
664+ Json(json!({"error": "InternalError"})),
665+ )
666+ .into_response();
667+ }
668+669+ info!(did = %auth.0.did, "Backup codes regenerated");
670+671+ Json(RegenerateBackupCodesResponse { backup_codes }).into_response()
672+}
673+674+async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> bool {
675+ let code = code.trim().to_uppercase();
676+677+ let backup_codes = sqlx::query!(
678+ "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL",
679+ did
680+ )
681+ .fetch_all(&state.db)
682+ .await;
683+684+ let backup_codes = match backup_codes {
685+ Ok(codes) => codes,
686+ Err(e) => {
687+ warn!("Failed to fetch backup codes: {:?}", e);
688+ return false;
689+ }
690+ };
691+692+ for row in backup_codes {
693+ if verify_backup_code(&code, &row.code_hash) {
694+ let _ = sqlx::query!(
695+ "UPDATE backup_codes SET used_at = $1 WHERE id = $2",
696+ Utc::now(),
697+ row.id
698+ )
699+ .execute(&state.db)
700+ .await;
701+ return true;
702+ }
703+ }
704+705+ false
706+}
707+708+pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool {
709+ let code = code.trim();
710+711+ if is_backup_code_format(code) {
712+ return verify_backup_code_for_user(state, did, code).await;
713+ }
714+715+ let totp_row = sqlx::query!(
716+ "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
717+ did
718+ )
719+ .fetch_optional(&state.db)
720+ .await;
721+722+ let totp_row = match totp_row {
723+ Ok(Some(row)) if row.verified => row,
724+ _ => return false,
725+ };
726+727+ let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
728+ {
729+ Ok(s) => s,
730+ Err(_) => return false,
731+ };
732+733+ if verify_totp_code(&secret, code) {
734+ let _ = sqlx::query!("UPDATE user_totp SET last_used = NOW() WHERE did = $1", did)
735+ .execute(&state.db)
736+ .await;
737+ return true;
738+ }
739+740+ false
741+}
742+743+pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool {
744+ let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
745+ .fetch_optional(&state.db)
746+ .await;
747+748+ matches!(result, Ok(Some(true)))
749+}
+2
src/auth/mod.rs
···11pub mod scope_check;
12pub mod service;
13pub mod token;
014pub mod verify;
01516pub use extractor::{
17 AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,
···11pub mod scope_check;
12pub mod service;
13pub mod token;
14+pub mod totp;
15pub mod verify;
16+pub mod webauthn;
1718pub use extractor::{
19 AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,