···11+CREATE TABLE user_totp (
22+ did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE,
33+ secret_encrypted BYTEA NOT NULL,
44+ encryption_version INTEGER NOT NULL DEFAULT 1,
55+ verified BOOLEAN NOT NULL DEFAULT FALSE,
66+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
77+ last_used TIMESTAMPTZ
88+);
99+1010+CREATE TABLE backup_codes (
1111+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1212+ did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
1313+ code_hash TEXT NOT NULL,
1414+ used_at TIMESTAMPTZ,
1515+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1616+);
1717+CREATE INDEX idx_backup_codes_did ON backup_codes(did);
1818+1919+CREATE TABLE passkeys (
2020+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
2121+ did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
2222+ credential_id BYTEA NOT NULL UNIQUE,
2323+ public_key BYTEA NOT NULL,
2424+ sign_count INTEGER NOT NULL DEFAULT 0,
2525+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2626+ last_used TIMESTAMPTZ,
2727+ friendly_name TEXT,
2828+ aaguid BYTEA,
2929+ transports TEXT[]
3030+);
3131+CREATE INDEX idx_passkeys_did ON passkeys(did);
3232+3333+CREATE TABLE webauthn_challenges (
3434+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3535+ did TEXT NOT NULL,
3636+ challenge BYTEA NOT NULL,
3737+ challenge_type TEXT NOT NULL,
3838+ state_json TEXT NOT NULL,
3939+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
4040+ expires_at TIMESTAMPTZ NOT NULL
4141+);
4242+CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
+10
src/api/server/mod.rs
···33pub mod email;
44pub mod invite;
55pub mod meta;
66+pub mod passkeys;
67pub mod password;
78pub mod service_auth;
89pub mod session;
910pub mod signing_key;
1111+pub mod totp;
10121113pub use account_status::{
1214 activate_account, check_account_status, deactivate_account, delete_account,
···1618pub use email::{confirm_email, request_email_update, update_email};
1719pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
1820pub use meta::{describe_server, health, robots_txt};
2121+pub use passkeys::{
2222+ delete_passkey, finish_passkey_registration, has_passkeys_for_user, list_passkeys,
2323+ start_passkey_registration, update_passkey,
2424+};
1925pub use password::{change_password, request_password_reset, reset_password};
2026pub use service_auth::get_service_auth;
2127pub use session::{
···2329 resend_verification, revoke_session,
2430};
2531pub use signing_key::reserve_signing_key;
3232+pub use totp::{
3333+ create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled,
3434+ regenerate_backup_codes, verify_totp_or_backup_for_user,
3535+};
···11+use crate::auth::BearerAuth;
22+use crate::auth::totp::{
33+ decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
44+ generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
55+ verify_backup_code, verify_totp_code,
66+};
77+use crate::state::AppState;
88+use axum::{
99+ Json,
1010+ extract::State,
1111+ http::StatusCode,
1212+ response::{IntoResponse, Response},
1313+};
1414+use chrono::Utc;
1515+use serde::{Deserialize, Serialize};
1616+use serde_json::json;
1717+use tracing::{error, info, warn};
1818+1919+const ENCRYPTION_VERSION: i32 = 1;
2020+2121+#[derive(Serialize)]
2222+#[serde(rename_all = "camelCase")]
2323+pub struct CreateTotpSecretResponse {
2424+ pub secret: String,
2525+ pub uri: String,
2626+ pub qr_base64: String,
2727+}
2828+2929+pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response {
3030+ let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
3131+ .fetch_optional(&state.db)
3232+ .await;
3333+3434+ if let Ok(Some(true)) = existing {
3535+ return (
3636+ StatusCode::CONFLICT,
3737+ Json(json!({
3838+ "error": "TotpAlreadyEnabled",
3939+ "message": "TOTP is already enabled for this account"
4040+ })),
4141+ )
4242+ .into_response();
4343+ }
4444+4545+ let secret = generate_totp_secret();
4646+4747+ let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth.0.did)
4848+ .fetch_optional(&state.db)
4949+ .await;
5050+5151+ let handle = match handle {
5252+ Ok(Some(h)) => h,
5353+ Ok(None) => {
5454+ return (
5555+ StatusCode::NOT_FOUND,
5656+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
5757+ )
5858+ .into_response();
5959+ }
6060+ Err(e) => {
6161+ error!("DB error fetching handle: {:?}", e);
6262+ return (
6363+ StatusCode::INTERNAL_SERVER_ERROR,
6464+ Json(json!({"error": "InternalError"})),
6565+ )
6666+ .into_response();
6767+ }
6868+ };
6969+7070+ let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
7171+ let uri = generate_totp_uri(&secret, &handle, &hostname);
7272+7373+ let qr_code = match generate_qr_png_base64(&secret, &handle, &hostname) {
7474+ Ok(qr) => qr,
7575+ Err(e) => {
7676+ error!("Failed to generate QR code: {:?}", e);
7777+ return (
7878+ StatusCode::INTERNAL_SERVER_ERROR,
7979+ Json(json!({"error": "InternalError", "message": "Failed to generate QR code"})),
8080+ )
8181+ .into_response();
8282+ }
8383+ };
8484+8585+ let encrypted_secret = match encrypt_totp_secret(&secret) {
8686+ Ok(enc) => enc,
8787+ Err(e) => {
8888+ error!("Failed to encrypt TOTP secret: {:?}", e);
8989+ return (
9090+ StatusCode::INTERNAL_SERVER_ERROR,
9191+ Json(json!({"error": "InternalError"})),
9292+ )
9393+ .into_response();
9494+ }
9595+ };
9696+9797+ let result = sqlx::query!(
9898+ r#"
9999+ INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)
100100+ VALUES ($1, $2, $3, false, NOW())
101101+ ON CONFLICT (did) DO UPDATE SET
102102+ secret_encrypted = $2,
103103+ encryption_version = $3,
104104+ verified = false,
105105+ created_at = NOW(),
106106+ last_used = NULL
107107+ "#,
108108+ auth.0.did,
109109+ encrypted_secret,
110110+ ENCRYPTION_VERSION
111111+ )
112112+ .execute(&state.db)
113113+ .await;
114114+115115+ if let Err(e) = result {
116116+ error!("Failed to store TOTP secret: {:?}", e);
117117+ return (
118118+ StatusCode::INTERNAL_SERVER_ERROR,
119119+ Json(json!({"error": "InternalError"})),
120120+ )
121121+ .into_response();
122122+ }
123123+124124+ let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
125125+126126+ info!(did = %auth.0.did, "TOTP secret created (pending verification)");
127127+128128+ Json(CreateTotpSecretResponse {
129129+ secret: secret_base32,
130130+ uri,
131131+ qr_base64: qr_code,
132132+ })
133133+ .into_response()
134134+}
135135+136136+#[derive(Deserialize)]
137137+pub struct EnableTotpInput {
138138+ pub code: String,
139139+}
140140+141141+#[derive(Serialize)]
142142+#[serde(rename_all = "camelCase")]
143143+pub struct EnableTotpResponse {
144144+ pub backup_codes: Vec<String>,
145145+}
146146+147147+pub async fn enable_totp(
148148+ State(state): State<AppState>,
149149+ auth: BearerAuth,
150150+ Json(input): Json<EnableTotpInput>,
151151+) -> Response {
152152+ let totp_row = sqlx::query!(
153153+ "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
154154+ auth.0.did
155155+ )
156156+ .fetch_optional(&state.db)
157157+ .await;
158158+159159+ let totp_row = match totp_row {
160160+ Ok(Some(row)) => row,
161161+ Ok(None) => {
162162+ return (
163163+ StatusCode::BAD_REQUEST,
164164+ Json(json!({
165165+ "error": "TotpNotSetup",
166166+ "message": "Please call createTotpSecret first"
167167+ })),
168168+ )
169169+ .into_response();
170170+ }
171171+ Err(e) => {
172172+ error!("DB error fetching TOTP: {:?}", e);
173173+ return (
174174+ StatusCode::INTERNAL_SERVER_ERROR,
175175+ Json(json!({"error": "InternalError"})),
176176+ )
177177+ .into_response();
178178+ }
179179+ };
180180+181181+ if totp_row.verified {
182182+ return (
183183+ StatusCode::CONFLICT,
184184+ Json(json!({
185185+ "error": "TotpAlreadyEnabled",
186186+ "message": "TOTP is already enabled"
187187+ })),
188188+ )
189189+ .into_response();
190190+ }
191191+192192+ let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
193193+ {
194194+ Ok(s) => s,
195195+ Err(e) => {
196196+ error!("Failed to decrypt TOTP secret: {:?}", e);
197197+ return (
198198+ StatusCode::INTERNAL_SERVER_ERROR,
199199+ Json(json!({"error": "InternalError"})),
200200+ )
201201+ .into_response();
202202+ }
203203+ };
204204+205205+ let code = input.code.trim();
206206+ if !verify_totp_code(&secret, code) {
207207+ return (
208208+ StatusCode::UNAUTHORIZED,
209209+ Json(json!({
210210+ "error": "InvalidCode",
211211+ "message": "Invalid verification code"
212212+ })),
213213+ )
214214+ .into_response();
215215+ }
216216+217217+ let backup_codes = generate_backup_codes();
218218+ let mut tx = match state.db.begin().await {
219219+ Ok(tx) => tx,
220220+ Err(e) => {
221221+ error!("Failed to begin transaction: {:?}", e);
222222+ return (
223223+ StatusCode::INTERNAL_SERVER_ERROR,
224224+ Json(json!({"error": "InternalError"})),
225225+ )
226226+ .into_response();
227227+ }
228228+ };
229229+230230+ if let Err(e) = sqlx::query!(
231231+ "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1",
232232+ auth.0.did
233233+ )
234234+ .execute(&mut *tx)
235235+ .await
236236+ {
237237+ error!("Failed to enable TOTP: {:?}", e);
238238+ return (
239239+ StatusCode::INTERNAL_SERVER_ERROR,
240240+ Json(json!({"error": "InternalError"})),
241241+ )
242242+ .into_response();
243243+ }
244244+245245+ if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
246246+ .execute(&mut *tx)
247247+ .await
248248+ {
249249+ error!("Failed to clear old backup codes: {:?}", e);
250250+ return (
251251+ StatusCode::INTERNAL_SERVER_ERROR,
252252+ Json(json!({"error": "InternalError"})),
253253+ )
254254+ .into_response();
255255+ }
256256+257257+ for code in &backup_codes {
258258+ let hash = match hash_backup_code(code) {
259259+ Ok(h) => h,
260260+ Err(e) => {
261261+ error!("Failed to hash backup code: {:?}", e);
262262+ return (
263263+ StatusCode::INTERNAL_SERVER_ERROR,
264264+ Json(json!({"error": "InternalError"})),
265265+ )
266266+ .into_response();
267267+ }
268268+ };
269269+270270+ if let Err(e) = sqlx::query!(
271271+ "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
272272+ auth.0.did,
273273+ hash
274274+ )
275275+ .execute(&mut *tx)
276276+ .await
277277+ {
278278+ error!("Failed to store backup code: {:?}", e);
279279+ return (
280280+ StatusCode::INTERNAL_SERVER_ERROR,
281281+ Json(json!({"error": "InternalError"})),
282282+ )
283283+ .into_response();
284284+ }
285285+ }
286286+287287+ if let Err(e) = tx.commit().await {
288288+ error!("Failed to commit transaction: {:?}", e);
289289+ return (
290290+ StatusCode::INTERNAL_SERVER_ERROR,
291291+ Json(json!({"error": "InternalError"})),
292292+ )
293293+ .into_response();
294294+ }
295295+296296+ info!(did = %auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
297297+298298+ Json(EnableTotpResponse { backup_codes }).into_response()
299299+}
300300+301301+#[derive(Deserialize)]
302302+pub struct DisableTotpInput {
303303+ pub password: String,
304304+ pub code: String,
305305+}
306306+307307+pub async fn disable_totp(
308308+ State(state): State<AppState>,
309309+ auth: BearerAuth,
310310+ Json(input): Json<DisableTotpInput>,
311311+) -> Response {
312312+ let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
313313+ .fetch_optional(&state.db)
314314+ .await;
315315+316316+ let password_hash = match user {
317317+ Ok(Some(row)) => row.password_hash,
318318+ Ok(None) => {
319319+ return (
320320+ StatusCode::NOT_FOUND,
321321+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
322322+ )
323323+ .into_response();
324324+ }
325325+ Err(e) => {
326326+ error!("DB error fetching user: {:?}", e);
327327+ return (
328328+ StatusCode::INTERNAL_SERVER_ERROR,
329329+ Json(json!({"error": "InternalError"})),
330330+ )
331331+ .into_response();
332332+ }
333333+ };
334334+335335+ let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
336336+ if !password_valid {
337337+ return (
338338+ StatusCode::UNAUTHORIZED,
339339+ Json(json!({
340340+ "error": "InvalidPassword",
341341+ "message": "Password is incorrect"
342342+ })),
343343+ )
344344+ .into_response();
345345+ }
346346+347347+ let totp_row = sqlx::query!(
348348+ "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
349349+ auth.0.did
350350+ )
351351+ .fetch_optional(&state.db)
352352+ .await;
353353+354354+ let totp_row = match totp_row {
355355+ Ok(Some(row)) if row.verified => row,
356356+ Ok(Some(_)) | Ok(None) => {
357357+ return (
358358+ StatusCode::BAD_REQUEST,
359359+ Json(json!({
360360+ "error": "TotpNotEnabled",
361361+ "message": "TOTP is not enabled for this account"
362362+ })),
363363+ )
364364+ .into_response();
365365+ }
366366+ Err(e) => {
367367+ error!("DB error fetching TOTP: {:?}", e);
368368+ return (
369369+ StatusCode::INTERNAL_SERVER_ERROR,
370370+ Json(json!({"error": "InternalError"})),
371371+ )
372372+ .into_response();
373373+ }
374374+ };
375375+376376+ let code = input.code.trim();
377377+ let code_valid = if is_backup_code_format(code) {
378378+ verify_backup_code_for_user(&state, &auth.0.did, code).await
379379+ } else {
380380+ let secret =
381381+ match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) {
382382+ Ok(s) => s,
383383+ Err(e) => {
384384+ error!("Failed to decrypt TOTP secret: {:?}", e);
385385+ return (
386386+ StatusCode::INTERNAL_SERVER_ERROR,
387387+ Json(json!({"error": "InternalError"})),
388388+ )
389389+ .into_response();
390390+ }
391391+ };
392392+ verify_totp_code(&secret, code)
393393+ };
394394+395395+ if !code_valid {
396396+ return (
397397+ StatusCode::UNAUTHORIZED,
398398+ Json(json!({
399399+ "error": "InvalidCode",
400400+ "message": "Invalid verification code"
401401+ })),
402402+ )
403403+ .into_response();
404404+ }
405405+406406+ let mut tx = match state.db.begin().await {
407407+ Ok(tx) => tx,
408408+ Err(e) => {
409409+ error!("Failed to begin transaction: {:?}", e);
410410+ return (
411411+ StatusCode::INTERNAL_SERVER_ERROR,
412412+ Json(json!({"error": "InternalError"})),
413413+ )
414414+ .into_response();
415415+ }
416416+ };
417417+418418+ if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did)
419419+ .execute(&mut *tx)
420420+ .await
421421+ {
422422+ error!("Failed to delete TOTP: {:?}", e);
423423+ return (
424424+ StatusCode::INTERNAL_SERVER_ERROR,
425425+ Json(json!({"error": "InternalError"})),
426426+ )
427427+ .into_response();
428428+ }
429429+430430+ if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
431431+ .execute(&mut *tx)
432432+ .await
433433+ {
434434+ error!("Failed to delete backup codes: {:?}", e);
435435+ return (
436436+ StatusCode::INTERNAL_SERVER_ERROR,
437437+ Json(json!({"error": "InternalError"})),
438438+ )
439439+ .into_response();
440440+ }
441441+442442+ if let Err(e) = tx.commit().await {
443443+ error!("Failed to commit transaction: {:?}", e);
444444+ return (
445445+ StatusCode::INTERNAL_SERVER_ERROR,
446446+ Json(json!({"error": "InternalError"})),
447447+ )
448448+ .into_response();
449449+ }
450450+451451+ info!(did = %auth.0.did, "TOTP disabled");
452452+453453+ (StatusCode::OK, Json(json!({}))).into_response()
454454+}
455455+456456+#[derive(Serialize)]
457457+#[serde(rename_all = "camelCase")]
458458+pub struct GetTotpStatusResponse {
459459+ pub enabled: bool,
460460+ pub has_backup_codes: bool,
461461+ pub backup_codes_remaining: i64,
462462+}
463463+464464+pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
465465+ let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
466466+ .fetch_optional(&state.db)
467467+ .await;
468468+469469+ let enabled = match totp_row {
470470+ Ok(Some(row)) => row.verified,
471471+ Ok(None) => false,
472472+ Err(e) => {
473473+ error!("DB error fetching TOTP status: {:?}", e);
474474+ return (
475475+ StatusCode::INTERNAL_SERVER_ERROR,
476476+ Json(json!({"error": "InternalError"})),
477477+ )
478478+ .into_response();
479479+ }
480480+ };
481481+482482+ let backup_count_row = sqlx::query!(
483483+ "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
484484+ auth.0.did
485485+ )
486486+ .fetch_one(&state.db)
487487+ .await;
488488+489489+ let backup_count = backup_count_row.map(|r| r.count.unwrap_or(0)).unwrap_or(0);
490490+491491+ Json(GetTotpStatusResponse {
492492+ enabled,
493493+ has_backup_codes: backup_count > 0,
494494+ backup_codes_remaining: backup_count,
495495+ })
496496+ .into_response()
497497+}
498498+499499+#[derive(Deserialize)]
500500+pub struct RegenerateBackupCodesInput {
501501+ pub password: String,
502502+ pub code: String,
503503+}
504504+505505+#[derive(Serialize)]
506506+#[serde(rename_all = "camelCase")]
507507+pub struct RegenerateBackupCodesResponse {
508508+ pub backup_codes: Vec<String>,
509509+}
510510+511511+pub async fn regenerate_backup_codes(
512512+ State(state): State<AppState>,
513513+ auth: BearerAuth,
514514+ Json(input): Json<RegenerateBackupCodesInput>,
515515+) -> Response {
516516+ let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
517517+ .fetch_optional(&state.db)
518518+ .await;
519519+520520+ let password_hash = match user {
521521+ Ok(Some(row)) => row.password_hash,
522522+ Ok(None) => {
523523+ return (
524524+ StatusCode::NOT_FOUND,
525525+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
526526+ )
527527+ .into_response();
528528+ }
529529+ Err(e) => {
530530+ error!("DB error fetching user: {:?}", e);
531531+ return (
532532+ StatusCode::INTERNAL_SERVER_ERROR,
533533+ Json(json!({"error": "InternalError"})),
534534+ )
535535+ .into_response();
536536+ }
537537+ };
538538+539539+ let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
540540+ if !password_valid {
541541+ return (
542542+ StatusCode::UNAUTHORIZED,
543543+ Json(json!({
544544+ "error": "InvalidPassword",
545545+ "message": "Password is incorrect"
546546+ })),
547547+ )
548548+ .into_response();
549549+ }
550550+551551+ let totp_row = sqlx::query!(
552552+ "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
553553+ auth.0.did
554554+ )
555555+ .fetch_optional(&state.db)
556556+ .await;
557557+558558+ let totp_row = match totp_row {
559559+ Ok(Some(row)) if row.verified => row,
560560+ Ok(Some(_)) | Ok(None) => {
561561+ return (
562562+ StatusCode::BAD_REQUEST,
563563+ Json(json!({
564564+ "error": "TotpNotEnabled",
565565+ "message": "TOTP must be enabled to regenerate backup codes"
566566+ })),
567567+ )
568568+ .into_response();
569569+ }
570570+ Err(e) => {
571571+ error!("DB error fetching TOTP: {:?}", e);
572572+ return (
573573+ StatusCode::INTERNAL_SERVER_ERROR,
574574+ Json(json!({"error": "InternalError"})),
575575+ )
576576+ .into_response();
577577+ }
578578+ };
579579+580580+ let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
581581+ {
582582+ Ok(s) => s,
583583+ Err(e) => {
584584+ error!("Failed to decrypt TOTP secret: {:?}", e);
585585+ return (
586586+ StatusCode::INTERNAL_SERVER_ERROR,
587587+ Json(json!({"error": "InternalError"})),
588588+ )
589589+ .into_response();
590590+ }
591591+ };
592592+593593+ let code = input.code.trim();
594594+ if !verify_totp_code(&secret, code) {
595595+ return (
596596+ StatusCode::UNAUTHORIZED,
597597+ Json(json!({
598598+ "error": "InvalidCode",
599599+ "message": "Invalid verification code"
600600+ })),
601601+ )
602602+ .into_response();
603603+ }
604604+605605+ let backup_codes = generate_backup_codes();
606606+ let mut tx = match state.db.begin().await {
607607+ Ok(tx) => tx,
608608+ Err(e) => {
609609+ error!("Failed to begin transaction: {:?}", e);
610610+ return (
611611+ StatusCode::INTERNAL_SERVER_ERROR,
612612+ Json(json!({"error": "InternalError"})),
613613+ )
614614+ .into_response();
615615+ }
616616+ };
617617+618618+ if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
619619+ .execute(&mut *tx)
620620+ .await
621621+ {
622622+ error!("Failed to clear old backup codes: {:?}", e);
623623+ return (
624624+ StatusCode::INTERNAL_SERVER_ERROR,
625625+ Json(json!({"error": "InternalError"})),
626626+ )
627627+ .into_response();
628628+ }
629629+630630+ for code in &backup_codes {
631631+ let hash = match hash_backup_code(code) {
632632+ Ok(h) => h,
633633+ Err(e) => {
634634+ error!("Failed to hash backup code: {:?}", e);
635635+ return (
636636+ StatusCode::INTERNAL_SERVER_ERROR,
637637+ Json(json!({"error": "InternalError"})),
638638+ )
639639+ .into_response();
640640+ }
641641+ };
642642+643643+ if let Err(e) = sqlx::query!(
644644+ "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
645645+ auth.0.did,
646646+ hash
647647+ )
648648+ .execute(&mut *tx)
649649+ .await
650650+ {
651651+ error!("Failed to store backup code: {:?}", e);
652652+ return (
653653+ StatusCode::INTERNAL_SERVER_ERROR,
654654+ Json(json!({"error": "InternalError"})),
655655+ )
656656+ .into_response();
657657+ }
658658+ }
659659+660660+ if let Err(e) = tx.commit().await {
661661+ error!("Failed to commit transaction: {:?}", e);
662662+ return (
663663+ StatusCode::INTERNAL_SERVER_ERROR,
664664+ Json(json!({"error": "InternalError"})),
665665+ )
666666+ .into_response();
667667+ }
668668+669669+ info!(did = %auth.0.did, "Backup codes regenerated");
670670+671671+ Json(RegenerateBackupCodesResponse { backup_codes }).into_response()
672672+}
673673+674674+async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> bool {
675675+ let code = code.trim().to_uppercase();
676676+677677+ let backup_codes = sqlx::query!(
678678+ "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL",
679679+ did
680680+ )
681681+ .fetch_all(&state.db)
682682+ .await;
683683+684684+ let backup_codes = match backup_codes {
685685+ Ok(codes) => codes,
686686+ Err(e) => {
687687+ warn!("Failed to fetch backup codes: {:?}", e);
688688+ return false;
689689+ }
690690+ };
691691+692692+ for row in backup_codes {
693693+ if verify_backup_code(&code, &row.code_hash) {
694694+ let _ = sqlx::query!(
695695+ "UPDATE backup_codes SET used_at = $1 WHERE id = $2",
696696+ Utc::now(),
697697+ row.id
698698+ )
699699+ .execute(&state.db)
700700+ .await;
701701+ return true;
702702+ }
703703+ }
704704+705705+ false
706706+}
707707+708708+pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool {
709709+ let code = code.trim();
710710+711711+ if is_backup_code_format(code) {
712712+ return verify_backup_code_for_user(state, did, code).await;
713713+ }
714714+715715+ let totp_row = sqlx::query!(
716716+ "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
717717+ did
718718+ )
719719+ .fetch_optional(&state.db)
720720+ .await;
721721+722722+ let totp_row = match totp_row {
723723+ Ok(Some(row)) if row.verified => row,
724724+ _ => return false,
725725+ };
726726+727727+ let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
728728+ {
729729+ Ok(s) => s,
730730+ Err(_) => return false,
731731+ };
732732+733733+ if verify_totp_code(&secret, code) {
734734+ let _ = sqlx::query!("UPDATE user_totp SET last_used = NOW() WHERE did = $1", did)
735735+ .execute(&state.db)
736736+ .await;
737737+ return true;
738738+ }
739739+740740+ false
741741+}
742742+743743+pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool {
744744+ let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
745745+ .fetch_optional(&state.db)
746746+ .await;
747747+748748+ matches!(result, Ok(Some(true)))
749749+}
+2
src/auth/mod.rs
···1111pub mod scope_check;
1212pub mod service;
1313pub mod token;
1414+pub mod totp;
1415pub mod verify;
1616+pub mod webauthn;
15171618pub use extractor::{
1719 AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,