Our Personal Data Server from scratch! tranquil.farm
atproto pds rust postgresql fun oauth

refactor(api): update password, reauth, verify, account_status, and totp endpoints #85

merged opened by oyster.cafe targeting main from refactor/api
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhi3qdcw6p22
+205 -327
Diff #0
+81 -104
crates/tranquil-api/src/server/account_status.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - http::StatusCode, 5 - response::{IntoResponse, Response}, 6 - }; 1 + use axum::{Json, extract::State}; 7 2 use backon::{ExponentialBuilder, Retryable}; 8 - use bcrypt::verify; 9 3 use chrono::{Duration, Utc}; 10 4 use cid::Cid; 11 5 use jacquard_repo::commit::Commit; 12 6 use jacquard_repo::storage::BlockStore; 13 7 use k256::ecdsa::SigningKey; 14 8 use serde::{Deserialize, Serialize}; 9 + use serde_json::Value; 15 10 use std::str::FromStr; 16 11 use std::sync::Arc; 17 12 use std::sync::atomic::{AtomicUsize, Ordering}; ··· 20 15 use tranquil_pds::api::error::{ApiError, DbResultExt}; 21 16 use tranquil_pds::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa}; 22 17 use tranquil_pds::cache::Cache; 18 + use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr}; 23 19 use tranquil_pds::plc::PlcClient; 24 20 use tranquil_pds::state::AppState; 25 21 use tranquil_pds::types::PlainPassword; ··· 42 38 pub async fn check_account_status( 43 39 State(state): State<AppState>, 44 40 auth: Auth<Permissive>, 45 - ) -> Result<Response, ApiError> { 41 + ) -> Result<Json<CheckAccountStatusOutput>, ApiError> { 46 42 let did = &auth.did; 47 43 let user_id = state 48 44 .user_repo 49 45 .get_id_by_did(did) 50 46 .await 51 - .map_err(|_| ApiError::InternalError(None))? 47 + .log_db_err("fetching user ID for account status")? 52 48 .ok_or(ApiError::InternalError(None))?; 53 49 let is_active = state 54 50 .user_repo ··· 97 93 .unwrap_or(0); 98 94 let valid_did = 99 95 is_valid_did_for_service(state.user_repo.as_ref(), state.cache.clone(), did).await; 100 - Ok(( 101 - StatusCode::OK, 102 - Json(CheckAccountStatusOutput { 103 - activated: is_active, 104 - valid_did, 105 - repo_commit: repo_commit.clone(), 106 - repo_rev, 107 - repo_blocks: block_count, 108 - indexed_records: record_count, 109 - private_state_values: 0, 110 - expected_blobs, 111 - imported_blobs, 112 - }), 113 - ) 114 - .into_response()) 96 + Ok(Json(CheckAccountStatusOutput { 97 + activated: is_active, 98 + valid_did, 99 + repo_commit: repo_commit.clone(), 100 + repo_rev, 101 + repo_blocks: block_count, 102 + indexed_records: record_count, 103 + private_state_values: 0, 104 + expected_blobs, 105 + imported_blobs, 106 + })) 115 107 } 116 108 117 109 async fn is_valid_did_for_service( ··· 204 196 if let Some(ref expected_rotation_key) = server_rotation_key { 205 197 let rotation_keys = doc_data 206 198 .get("rotationKeys") 207 - .and_then(|v| v.as_array()) 208 - .map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::<Vec<_>>()) 199 + .and_then(Value::as_array) 200 + .map(|arr| arr.iter().filter_map(Value::as_str).collect::<Vec<_>>()) 209 201 .unwrap_or_default(); 210 202 if !rotation_keys.contains(&expected_rotation_key.as_str()) { 211 203 return Err(ApiError::InvalidRequest( ··· 217 209 let doc_signing_key = doc_data 218 210 .get("verificationMethods") 219 211 .and_then(|v| v.get("atproto")) 220 - .and_then(|k| k.as_str()); 212 + .and_then(Value::as_str); 221 213 222 214 let user_key = user_repo 223 215 .get_user_key_by_did(did) ··· 279 271 280 272 let pds_endpoint = doc 281 273 .get("service") 282 - .and_then(|s| s.as_array()) 274 + .and_then(Value::as_array) 283 275 .and_then(|arr| { 284 276 arr.iter().find(|svc| { 285 277 svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds") 286 - || svc.get("type").and_then(|t| t.as_str()) 278 + || svc.get("type").and_then(Value::as_str) 287 279 == Some(tranquil_pds::plc::ServiceType::Pds.as_str()) 288 280 }) 289 281 }) 290 282 .and_then(|svc| svc.get("serviceEndpoint")) 291 - .and_then(|e| e.as_str()); 283 + .and_then(Value::as_str); 292 284 293 285 if pds_endpoint != Some(&expected_endpoint) { 294 286 warn!( ··· 307 299 pub async fn activate_account( 308 300 State(state): State<AppState>, 309 301 auth: Auth<Permissive>, 310 - ) -> Result<Response, ApiError> { 302 + ) -> Result<Json<EmptyResponse>, ApiError> { 311 303 info!("[MIGRATION] activateAccount called"); 312 304 info!( 313 305 "[MIGRATION] activateAccount: Authenticated user did={}", 314 306 auth.did 315 307 ); 316 308 317 - if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope( 318 - &auth.auth_source, 319 - auth.scope.as_deref(), 320 - tranquil_pds::oauth::scopes::AccountAttr::Repo, 321 - tranquil_pds::oauth::scopes::AccountAction::Manage, 322 - ) { 323 - info!("[MIGRATION] activateAccount: Scope check failed"); 324 - return Ok(e); 325 - } 309 + auth.check_account_scope(AccountAttr::Repo, AccountAction::Manage) 310 + .inspect_err(|_| { 311 + info!("[MIGRATION] activateAccount: Scope check failed"); 312 + })?; 326 313 327 314 let did = auth.did.clone(); 328 315 ··· 460 447 ); 461 448 } 462 449 info!("[MIGRATION] activateAccount: SUCCESS for did={}", did); 463 - Ok(EmptyResponse::ok().into_response()) 450 + Ok(Json(EmptyResponse {})) 464 451 } 465 452 Err(e) => { 466 453 error!( ··· 482 469 State(state): State<AppState>, 483 470 auth: Auth<Permissive>, 484 471 Json(input): Json<DeactivateAccountInput>, 485 - ) -> Result<Response, ApiError> { 486 - if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope( 487 - &auth.auth_source, 488 - auth.scope.as_deref(), 489 - tranquil_pds::oauth::scopes::AccountAttr::Repo, 490 - tranquil_pds::oauth::scopes::AccountAction::Manage, 491 - ) { 492 - return Ok(e); 493 - } 472 + ) -> Result<Json<EmptyResponse>, ApiError> { 473 + auth.check_account_scope(AccountAttr::Repo, AccountAction::Manage)?; 494 474 495 475 let delete_after: Option<chrono::DateTime<chrono::Utc>> = input 496 476 .delete_after ··· 521 501 { 522 502 warn!("Failed to sequence account deactivated event: {}", e); 523 503 } 524 - Ok(EmptyResponse::ok().into_response()) 504 + Ok(Json(EmptyResponse {})) 525 505 } 526 - Ok(false) => Ok(EmptyResponse::ok().into_response()), 506 + Ok(false) => Ok(Json(EmptyResponse {})), 527 507 Err(e) => { 528 508 error!("DB error deactivating account: {:?}", e); 529 509 Err(ApiError::InternalError(None)) ··· 534 514 pub async fn request_account_delete( 535 515 State(state): State<AppState>, 536 516 auth: Auth<NotTakendown>, 537 - ) -> Result<Response, ApiError> { 538 - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { 539 - Ok(proof) => proof, 540 - Err(response) => return Ok(response), 541 - }; 517 + ) -> Result<Json<EmptyResponse>, ApiError> { 518 + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; 542 519 543 520 let user_id = state 544 521 .user_repo ··· 567 544 warn!("Failed to enqueue account deletion notification: {:?}", e); 568 545 } 569 546 info!("Account deletion requested for user {}", session_mfa.did()); 570 - Ok(EmptyResponse::ok().into_response()) 547 + Ok(Json(EmptyResponse {})) 571 548 } 572 549 573 550 #[derive(Deserialize)] ··· 580 557 pub async fn delete_account( 581 558 State(state): State<AppState>, 582 559 Json(input): Json<DeleteAccountInput>, 583 - ) -> Response { 560 + ) -> Result<Json<EmptyResponse>, ApiError> { 584 561 let did = &input.did; 585 562 let password = &input.password; 586 563 let token = input.token.trim(); 587 564 if password.is_empty() { 588 - return ApiError::InvalidRequest("password is required".into()).into_response(); 565 + return Err(ApiError::InvalidRequest("password is required".into())); 589 566 } 590 567 const OLD_PASSWORD_MAX_LENGTH: usize = 512; 591 568 if password.len() > OLD_PASSWORD_MAX_LENGTH { 592 - return ApiError::InvalidRequest("Invalid password length".into()).into_response(); 569 + return Err(ApiError::InvalidRequest("Invalid password length".into())); 593 570 } 594 571 if token.is_empty() { 595 - return ApiError::InvalidToken(Some("token is required".into())).into_response(); 572 + return Err(ApiError::InvalidToken(Some("token is required".into()))); 596 573 } 597 - let user = match state.user_repo.get_user_for_deletion(did).await { 598 - Ok(Some(u)) => u, 599 - Ok(None) => { 600 - return ApiError::InvalidRequest("account not found".into()).into_response(); 601 - } 602 - Err(e) => { 574 + let user = state 575 + .user_repo 576 + .get_user_for_deletion(did) 577 + .await 578 + .map_err(|e| { 603 579 error!("DB error in delete_account: {:?}", e); 604 - return ApiError::InternalError(None).into_response(); 605 - } 606 - }; 580 + ApiError::InternalError(None) 581 + })? 582 + .ok_or(ApiError::InvalidRequest("account not found".into()))?; 607 583 let (user_id, password_hash, handle) = (user.id, user.password_hash, user.handle); 608 - let password_valid = if password_hash 609 - .as_ref() 610 - .map(|h| verify(password, h).unwrap_or(false)) 611 - .unwrap_or(false) 584 + if crate::common::verify_credential( 585 + state.session_repo.as_ref(), 586 + user_id, 587 + password, 588 + password_hash.as_deref(), 589 + ) 590 + .await 591 + .is_none() 612 592 { 613 - true 614 - } else { 615 - let app_pass_hashes = state 616 - .session_repo 617 - .get_app_password_hashes_by_did(did) 618 - .await 619 - .unwrap_or_default(); 620 - app_pass_hashes 621 - .iter() 622 - .any(|h| verify(password, h).unwrap_or(false)) 623 - }; 624 - if !password_valid { 625 - return ApiError::AuthenticationFailed(Some("Invalid password".into())).into_response(); 593 + return Err(ApiError::AuthenticationFailed(Some( 594 + "Invalid password".into(), 595 + ))); 626 596 } 627 - let deletion_request = match state.infra_repo.get_deletion_request(token).await { 628 - Ok(Some(req)) => req, 629 - Ok(None) => { 630 - return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response(); 631 - } 632 - Err(e) => { 597 + let deletion_request = state 598 + .infra_repo 599 + .get_deletion_request(token) 600 + .await 601 + .map_err(|e| { 633 602 error!("DB error fetching deletion token: {:?}", e); 634 - return ApiError::InternalError(None).into_response(); 635 - } 636 - }; 603 + ApiError::InternalError(None) 604 + })? 605 + .ok_or(ApiError::InvalidToken(Some( 606 + "Invalid or expired token".into(), 607 + )))?; 637 608 if &deletion_request.did != did { 638 - return ApiError::InvalidToken(Some("Token does not match account".into())).into_response(); 609 + return Err(ApiError::InvalidToken(Some( 610 + "Token does not match account".into(), 611 + ))); 639 612 } 640 613 if Utc::now() > deletion_request.expires_at { 641 614 let _ = state.infra_repo.delete_deletion_request(token).await; 642 - return ApiError::ExpiredToken(None).into_response(); 643 - } 644 - if let Err(e) = state.user_repo.delete_account_complete(user_id, did).await { 645 - error!("DB error deleting account: {:?}", e); 646 - return ApiError::InternalError(None).into_response(); 615 + return Err(ApiError::ExpiredToken(None)); 647 616 } 617 + state 618 + .user_repo 619 + .delete_account_complete(user_id, did) 620 + .await 621 + .map_err(|e| { 622 + error!("DB error deleting account: {:?}", e); 623 + ApiError::InternalError(None) 624 + })?; 648 625 let account_seq = tranquil_pds::repo_ops::sequence_account_event( 649 626 &state, 650 627 did, ··· 672 649 .delete(&tranquil_pds::cache_keys::handle_key(&handle)) 673 650 .await; 674 651 info!("Account {} deleted successfully", did); 675 - EmptyResponse::ok().into_response() 652 + Ok(Json(EmptyResponse {})) 676 653 }
+50 -55
crates/tranquil-api/src/server/password.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - response::{IntoResponse, Response}, 5 - }; 1 + use axum::{Json, extract::State}; 6 2 use bcrypt::{DEFAULT_COST, hash}; 7 3 use chrono::{Duration, Utc}; 8 4 use serde::Deserialize; 9 5 use tracing::{error, info, warn}; 10 6 use tranquil_pds::api::error::{ApiError, DbResultExt}; 11 - use tranquil_pds::api::{EmptyResponse, HasPasswordResponse, SuccessResponse}; 7 + use tranquil_pds::api::{EmptyResponse, HasPasswordResponse, PasswordResetOutput, SuccessResponse}; 12 8 use tranquil_pds::auth::{ 13 9 Active, Auth, NormalizedLoginIdentifier, require_legacy_session_mfa, require_reauth_window, 14 10 require_reauth_window_if_available, ··· 32 28 State(state): State<AppState>, 33 29 _rate_limit: RateLimited<PasswordResetLimit>, 34 30 Json(input): Json<RequestPasswordResetInput>, 35 - ) -> Response { 31 + ) -> Result<Json<PasswordResetOutput>, ApiError> { 36 32 let identifier = input.email.trim(); 37 33 if identifier.is_empty() { 38 - return ApiError::InvalidRequest("email or handle is required".into()).into_response(); 34 + return Err(ApiError::InvalidRequest( 35 + "email or handle is required".into(), 36 + )); 39 37 } 40 38 let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 41 39 let normalized = identifier.to_lowercase(); ··· 60 58 Ok(Some(id)) => id, 61 59 Ok(None) => { 62 60 info!("Password reset requested for unknown identifier"); 63 - return Json(serde_json::json!({ "success": true })).into_response(); 61 + return Ok(Json(PasswordResetOutput { 62 + success: true, 63 + multiple_accounts: None, 64 + account_count: None, 65 + message: None, 66 + })); 64 67 } 65 68 Err(e) => { 66 69 error!("DB error in request_password_reset: {:?}", e); 67 - return ApiError::InternalError(None).into_response(); 70 + return Err(ApiError::InternalError(None)); 68 71 } 69 72 }; 70 73 let code = generate_reset_code(); ··· 75 78 .await 76 79 { 77 80 error!("DB error setting reset code: {:?}", e); 78 - return ApiError::InternalError(None).into_response(); 81 + return Err(ApiError::InternalError(None)); 79 82 } 80 83 let hostname = &tranquil_config::get().server.hostname; 81 84 if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_password_reset( ··· 92 95 info!("Password reset requested for user {}", user_id); 93 96 94 97 match multiple_accounts_warning { 95 - Some(count) => Json(serde_json::json!({ 96 - "success": true, 97 - "multipleAccounts": true, 98 - "accountCount": count, 99 - "message": "Multiple accounts share this email. Reset link sent to the most recent account. Use your handle for a specific account." 100 - })) 101 - .into_response(), 102 - None => Json(serde_json::json!({ "success": true })).into_response(), 98 + Some(count) => Ok(Json(PasswordResetOutput { 99 + success: true, 100 + multiple_accounts: Some(true), 101 + account_count: Some(count), 102 + message: Some("Multiple accounts share this email. Reset link sent to the most recent account. Use your handle for a specific account.".into()), 103 + })), 104 + None => Ok(Json(PasswordResetOutput { 105 + success: true, 106 + multiple_accounts: None, 107 + account_count: None, 108 + message: None, 109 + })), 103 110 } 104 111 } 105 112 ··· 113 120 State(state): State<AppState>, 114 121 _rate_limit: RateLimited<ResetPasswordLimit>, 115 122 Json(input): Json<ResetPasswordInput>, 116 - ) -> Response { 123 + ) -> Result<Json<EmptyResponse>, ApiError> { 117 124 let token = input.token.trim(); 118 125 let password = &input.password; 119 126 if token.is_empty() { 120 - return ApiError::InvalidToken(None).into_response(); 127 + return Err(ApiError::InvalidToken(None)); 121 128 } 122 129 if password.is_empty() { 123 - return ApiError::InvalidRequest("password is required".into()).into_response(); 130 + return Err(ApiError::InvalidRequest("password is required".into())); 124 131 } 125 132 if let Err(e) = validate_password(password) { 126 - return ApiError::InvalidRequest(e.to_string()).into_response(); 133 + return Err(ApiError::InvalidRequest(e.to_string())); 127 134 } 128 135 let user = match state.user_repo.get_user_by_reset_code(token).await { 129 136 Ok(Some(u)) => u, 130 137 Ok(None) => { 131 - return ApiError::InvalidToken(None).into_response(); 138 + return Err(ApiError::InvalidToken(None)); 132 139 } 133 140 Err(e) => { 134 141 error!("DB error in reset_password: {:?}", e); 135 - return ApiError::InternalError(None).into_response(); 142 + return Err(ApiError::InternalError(None)); 136 143 } 137 144 }; 138 145 let user_id = user.id; 139 146 let Some(exp) = user.expires_at else { 140 - return ApiError::InvalidToken(None).into_response(); 147 + return Err(ApiError::InvalidToken(None)); 141 148 }; 142 149 if Utc::now() > exp { 143 150 if let Err(e) = state.user_repo.clear_password_reset_code(user_id).await { 144 151 error!("Failed to clear expired reset code: {:?}", e); 145 152 } 146 - return ApiError::ExpiredToken(None).into_response(); 153 + return Err(ApiError::ExpiredToken(None)); 147 154 } 148 155 let password_clone = password.to_string(); 149 156 let password_hash = ··· 151 158 Ok(Ok(h)) => h, 152 159 Ok(Err(e)) => { 153 160 error!("Failed to hash password: {:?}", e); 154 - return ApiError::InternalError(None).into_response(); 161 + return Err(ApiError::InternalError(None)); 155 162 } 156 163 Err(e) => { 157 164 error!("Failed to spawn blocking task: {:?}", e); 158 - return ApiError::InternalError(None).into_response(); 165 + return Err(ApiError::InternalError(None)); 159 166 } 160 167 }; 161 168 let result = match state ··· 166 173 Ok(r) => r, 167 174 Err(e) => { 168 175 error!("Failed to reset password: {:?}", e); 169 - return ApiError::InternalError(None).into_response(); 176 + return Err(ApiError::InternalError(None)); 170 177 } 171 178 }; 172 179 futures::future::join_all(result.session_jtis.iter().map(|jti| { ··· 197 204 } 198 205 } 199 206 info!("Password reset completed for user {}", user_id); 200 - EmptyResponse::ok().into_response() 207 + Ok(Json(EmptyResponse {})) 201 208 } 202 209 203 210 #[derive(Deserialize)] ··· 211 218 State(state): State<AppState>, 212 219 auth: Auth<Active>, 213 220 Json(input): Json<ChangePasswordInput>, 214 - ) -> Result<Response, ApiError> { 221 + ) -> Result<Json<EmptyResponse>, ApiError> { 215 222 use tranquil_pds::auth::verify_password_mfa; 216 223 217 - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { 218 - Ok(proof) => proof, 219 - Err(response) => return Ok(response), 220 - }; 224 + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; 221 225 222 226 if input.current_password.is_empty() { 223 227 return Err(ApiError::InvalidRequest( ··· 259 263 .log_db_err("updating password")?; 260 264 261 265 info!(did = %session_mfa.did(), "Password changed successfully"); 262 - Ok(EmptyResponse::ok().into_response()) 266 + Ok(Json(EmptyResponse {})) 263 267 } 264 268 265 269 pub async fn get_password_status( 266 270 State(state): State<AppState>, 267 271 auth: Auth<Active>, 268 - ) -> Result<Response, ApiError> { 272 + ) -> Result<Json<HasPasswordResponse>, ApiError> { 269 273 let has = state 270 274 .user_repo 271 275 .has_password_by_did(&auth.did) 272 276 .await 273 277 .log_db_err("checking password status")? 274 278 .ok_or(ApiError::AccountNotFound)?; 275 - Ok(HasPasswordResponse::response(has).into_response()) 279 + Ok(Json(HasPasswordResponse { has_password: has })) 276 280 } 277 281 278 282 pub async fn remove_password( 279 283 State(state): State<AppState>, 280 284 auth: Auth<Active>, 281 - ) -> Result<Response, ApiError> { 282 - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { 283 - Ok(proof) => proof, 284 - Err(response) => return Ok(response), 285 - }; 285 + ) -> Result<Json<SuccessResponse>, ApiError> { 286 + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; 286 287 287 - let reauth_mfa = match require_reauth_window(&state, &auth).await { 288 - Ok(proof) => proof, 289 - Err(response) => return Ok(response), 290 - }; 288 + let reauth_mfa = require_reauth_window(&state, &auth).await?; 291 289 292 290 let has_passkeys = state 293 291 .user_repo ··· 320 318 .log_db_err("removing password")?; 321 319 322 320 info!(did = %session_mfa.did(), "Password removed - account is now passkey-only"); 323 - Ok(SuccessResponse::ok().into_response()) 321 + Ok(Json(SuccessResponse { success: true })) 324 322 } 325 323 326 324 #[derive(Deserialize)] ··· 333 331 State(state): State<AppState>, 334 332 auth: Auth<Active>, 335 333 Json(input): Json<SetPasswordInput>, 336 - ) -> Result<Response, ApiError> { 337 - let reauth_mfa = match require_reauth_window_if_available(&state, &auth).await { 338 - Ok(proof) => proof, 339 - Err(response) => return Ok(response), 340 - }; 334 + ) -> Result<Json<SuccessResponse>, ApiError> { 335 + let reauth_mfa = require_reauth_window_if_available(&state, &auth).await?; 341 336 342 337 let new_password = &input.new_password; 343 338 if new_password.is_empty() { ··· 381 376 .log_db_err("setting password")?; 382 377 383 378 info!(did = %did, "Password set for passkey-only account"); 384 - Ok(SuccessResponse::ok().into_response()) 379 + Ok(Json(SuccessResponse { success: true })) 385 380 }
+24 -89
crates/tranquil-api/src/server/reauth.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - http::StatusCode, 5 - response::{IntoResponse, Response}, 6 - }; 1 + use axum::{Json, extract::State}; 7 2 use chrono::{DateTime, Utc}; 8 3 use serde::{Deserialize, Serialize}; 9 4 use tracing::{error, info, warn}; ··· 27 22 28 23 #[derive(Serialize)] 29 24 #[serde(rename_all = "camelCase")] 30 - pub struct ReauthStatusResponse { 25 + pub struct ReauthStatusOutput { 31 26 pub last_reauth_at: Option<DateTime<Utc>>, 32 27 pub reauth_required: bool, 33 28 pub available_methods: Vec<ReauthMethod>, ··· 36 31 pub async fn get_reauth_status( 37 32 State(state): State<AppState>, 38 33 auth: Auth<Active>, 39 - ) -> Result<Response, ApiError> { 34 + ) -> Result<Json<ReauthStatusOutput>, ApiError> { 40 35 let last_reauth_at = state 41 36 .session_repo 42 37 .get_last_reauth_at(&auth.did) ··· 44 39 .log_db_err("getting last reauth")?; 45 40 46 41 let reauth_required = is_reauth_required(last_reauth_at); 47 - let available_methods = 48 - get_available_reauth_methods(&*state.user_repo, &*state.session_repo, &auth.did).await; 42 + let available_methods = get_available_reauth_methods(&*state.user_repo, &auth.did).await; 49 43 50 - Ok(Json(ReauthStatusResponse { 44 + Ok(Json(ReauthStatusOutput { 51 45 last_reauth_at, 52 46 reauth_required, 53 47 available_methods, 54 - }) 55 - .into_response()) 48 + })) 56 49 } 57 50 58 51 #[derive(Deserialize)] ··· 63 56 64 57 #[derive(Serialize)] 65 58 #[serde(rename_all = "camelCase")] 66 - pub struct ReauthResponse { 59 + pub struct ReauthOutput { 67 60 pub reauthed_at: DateTime<Utc>, 68 61 } 69 62 ··· 71 64 State(state): State<AppState>, 72 65 auth: Auth<Active>, 73 66 Json(input): Json<PasswordReauthInput>, 74 - ) -> Result<Response, ApiError> { 67 + ) -> Result<Json<ReauthOutput>, ApiError> { 75 68 let password_hash = state 76 69 .user_repo 77 70 .get_password_hash_by_did(&auth.did) ··· 103 96 .log_db_err("updating reauth")?; 104 97 105 98 info!(did = %&auth.did, "Re-auth successful via password"); 106 - Ok(Json(ReauthResponse { reauthed_at }).into_response()) 99 + Ok(Json(ReauthOutput { reauthed_at })) 107 100 } 108 101 109 102 #[derive(Deserialize)] ··· 116 109 State(state): State<AppState>, 117 110 auth: Auth<Active>, 118 111 Json(input): Json<TotpReauthInput>, 119 - ) -> Result<Response, ApiError> { 112 + ) -> Result<Json<ReauthOutput>, ApiError> { 120 113 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( 121 114 &state, 122 115 &auth.did, ··· 139 132 .log_db_err("updating reauth")?; 140 133 141 134 info!(did = %&auth.did, "Re-auth successful via TOTP"); 142 - Ok(Json(ReauthResponse { reauthed_at }).into_response()) 135 + Ok(Json(ReauthOutput { reauthed_at })) 143 136 } 144 137 145 138 #[derive(Serialize)] 146 139 #[serde(rename_all = "camelCase")] 147 - pub struct PasskeyReauthStartResponse { 140 + pub struct PasskeyReauthStartOutput { 148 141 pub options: serde_json::Value, 149 142 } 150 143 151 144 pub async fn reauth_passkey_start( 152 145 State(state): State<AppState>, 153 146 auth: Auth<Active>, 154 - ) -> Result<Response, ApiError> { 147 + ) -> Result<Json<PasskeyReauthStartOutput>, ApiError> { 155 148 let stored_passkeys = state 156 149 .user_repo 157 150 .get_passkeys_for_user(&auth.did) ··· 196 189 .log_db_err("saving authentication state")?; 197 190 198 191 let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); 199 - Ok(Json(PasskeyReauthStartResponse { options }).into_response()) 192 + Ok(Json(PasskeyReauthStartOutput { options })) 200 193 } 201 194 202 195 #[derive(Deserialize)] ··· 209 202 State(state): State<AppState>, 210 203 auth: Auth<Active>, 211 204 Json(input): Json<PasskeyReauthFinishInput>, 212 - ) -> Result<Response, ApiError> { 205 + ) -> Result<Json<ReauthOutput>, ApiError> { 213 206 let auth_state_json = state 214 207 .user_repo 215 208 .load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) ··· 270 263 .log_db_err("updating reauth")?; 271 264 272 265 info!(did = %&auth.did, "Re-auth successful via passkey"); 273 - Ok(Json(ReauthResponse { reauthed_at }).into_response()) 266 + Ok(Json(ReauthOutput { reauthed_at })) 274 267 } 275 268 276 269 pub async fn update_last_reauth_cached( ··· 302 295 303 296 async fn get_available_reauth_methods( 304 297 user_repo: &dyn UserRepository, 305 - _session_repo: &dyn SessionRepository, 306 298 did: &tranquil_pds::types::Did, 307 299 ) -> Vec<ReauthMethod> { 308 - let mut methods = Vec::new(); 309 - 310 300 let has_password = user_repo 311 301 .get_password_hash_by_did(did) 312 302 .await 313 303 .ok() 314 304 .flatten() 315 305 .is_some(); 316 - 317 - if has_password { 318 - methods.push(ReauthMethod::Password); 319 - } 320 - 321 306 let has_totp = user_repo.has_totp_enabled(did).await.unwrap_or(false); 322 - if has_totp { 323 - methods.push(ReauthMethod::Totp); 324 - } 325 - 326 307 let has_passkeys = user_repo.has_passkeys(did).await.unwrap_or(false); 327 - if has_passkeys { 328 - methods.push(ReauthMethod::Passkey); 329 - } 330 308 331 - methods 309 + [ 310 + (has_password, ReauthMethod::Password), 311 + (has_totp, ReauthMethod::Totp), 312 + (has_passkeys, ReauthMethod::Passkey), 313 + ] 314 + .into_iter() 315 + .filter_map(|(enabled, method)| enabled.then_some(method)) 316 + .collect() 332 317 } 333 318 334 319 pub async fn check_reauth_required( ··· 364 349 } 365 350 } 366 351 367 - #[derive(Serialize)] 368 - #[serde(rename_all = "camelCase")] 369 - pub struct ReauthRequiredError { 370 - pub error: String, 371 - pub message: String, 372 - pub reauth_methods: Vec<ReauthMethod>, 373 - } 374 - 375 - pub async fn reauth_required_response( 376 - user_repo: &dyn UserRepository, 377 - session_repo: &dyn SessionRepository, 378 - did: &tranquil_pds::types::Did, 379 - ) -> Response { 380 - let methods = get_available_reauth_methods(user_repo, session_repo, did).await; 381 - ( 382 - StatusCode::UNAUTHORIZED, 383 - Json(ReauthRequiredError { 384 - error: "ReauthRequired".to_string(), 385 - message: "Re-authentication required for this action".to_string(), 386 - reauth_methods: methods, 387 - }), 388 - ) 389 - .into_response() 390 - } 391 - 392 352 pub async fn check_legacy_session_mfa( 393 353 session_repo: &dyn SessionRepository, 394 354 did: &tranquil_pds::types::Did, ··· 419 379 ) -> Result<(), tranquil_db_traits::DbError> { 420 380 session_repo.update_mfa_verified(did).await 421 381 } 422 - 423 - pub async fn legacy_mfa_required_response( 424 - user_repo: &dyn UserRepository, 425 - session_repo: &dyn SessionRepository, 426 - did: &tranquil_pds::types::Did, 427 - ) -> Response { 428 - let methods = get_available_reauth_methods(user_repo, session_repo, did).await; 429 - ( 430 - StatusCode::FORBIDDEN, 431 - Json(MfaVerificationRequiredError { 432 - error: "MfaVerificationRequired".to_string(), 433 - message: "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.".to_string(), 434 - reauth_methods: methods, 435 - }), 436 - ) 437 - .into_response() 438 - } 439 - 440 - #[derive(Serialize)] 441 - #[serde(rename_all = "camelCase")] 442 - pub struct MfaVerificationRequiredError { 443 - pub error: String, 444 - pub message: String, 445 - pub reauth_methods: Vec<ReauthMethod>, 446 - }
+18 -27
crates/tranquil-api/src/server/totp.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - response::{IntoResponse, Response}, 5 - }; 1 + use axum::{Json, extract::State}; 6 2 use serde::{Deserialize, Serialize}; 7 3 use tracing::{error, info, warn}; 8 4 use tranquil_pds::api::EmptyResponse; ··· 21 17 22 18 #[derive(Serialize)] 23 19 #[serde(rename_all = "camelCase")] 24 - pub struct CreateTotpSecretResponse { 20 + pub struct CreateTotpSecretOutput { 25 21 pub secret: String, 26 22 pub uri: String, 27 23 pub qr_base64: String, ··· 30 26 pub async fn create_totp_secret( 31 27 State(state): State<AppState>, 32 28 auth: Auth<Active>, 33 - ) -> Result<Response, ApiError> { 29 + ) -> Result<Json<CreateTotpSecretOutput>, ApiError> { 34 30 use tranquil_db_traits::TotpRecordState; 35 31 36 32 match state.user_repo.get_totp_record_state(&auth.did).await { ··· 74 70 75 71 info!(did = %&auth.did, "TOTP secret created (pending verification)"); 76 72 77 - Ok(Json(CreateTotpSecretResponse { 73 + Ok(Json(CreateTotpSecretOutput { 78 74 secret: secret_base32, 79 75 uri, 80 76 qr_base64: qr_code, 81 - }) 82 - .into_response()) 77 + })) 83 78 } 84 79 85 80 #[derive(Deserialize)] ··· 89 84 90 85 #[derive(Serialize)] 91 86 #[serde(rename_all = "camelCase")] 92 - pub struct EnableTotpResponse { 87 + pub struct EnableTotpOutput { 93 88 pub backup_codes: Vec<String>, 94 89 } 95 90 ··· 97 92 State(state): State<AppState>, 98 93 auth: Auth<Active>, 99 94 Json(input): Json<EnableTotpInput>, 100 - ) -> Result<Response, ApiError> { 95 + ) -> Result<Json<EnableTotpOutput>, ApiError> { 101 96 use tranquil_db_traits::TotpRecordState; 102 97 103 98 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( ··· 151 146 152 147 info!(did = %&auth.did, "TOTP enabled with {} backup codes", backup_codes.len()); 153 148 154 - Ok(Json(EnableTotpResponse { backup_codes }).into_response()) 149 + Ok(Json(EnableTotpOutput { backup_codes })) 155 150 } 156 151 157 152 #[derive(Deserialize)] ··· 164 159 State(state): State<AppState>, 165 160 auth: Auth<Active>, 166 161 Json(input): Json<DisableTotpInput>, 167 - ) -> Result<Response, ApiError> { 168 - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { 169 - Ok(proof) => proof, 170 - Err(response) => return Ok(response), 171 - }; 162 + ) -> Result<Json<EmptyResponse>, ApiError> { 163 + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; 172 164 173 165 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( 174 166 &state, ··· 190 182 191 183 info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method()); 192 184 193 - Ok(EmptyResponse::ok().into_response()) 185 + Ok(Json(EmptyResponse {})) 194 186 } 195 187 196 188 #[derive(Serialize)] 197 189 #[serde(rename_all = "camelCase")] 198 - pub struct GetTotpStatusResponse { 190 + pub struct GetTotpStatusOutput { 199 191 pub enabled: bool, 200 192 pub has_backup_codes: bool, 201 193 pub backup_codes_remaining: i64, ··· 204 196 pub async fn get_totp_status( 205 197 State(state): State<AppState>, 206 198 auth: Auth<Active>, 207 - ) -> Result<Response, ApiError> { 199 + ) -> Result<Json<GetTotpStatusOutput>, ApiError> { 208 200 use tranquil_db_traits::TotpRecordState; 209 201 210 202 let enabled = match state.user_repo.get_totp_record_state(&auth.did).await { ··· 222 214 .await 223 215 .log_db_err("counting backup codes")?; 224 216 225 - Ok(Json(GetTotpStatusResponse { 217 + Ok(Json(GetTotpStatusOutput { 226 218 enabled, 227 219 has_backup_codes: backup_count > 0, 228 220 backup_codes_remaining: backup_count, 229 - }) 230 - .into_response()) 221 + })) 231 222 } 232 223 233 224 #[derive(Deserialize)] ··· 238 229 239 230 #[derive(Serialize)] 240 231 #[serde(rename_all = "camelCase")] 241 - pub struct RegenerateBackupCodesResponse { 232 + pub struct RegenerateBackupCodesOutput { 242 233 pub backup_codes: Vec<String>, 243 234 } 244 235 ··· 246 237 State(state): State<AppState>, 247 238 auth: Auth<Active>, 248 239 Json(input): Json<RegenerateBackupCodesInput>, 249 - ) -> Result<Response, ApiError> { 240 + ) -> Result<Json<RegenerateBackupCodesOutput>, ApiError> { 250 241 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( 251 242 &state, 252 243 &auth.did, ··· 275 266 276 267 info!(did = %password_mfa.did(), "Backup codes regenerated (verified via {} and {})", password_mfa.method(), totp_mfa.method()); 277 268 278 - Ok(Json(RegenerateBackupCodesResponse { backup_codes }).into_response()) 269 + Ok(Json(RegenerateBackupCodesOutput { backup_codes })) 279 270 } 280 271 281 272 async fn verify_backup_code_for_user(
+32 -52
crates/tranquil-api/src/server/verify_token.rs
··· 1 - use axum::{Json, extract::State}; 1 + use crate::common; 2 + use axum::{ 3 + Json, 4 + extract::State, 5 + response::{IntoResponse, Response}, 6 + }; 2 7 use serde::{Deserialize, Serialize}; 3 8 use tracing::{info, warn}; 9 + use tranquil_pds::api::SuccessResponse; 4 10 use tranquil_pds::api::error::{ApiError, DbResultExt}; 5 11 use tranquil_pds::comms::comms_repo; 6 12 use tranquil_pds::types::Did; ··· 92 98 .log_db_err("updating email_verified status")?; 93 99 } 94 100 } 95 - CommsChannel::Discord => { 96 - state 97 - .user_repo 98 - .set_discord_verified_flag(user.id) 99 - .await 100 - .log_db_err("updating discord verified status")?; 101 - } 102 - CommsChannel::Telegram => { 103 - state 104 - .user_repo 105 - .set_telegram_verified_flag(user.id) 106 - .await 107 - .log_db_err("updating telegram verified status")?; 108 - } 109 - CommsChannel::Signal => { 110 - state 111 - .user_repo 112 - .set_signal_verified_flag(user.id) 113 - .await 114 - .log_db_err("updating signal verified status")?; 115 - } 101 + _ => common::set_channel_verified_flag(state.user_repo.as_ref(), user.id, channel).await?, 116 102 }; 117 103 118 104 info!(did = %did, channel = ?channel, "Migration verification completed successfully"); ··· 239 225 })); 240 226 } 241 227 242 - match channel { 243 - CommsChannel::Email => { 244 - state 245 - .user_repo 246 - .set_email_verified_flag(user.id) 247 - .await 248 - .log_db_err("updating email verified status")?; 249 - } 250 - CommsChannel::Discord => { 251 - state 252 - .user_repo 253 - .set_discord_verified_flag(user.id) 254 - .await 255 - .log_db_err("updating discord verified status")?; 256 - } 257 - CommsChannel::Telegram => { 258 - state 259 - .user_repo 260 - .set_telegram_verified_flag(user.id) 261 - .await 262 - .log_db_err("updating telegram verified status")?; 263 - } 264 - CommsChannel::Signal => { 265 - state 266 - .user_repo 267 - .set_signal_verified_flag(user.id) 268 - .await 269 - .log_db_err("updating signal verified status")?; 270 - } 271 - }; 228 + common::set_channel_verified_flag(state.user_repo.as_ref(), user.id, channel).await?; 272 229 273 230 info!(did = %did, channel = ?channel, "Signup verified successfully"); 274 231 ··· 293 250 channel, 294 251 })) 295 252 } 253 + 254 + #[derive(Deserialize)] 255 + #[serde(rename_all = "camelCase")] 256 + pub struct ConfirmChannelVerificationInput { 257 + pub channel: CommsChannel, 258 + pub identifier: String, 259 + pub code: String, 260 + } 261 + 262 + pub async fn confirm_channel_verification( 263 + State(state): State<AppState>, 264 + Json(input): Json<ConfirmChannelVerificationInput>, 265 + ) -> Response { 266 + let token_input = VerifyTokenInput { 267 + token: input.code, 268 + identifier: input.identifier, 269 + }; 270 + 271 + match verify_token_internal(&state, token_input).await { 272 + Ok(_output) => SuccessResponse::ok().into_response(), 273 + Err(e) => e.into_response(), 274 + } 275 + }

History

1 round 0 comments
sign up or login to add to the discussion
oyster.cafe submitted #0
1 commit
expand
refactor(api): update password, reauth, verify, account_status, and totp endpoints
expand 0 comments
pull request successfully merged