this repo has no description
1use crate::auth::BearerAuth;
2use 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};
7use crate::state::AppState;
8use axum::{
9 Json,
10 extract::State,
11 http::StatusCode,
12 response::{IntoResponse, Response},
13};
14use chrono::Utc;
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17use tracing::{error, info, warn};
18
19const ENCRYPTION_VERSION: i32 = 1;
20
21#[derive(Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct CreateTotpSecretResponse {
24 pub secret: String,
25 pub uri: String,
26 pub qr_base64: String,
27}
28
29pub 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)]
137pub struct EnableTotpInput {
138 pub code: String,
139}
140
141#[derive(Serialize)]
142#[serde(rename_all = "camelCase")]
143pub struct EnableTotpResponse {
144 pub backup_codes: Vec<String>,
145}
146
147pub 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)]
302pub struct DisableTotpInput {
303 pub password: String,
304 pub code: String,
305}
306
307pub 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 = password_hash
336 .as_ref()
337 .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
338 .unwrap_or(false);
339 if !password_valid {
340 return (
341 StatusCode::UNAUTHORIZED,
342 Json(json!({
343 "error": "InvalidPassword",
344 "message": "Password is incorrect"
345 })),
346 )
347 .into_response();
348 }
349
350 let totp_row = sqlx::query!(
351 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
352 auth.0.did
353 )
354 .fetch_optional(&state.db)
355 .await;
356
357 let totp_row = match totp_row {
358 Ok(Some(row)) if row.verified => row,
359 Ok(Some(_)) | Ok(None) => {
360 return (
361 StatusCode::BAD_REQUEST,
362 Json(json!({
363 "error": "TotpNotEnabled",
364 "message": "TOTP is not enabled for this account"
365 })),
366 )
367 .into_response();
368 }
369 Err(e) => {
370 error!("DB error fetching TOTP: {:?}", e);
371 return (
372 StatusCode::INTERNAL_SERVER_ERROR,
373 Json(json!({"error": "InternalError"})),
374 )
375 .into_response();
376 }
377 };
378
379 let code = input.code.trim();
380 let code_valid = if is_backup_code_format(code) {
381 verify_backup_code_for_user(&state, &auth.0.did, code).await
382 } else {
383 let secret =
384 match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) {
385 Ok(s) => s,
386 Err(e) => {
387 error!("Failed to decrypt TOTP secret: {:?}", e);
388 return (
389 StatusCode::INTERNAL_SERVER_ERROR,
390 Json(json!({"error": "InternalError"})),
391 )
392 .into_response();
393 }
394 };
395 verify_totp_code(&secret, code)
396 };
397
398 if !code_valid {
399 return (
400 StatusCode::UNAUTHORIZED,
401 Json(json!({
402 "error": "InvalidCode",
403 "message": "Invalid verification code"
404 })),
405 )
406 .into_response();
407 }
408
409 let mut tx = match state.db.begin().await {
410 Ok(tx) => tx,
411 Err(e) => {
412 error!("Failed to begin transaction: {:?}", e);
413 return (
414 StatusCode::INTERNAL_SERVER_ERROR,
415 Json(json!({"error": "InternalError"})),
416 )
417 .into_response();
418 }
419 };
420
421 if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did)
422 .execute(&mut *tx)
423 .await
424 {
425 error!("Failed to delete TOTP: {:?}", e);
426 return (
427 StatusCode::INTERNAL_SERVER_ERROR,
428 Json(json!({"error": "InternalError"})),
429 )
430 .into_response();
431 }
432
433 if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
434 .execute(&mut *tx)
435 .await
436 {
437 error!("Failed to delete backup codes: {:?}", e);
438 return (
439 StatusCode::INTERNAL_SERVER_ERROR,
440 Json(json!({"error": "InternalError"})),
441 )
442 .into_response();
443 }
444
445 if let Err(e) = tx.commit().await {
446 error!("Failed to commit transaction: {:?}", e);
447 return (
448 StatusCode::INTERNAL_SERVER_ERROR,
449 Json(json!({"error": "InternalError"})),
450 )
451 .into_response();
452 }
453
454 info!(did = %auth.0.did, "TOTP disabled");
455
456 (StatusCode::OK, Json(json!({}))).into_response()
457}
458
459#[derive(Serialize)]
460#[serde(rename_all = "camelCase")]
461pub struct GetTotpStatusResponse {
462 pub enabled: bool,
463 pub has_backup_codes: bool,
464 pub backup_codes_remaining: i64,
465}
466
467pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
468 let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
469 .fetch_optional(&state.db)
470 .await;
471
472 let enabled = match totp_row {
473 Ok(Some(row)) => row.verified,
474 Ok(None) => false,
475 Err(e) => {
476 error!("DB error fetching TOTP status: {:?}", e);
477 return (
478 StatusCode::INTERNAL_SERVER_ERROR,
479 Json(json!({"error": "InternalError"})),
480 )
481 .into_response();
482 }
483 };
484
485 let backup_count_row = sqlx::query!(
486 "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
487 auth.0.did
488 )
489 .fetch_one(&state.db)
490 .await;
491
492 let backup_count = backup_count_row.map(|r| r.count.unwrap_or(0)).unwrap_or(0);
493
494 Json(GetTotpStatusResponse {
495 enabled,
496 has_backup_codes: backup_count > 0,
497 backup_codes_remaining: backup_count,
498 })
499 .into_response()
500}
501
502#[derive(Deserialize)]
503pub struct RegenerateBackupCodesInput {
504 pub password: String,
505 pub code: String,
506}
507
508#[derive(Serialize)]
509#[serde(rename_all = "camelCase")]
510pub struct RegenerateBackupCodesResponse {
511 pub backup_codes: Vec<String>,
512}
513
514pub async fn regenerate_backup_codes(
515 State(state): State<AppState>,
516 auth: BearerAuth,
517 Json(input): Json<RegenerateBackupCodesInput>,
518) -> Response {
519 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
520 .fetch_optional(&state.db)
521 .await;
522
523 let password_hash = match user {
524 Ok(Some(row)) => row.password_hash,
525 Ok(None) => {
526 return (
527 StatusCode::NOT_FOUND,
528 Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
529 )
530 .into_response();
531 }
532 Err(e) => {
533 error!("DB error fetching user: {:?}", e);
534 return (
535 StatusCode::INTERNAL_SERVER_ERROR,
536 Json(json!({"error": "InternalError"})),
537 )
538 .into_response();
539 }
540 };
541
542 let password_valid = password_hash
543 .as_ref()
544 .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
545 .unwrap_or(false);
546 if !password_valid {
547 return (
548 StatusCode::UNAUTHORIZED,
549 Json(json!({
550 "error": "InvalidPassword",
551 "message": "Password is incorrect"
552 })),
553 )
554 .into_response();
555 }
556
557 let totp_row = sqlx::query!(
558 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
559 auth.0.did
560 )
561 .fetch_optional(&state.db)
562 .await;
563
564 let totp_row = match totp_row {
565 Ok(Some(row)) if row.verified => row,
566 Ok(Some(_)) | Ok(None) => {
567 return (
568 StatusCode::BAD_REQUEST,
569 Json(json!({
570 "error": "TotpNotEnabled",
571 "message": "TOTP must be enabled to regenerate backup codes"
572 })),
573 )
574 .into_response();
575 }
576 Err(e) => {
577 error!("DB error fetching TOTP: {:?}", e);
578 return (
579 StatusCode::INTERNAL_SERVER_ERROR,
580 Json(json!({"error": "InternalError"})),
581 )
582 .into_response();
583 }
584 };
585
586 let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
587 {
588 Ok(s) => s,
589 Err(e) => {
590 error!("Failed to decrypt TOTP secret: {:?}", e);
591 return (
592 StatusCode::INTERNAL_SERVER_ERROR,
593 Json(json!({"error": "InternalError"})),
594 )
595 .into_response();
596 }
597 };
598
599 let code = input.code.trim();
600 if !verify_totp_code(&secret, code) {
601 return (
602 StatusCode::UNAUTHORIZED,
603 Json(json!({
604 "error": "InvalidCode",
605 "message": "Invalid verification code"
606 })),
607 )
608 .into_response();
609 }
610
611 let backup_codes = generate_backup_codes();
612 let mut tx = match state.db.begin().await {
613 Ok(tx) => tx,
614 Err(e) => {
615 error!("Failed to begin transaction: {:?}", e);
616 return (
617 StatusCode::INTERNAL_SERVER_ERROR,
618 Json(json!({"error": "InternalError"})),
619 )
620 .into_response();
621 }
622 };
623
624 if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
625 .execute(&mut *tx)
626 .await
627 {
628 error!("Failed to clear old backup codes: {:?}", e);
629 return (
630 StatusCode::INTERNAL_SERVER_ERROR,
631 Json(json!({"error": "InternalError"})),
632 )
633 .into_response();
634 }
635
636 for code in &backup_codes {
637 let hash = match hash_backup_code(code) {
638 Ok(h) => h,
639 Err(e) => {
640 error!("Failed to hash backup code: {:?}", e);
641 return (
642 StatusCode::INTERNAL_SERVER_ERROR,
643 Json(json!({"error": "InternalError"})),
644 )
645 .into_response();
646 }
647 };
648
649 if let Err(e) = sqlx::query!(
650 "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
651 auth.0.did,
652 hash
653 )
654 .execute(&mut *tx)
655 .await
656 {
657 error!("Failed to store backup code: {:?}", e);
658 return (
659 StatusCode::INTERNAL_SERVER_ERROR,
660 Json(json!({"error": "InternalError"})),
661 )
662 .into_response();
663 }
664 }
665
666 if let Err(e) = tx.commit().await {
667 error!("Failed to commit transaction: {:?}", e);
668 return (
669 StatusCode::INTERNAL_SERVER_ERROR,
670 Json(json!({"error": "InternalError"})),
671 )
672 .into_response();
673 }
674
675 info!(did = %auth.0.did, "Backup codes regenerated");
676
677 Json(RegenerateBackupCodesResponse { backup_codes }).into_response()
678}
679
680async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> bool {
681 let code = code.trim().to_uppercase();
682
683 let backup_codes = sqlx::query!(
684 "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL",
685 did
686 )
687 .fetch_all(&state.db)
688 .await;
689
690 let backup_codes = match backup_codes {
691 Ok(codes) => codes,
692 Err(e) => {
693 warn!("Failed to fetch backup codes: {:?}", e);
694 return false;
695 }
696 };
697
698 for row in backup_codes {
699 if verify_backup_code(&code, &row.code_hash) {
700 let _ = sqlx::query!(
701 "UPDATE backup_codes SET used_at = $1 WHERE id = $2",
702 Utc::now(),
703 row.id
704 )
705 .execute(&state.db)
706 .await;
707 return true;
708 }
709 }
710
711 false
712}
713
714pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool {
715 let code = code.trim();
716
717 if is_backup_code_format(code) {
718 return verify_backup_code_for_user(state, did, code).await;
719 }
720
721 let totp_row = sqlx::query!(
722 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
723 did
724 )
725 .fetch_optional(&state.db)
726 .await;
727
728 let totp_row = match totp_row {
729 Ok(Some(row)) if row.verified => row,
730 _ => return false,
731 };
732
733 let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
734 {
735 Ok(s) => s,
736 Err(_) => return false,
737 };
738
739 if verify_totp_code(&secret, code) {
740 let _ = sqlx::query!("UPDATE user_totp SET last_used = NOW() WHERE did = $1", did)
741 .execute(&state.db)
742 .await;
743 return true;
744 }
745
746 false
747}
748
749pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool {
750 has_totp_enabled_db(&state.db, did).await
751}
752
753pub async fn has_totp_enabled_db(db: &sqlx::PgPool, did: &str) -> bool {
754 let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
755 .fetch_optional(db)
756 .await;
757
758 matches!(result, Ok(Some(true)))
759}