this repo has no description
1use crate::api::EmptyResponse;
2use crate::api::error::ApiError;
3use crate::auth::BearerAuth;
4use crate::auth::totp::{
5 decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
6 generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
7 verify_backup_code, verify_totp_code,
8};
9use crate::state::{AppState, RateLimitKind};
10use crate::types::PlainPassword;
11use axum::{
12 Json,
13 extract::State,
14 response::{IntoResponse, Response},
15};
16use chrono::Utc;
17use serde::{Deserialize, Serialize};
18use tracing::{error, info, warn};
19
20const ENCRYPTION_VERSION: i32 = 1;
21
22#[derive(Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct CreateTotpSecretResponse {
25 pub secret: String,
26 pub uri: String,
27 pub qr_base64: String,
28}
29
30pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response {
31 let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did)
32 .fetch_optional(&state.db)
33 .await;
34
35 if let Ok(Some(true)) = existing {
36 return ApiError::TotpAlreadyEnabled.into_response();
37 }
38
39 let secret = generate_totp_secret();
40
41 let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", &*&auth.0.did)
42 .fetch_optional(&state.db)
43 .await;
44
45 let handle = match handle {
46 Ok(Some(h)) => h,
47 Ok(None) => return ApiError::AccountNotFound.into_response(),
48 Err(e) => {
49 error!("DB error fetching handle: {:?}", e);
50 return ApiError::InternalError(None).into_response();
51 }
52 };
53
54 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
55 let uri = generate_totp_uri(&secret, &handle, &hostname);
56
57 let qr_code = match generate_qr_png_base64(&secret, &handle, &hostname) {
58 Ok(qr) => qr,
59 Err(e) => {
60 error!("Failed to generate QR code: {:?}", e);
61 return ApiError::InternalError(Some("Failed to generate QR code".into())).into_response();
62 }
63 };
64
65 let encrypted_secret = match encrypt_totp_secret(&secret) {
66 Ok(enc) => enc,
67 Err(e) => {
68 error!("Failed to encrypt TOTP secret: {:?}", e);
69 return ApiError::InternalError(None).into_response();
70 }
71 };
72
73 let result = sqlx::query!(
74 r#"
75 INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)
76 VALUES ($1, $2, $3, false, NOW())
77 ON CONFLICT (did) DO UPDATE SET
78 secret_encrypted = $2,
79 encryption_version = $3,
80 verified = false,
81 created_at = NOW(),
82 last_used = NULL
83 "#,
84 &auth.0.did,
85 encrypted_secret,
86 ENCRYPTION_VERSION
87 )
88 .execute(&state.db)
89 .await;
90
91 if let Err(e) = result {
92 error!("Failed to store TOTP secret: {:?}", e);
93 return ApiError::InternalError(None).into_response();
94 }
95
96 let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
97
98 info!(did = %&auth.0.did, "TOTP secret created (pending verification)");
99
100 Json(CreateTotpSecretResponse {
101 secret: secret_base32,
102 uri,
103 qr_base64: qr_code,
104 })
105 .into_response()
106}
107
108#[derive(Deserialize)]
109pub struct EnableTotpInput {
110 pub code: String,
111}
112
113#[derive(Serialize)]
114#[serde(rename_all = "camelCase")]
115pub struct EnableTotpResponse {
116 pub backup_codes: Vec<String>,
117}
118
119pub async fn enable_totp(
120 State(state): State<AppState>,
121 auth: BearerAuth,
122 Json(input): Json<EnableTotpInput>,
123) -> Response {
124 if !state
125 .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
126 .await
127 {
128 warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
129 return ApiError::RateLimitExceeded(None).into_response();
130 }
131
132 let totp_row = sqlx::query!(
133 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
134 &auth.0.did
135 )
136 .fetch_optional(&state.db)
137 .await;
138
139 let totp_row = match totp_row {
140 Ok(Some(row)) => row,
141 Ok(None) => return ApiError::TotpNotEnabled.into_response(),
142 Err(e) => {
143 error!("DB error fetching TOTP: {:?}", e);
144 return ApiError::InternalError(None).into_response();
145 }
146 };
147
148 if totp_row.verified {
149 return ApiError::TotpAlreadyEnabled.into_response();
150 }
151
152 let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
153 {
154 Ok(s) => s,
155 Err(e) => {
156 error!("Failed to decrypt TOTP secret: {:?}", e);
157 return ApiError::InternalError(None).into_response();
158 }
159 };
160
161 let code = input.code.trim();
162 if !verify_totp_code(&secret, code) {
163 return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
164 }
165
166 let backup_codes = generate_backup_codes();
167 let mut tx = match state.db.begin().await {
168 Ok(tx) => tx,
169 Err(e) => {
170 error!("Failed to begin transaction: {:?}", e);
171 return ApiError::InternalError(None).into_response();
172 }
173 };
174
175 if let Err(e) = sqlx::query!(
176 "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1",
177 &auth.0.did
178 )
179 .execute(&mut *tx)
180 .await
181 {
182 error!("Failed to enable TOTP: {:?}", e);
183 return ApiError::InternalError(None).into_response();
184 }
185
186 if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did)
187 .execute(&mut *tx)
188 .await
189 {
190 error!("Failed to clear old backup codes: {:?}", e);
191 return ApiError::InternalError(None).into_response();
192 }
193
194 for code in &backup_codes {
195 let hash = match hash_backup_code(code) {
196 Ok(h) => h,
197 Err(e) => {
198 error!("Failed to hash backup code: {:?}", e);
199 return ApiError::InternalError(None).into_response();
200 }
201 };
202
203 if let Err(e) = sqlx::query!(
204 "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
205 &auth.0.did,
206 hash
207 )
208 .execute(&mut *tx)
209 .await
210 {
211 error!("Failed to store backup code: {:?}", e);
212 return ApiError::InternalError(None).into_response();
213 }
214 }
215
216 if let Err(e) = tx.commit().await {
217 error!("Failed to commit transaction: {:?}", e);
218 return ApiError::InternalError(None).into_response();
219 }
220
221 info!(did = %&auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
222
223 Json(EnableTotpResponse { backup_codes }).into_response()
224}
225
226#[derive(Deserialize)]
227pub struct DisableTotpInput {
228 pub password: PlainPassword,
229 pub code: String,
230}
231
232pub async fn disable_totp(
233 State(state): State<AppState>,
234 auth: BearerAuth,
235 Json(input): Json<DisableTotpInput>,
236) -> Response {
237 if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
238 return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
239 .await;
240 }
241
242 if !state
243 .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
244 .await
245 {
246 warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
247 return ApiError::RateLimitExceeded(None).into_response();
248 }
249
250 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
251 .fetch_optional(&state.db)
252 .await;
253
254 let password_hash = match user {
255 Ok(Some(row)) => row.password_hash,
256 Ok(None) => return ApiError::AccountNotFound.into_response(),
257 Err(e) => {
258 error!("DB error fetching user: {:?}", e);
259 return ApiError::InternalError(None).into_response();
260 }
261 };
262
263 let password_valid = password_hash
264 .as_ref()
265 .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
266 .unwrap_or(false);
267 if !password_valid {
268 return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
269 }
270
271 let totp_row = sqlx::query!(
272 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
273 &auth.0.did
274 )
275 .fetch_optional(&state.db)
276 .await;
277
278 let totp_row = match totp_row {
279 Ok(Some(row)) if row.verified => row,
280 Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(),
281 Err(e) => {
282 error!("DB error fetching TOTP: {:?}", e);
283 return ApiError::InternalError(None).into_response();
284 }
285 };
286
287 let code = input.code.trim();
288 let code_valid = if is_backup_code_format(code) {
289 verify_backup_code_for_user(&state, &auth.0.did, code).await
290 } else {
291 let secret =
292 match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) {
293 Ok(s) => s,
294 Err(e) => {
295 error!("Failed to decrypt TOTP secret: {:?}", e);
296 return ApiError::InternalError(None).into_response();
297 }
298 };
299 verify_totp_code(&secret, code)
300 };
301
302 if !code_valid {
303 return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
304 }
305
306 let mut tx = match state.db.begin().await {
307 Ok(tx) => tx,
308 Err(e) => {
309 error!("Failed to begin transaction: {:?}", e);
310 return ApiError::InternalError(None).into_response();
311 }
312 };
313
314 if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", &*&auth.0.did)
315 .execute(&mut *tx)
316 .await
317 {
318 error!("Failed to delete TOTP: {:?}", e);
319 return ApiError::InternalError(None).into_response();
320 }
321
322 if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did)
323 .execute(&mut *tx)
324 .await
325 {
326 error!("Failed to delete backup codes: {:?}", e);
327 return ApiError::InternalError(None).into_response();
328 }
329
330 if let Err(e) = tx.commit().await {
331 error!("Failed to commit transaction: {:?}", e);
332 return ApiError::InternalError(None).into_response();
333 }
334
335 info!(did = %&auth.0.did, "TOTP disabled");
336
337 EmptyResponse::ok().into_response()
338}
339
340#[derive(Serialize)]
341#[serde(rename_all = "camelCase")]
342pub struct GetTotpStatusResponse {
343 pub enabled: bool,
344 pub has_backup_codes: bool,
345 pub backup_codes_remaining: i64,
346}
347
348pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
349 let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did)
350 .fetch_optional(&state.db)
351 .await;
352
353 let enabled = match totp_row {
354 Ok(Some(row)) => row.verified,
355 Ok(None) => false,
356 Err(e) => {
357 error!("DB error fetching TOTP status: {:?}", e);
358 return ApiError::InternalError(None).into_response();
359 }
360 };
361
362 let backup_count_row = sqlx::query!(
363 "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
364 &auth.0.did
365 )
366 .fetch_one(&state.db)
367 .await;
368
369 let backup_count = backup_count_row.map(|r| r.count.unwrap_or(0)).unwrap_or(0);
370
371 Json(GetTotpStatusResponse {
372 enabled,
373 has_backup_codes: backup_count > 0,
374 backup_codes_remaining: backup_count,
375 })
376 .into_response()
377}
378
379#[derive(Deserialize)]
380pub struct RegenerateBackupCodesInput {
381 pub password: PlainPassword,
382 pub code: String,
383}
384
385#[derive(Serialize)]
386#[serde(rename_all = "camelCase")]
387pub struct RegenerateBackupCodesResponse {
388 pub backup_codes: Vec<String>,
389}
390
391pub async fn regenerate_backup_codes(
392 State(state): State<AppState>,
393 auth: BearerAuth,
394 Json(input): Json<RegenerateBackupCodesInput>,
395) -> Response {
396 if !state
397 .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
398 .await
399 {
400 warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
401 return ApiError::RateLimitExceeded(None).into_response();
402 }
403
404 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
405 .fetch_optional(&state.db)
406 .await;
407
408 let password_hash = match user {
409 Ok(Some(row)) => row.password_hash,
410 Ok(None) => return ApiError::AccountNotFound.into_response(),
411 Err(e) => {
412 error!("DB error fetching user: {:?}", e);
413 return ApiError::InternalError(None).into_response();
414 }
415 };
416
417 let password_valid = password_hash
418 .as_ref()
419 .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
420 .unwrap_or(false);
421 if !password_valid {
422 return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
423 }
424
425 let totp_row = sqlx::query!(
426 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
427 &auth.0.did
428 )
429 .fetch_optional(&state.db)
430 .await;
431
432 let totp_row = match totp_row {
433 Ok(Some(row)) if row.verified => row,
434 Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(),
435 Err(e) => {
436 error!("DB error fetching TOTP: {:?}", e);
437 return ApiError::InternalError(None).into_response();
438 }
439 };
440
441 let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
442 {
443 Ok(s) => s,
444 Err(e) => {
445 error!("Failed to decrypt TOTP secret: {:?}", e);
446 return ApiError::InternalError(None).into_response();
447 }
448 };
449
450 let code = input.code.trim();
451 if !verify_totp_code(&secret, code) {
452 return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
453 }
454
455 let backup_codes = generate_backup_codes();
456 let mut tx = match state.db.begin().await {
457 Ok(tx) => tx,
458 Err(e) => {
459 error!("Failed to begin transaction: {:?}", e);
460 return ApiError::InternalError(None).into_response();
461 }
462 };
463
464 if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did)
465 .execute(&mut *tx)
466 .await
467 {
468 error!("Failed to clear old backup codes: {:?}", e);
469 return ApiError::InternalError(None).into_response();
470 }
471
472 for code in &backup_codes {
473 let hash = match hash_backup_code(code) {
474 Ok(h) => h,
475 Err(e) => {
476 error!("Failed to hash backup code: {:?}", e);
477 return ApiError::InternalError(None).into_response();
478 }
479 };
480
481 if let Err(e) = sqlx::query!(
482 "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
483 &auth.0.did,
484 hash
485 )
486 .execute(&mut *tx)
487 .await
488 {
489 error!("Failed to store backup code: {:?}", e);
490 return ApiError::InternalError(None).into_response();
491 }
492 }
493
494 if let Err(e) = tx.commit().await {
495 error!("Failed to commit transaction: {:?}", e);
496 return ApiError::InternalError(None).into_response();
497 }
498
499 info!(did = %&auth.0.did, "Backup codes regenerated");
500
501 Json(RegenerateBackupCodesResponse { backup_codes }).into_response()
502}
503
504async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> bool {
505 let code = code.trim().to_uppercase();
506
507 let backup_codes = sqlx::query!(
508 "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL",
509 did
510 )
511 .fetch_all(&state.db)
512 .await;
513
514 let backup_codes = match backup_codes {
515 Ok(codes) => codes,
516 Err(e) => {
517 warn!("Failed to fetch backup codes: {:?}", e);
518 return false;
519 }
520 };
521
522 for row in backup_codes {
523 if verify_backup_code(&code, &row.code_hash) {
524 let _ = sqlx::query!(
525 "UPDATE backup_codes SET used_at = $1 WHERE id = $2",
526 Utc::now(),
527 row.id
528 )
529 .execute(&state.db)
530 .await;
531 return true;
532 }
533 }
534
535 false
536}
537
538pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool {
539 let code = code.trim();
540
541 if is_backup_code_format(code) {
542 return verify_backup_code_for_user(state, did, code).await;
543 }
544
545 let totp_row = sqlx::query!(
546 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
547 did
548 )
549 .fetch_optional(&state.db)
550 .await;
551
552 let totp_row = match totp_row {
553 Ok(Some(row)) if row.verified => row,
554 _ => return false,
555 };
556
557 let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
558 {
559 Ok(s) => s,
560 Err(_) => return false,
561 };
562
563 if verify_totp_code(&secret, code) {
564 let _ = sqlx::query!("UPDATE user_totp SET last_used = NOW() WHERE did = $1", did)
565 .execute(&state.db)
566 .await;
567 return true;
568 }
569
570 false
571}
572
573pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool {
574 has_totp_enabled_db(&state.db, did).await
575}
576
577pub async fn has_totp_enabled_db(db: &sqlx::PgPool, did: &str) -> bool {
578 let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
579 .fetch_optional(db)
580 .await;
581
582 matches!(result, Ok(Some(true)))
583}