diff --git a/crates/tranquil-api/src/server/account_status.rs b/crates/tranquil-api/src/server/account_status.rs index 08aa708..562bfdb 100644 --- a/crates/tranquil-api/src/server/account_status.rs +++ b/crates/tranquil-api/src/server/account_status.rs @@ -1,17 +1,12 @@ -use axum::{ - Json, - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use backon::{ExponentialBuilder, Retryable}; -use bcrypt::verify; use chrono::{Duration, Utc}; use cid::Cid; use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; use k256::ecdsa::SigningKey; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::str::FromStr; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -20,6 +15,7 @@ use tranquil_pds::api::EmptyResponse; use tranquil_pds::api::error::{ApiError, DbResultExt}; use tranquil_pds::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa}; use tranquil_pds::cache::Cache; +use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr}; use tranquil_pds::plc::PlcClient; use tranquil_pds::state::AppState; use tranquil_pds::types::PlainPassword; @@ -42,13 +38,13 @@ pub struct CheckAccountStatusOutput { pub async fn check_account_status( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { let did = &auth.did; let user_id = state .user_repo .get_id_by_did(did) .await - .map_err(|_| ApiError::InternalError(None))? + .log_db_err("fetching user ID for account status")? .ok_or(ApiError::InternalError(None))?; let is_active = state .user_repo @@ -97,21 +93,17 @@ pub async fn check_account_status( .unwrap_or(0); let valid_did = is_valid_did_for_service(state.user_repo.as_ref(), state.cache.clone(), did).await; - Ok(( - StatusCode::OK, - Json(CheckAccountStatusOutput { - activated: is_active, - valid_did, - repo_commit: repo_commit.clone(), - repo_rev, - repo_blocks: block_count, - indexed_records: record_count, - private_state_values: 0, - expected_blobs, - imported_blobs, - }), - ) - .into_response()) + Ok(Json(CheckAccountStatusOutput { + activated: is_active, + valid_did, + repo_commit: repo_commit.clone(), + repo_rev, + repo_blocks: block_count, + indexed_records: record_count, + private_state_values: 0, + expected_blobs, + imported_blobs, + })) } async fn is_valid_did_for_service( @@ -204,8 +196,8 @@ async fn assert_valid_did_document_for_service( if let Some(ref expected_rotation_key) = server_rotation_key { let rotation_keys = doc_data .get("rotationKeys") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::>()) + .and_then(Value::as_array) + .map(|arr| arr.iter().filter_map(Value::as_str).collect::>()) .unwrap_or_default(); if !rotation_keys.contains(&expected_rotation_key.as_str()) { return Err(ApiError::InvalidRequest( @@ -217,7 +209,7 @@ async fn assert_valid_did_document_for_service( let doc_signing_key = doc_data .get("verificationMethods") .and_then(|v| v.get("atproto")) - .and_then(|k| k.as_str()); + .and_then(Value::as_str); let user_key = user_repo .get_user_key_by_did(did) @@ -279,16 +271,16 @@ async fn assert_valid_did_document_for_service( let pds_endpoint = doc .get("service") - .and_then(|s| s.as_array()) + .and_then(Value::as_array) .and_then(|arr| { arr.iter().find(|svc| { svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds") - || svc.get("type").and_then(|t| t.as_str()) + || svc.get("type").and_then(Value::as_str) == Some(tranquil_pds::plc::ServiceType::Pds.as_str()) }) }) .and_then(|svc| svc.get("serviceEndpoint")) - .and_then(|e| e.as_str()); + .and_then(Value::as_str); if pds_endpoint != Some(&expected_endpoint) { warn!( @@ -307,22 +299,17 @@ async fn assert_valid_did_document_for_service( pub async fn activate_account( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { info!("[MIGRATION] activateAccount called"); info!( "[MIGRATION] activateAccount: Authenticated user did={}", auth.did ); - if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope( - &auth.auth_source, - auth.scope.as_deref(), - tranquil_pds::oauth::scopes::AccountAttr::Repo, - tranquil_pds::oauth::scopes::AccountAction::Manage, - ) { - info!("[MIGRATION] activateAccount: Scope check failed"); - return Ok(e); - } + auth.check_account_scope(AccountAttr::Repo, AccountAction::Manage) + .inspect_err(|_| { + info!("[MIGRATION] activateAccount: Scope check failed"); + })?; let did = auth.did.clone(); @@ -460,7 +447,7 @@ pub async fn activate_account( ); } info!("[MIGRATION] activateAccount: SUCCESS for did={}", did); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } Err(e) => { error!( @@ -482,15 +469,8 @@ pub async fn deactivate_account( State(state): State, auth: Auth, Json(input): Json, -) -> Result { - if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope( - &auth.auth_source, - auth.scope.as_deref(), - tranquil_pds::oauth::scopes::AccountAttr::Repo, - tranquil_pds::oauth::scopes::AccountAction::Manage, - ) { - return Ok(e); - } +) -> Result, ApiError> { + auth.check_account_scope(AccountAttr::Repo, AccountAction::Manage)?; let delete_after: Option> = input .delete_after @@ -521,9 +501,9 @@ pub async fn deactivate_account( { warn!("Failed to sequence account deactivated event: {}", e); } - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } - Ok(false) => Ok(EmptyResponse::ok().into_response()), + Ok(false) => Ok(Json(EmptyResponse {})), Err(e) => { error!("DB error deactivating account: {:?}", e); Err(ApiError::InternalError(None)) @@ -534,11 +514,8 @@ pub async fn deactivate_account( pub async fn request_account_delete( State(state): State, auth: Auth, -) -> Result { - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; +) -> Result, ApiError> { + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; let user_id = state .user_repo @@ -567,7 +544,7 @@ pub async fn request_account_delete( warn!("Failed to enqueue account deletion notification: {:?}", e); } info!("Account deletion requested for user {}", session_mfa.did()); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } #[derive(Deserialize)] @@ -580,71 +557,71 @@ pub struct DeleteAccountInput { pub async fn delete_account( State(state): State, Json(input): Json, -) -> Response { +) -> Result, ApiError> { let did = &input.did; let password = &input.password; let token = input.token.trim(); if password.is_empty() { - return ApiError::InvalidRequest("password is required".into()).into_response(); + return Err(ApiError::InvalidRequest("password is required".into())); } const OLD_PASSWORD_MAX_LENGTH: usize = 512; if password.len() > OLD_PASSWORD_MAX_LENGTH { - return ApiError::InvalidRequest("Invalid password length".into()).into_response(); + return Err(ApiError::InvalidRequest("Invalid password length".into())); } if token.is_empty() { - return ApiError::InvalidToken(Some("token is required".into())).into_response(); + return Err(ApiError::InvalidToken(Some("token is required".into()))); } - let user = match state.user_repo.get_user_for_deletion(did).await { - Ok(Some(u)) => u, - Ok(None) => { - return ApiError::InvalidRequest("account not found".into()).into_response(); - } - Err(e) => { + let user = state + .user_repo + .get_user_for_deletion(did) + .await + .map_err(|e| { error!("DB error in delete_account: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; + ApiError::InternalError(None) + })? + .ok_or(ApiError::InvalidRequest("account not found".into()))?; let (user_id, password_hash, handle) = (user.id, user.password_hash, user.handle); - let password_valid = if password_hash - .as_ref() - .map(|h| verify(password, h).unwrap_or(false)) - .unwrap_or(false) + if crate::common::verify_credential( + state.session_repo.as_ref(), + user_id, + password, + password_hash.as_deref(), + ) + .await + .is_none() { - true - } else { - let app_pass_hashes = state - .session_repo - .get_app_password_hashes_by_did(did) - .await - .unwrap_or_default(); - app_pass_hashes - .iter() - .any(|h| verify(password, h).unwrap_or(false)) - }; - if !password_valid { - return ApiError::AuthenticationFailed(Some("Invalid password".into())).into_response(); + return Err(ApiError::AuthenticationFailed(Some( + "Invalid password".into(), + ))); } - let deletion_request = match state.infra_repo.get_deletion_request(token).await { - Ok(Some(req)) => req, - Ok(None) => { - return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response(); - } - Err(e) => { + let deletion_request = state + .infra_repo + .get_deletion_request(token) + .await + .map_err(|e| { error!("DB error fetching deletion token: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; + ApiError::InternalError(None) + })? + .ok_or(ApiError::InvalidToken(Some( + "Invalid or expired token".into(), + )))?; if &deletion_request.did != did { - return ApiError::InvalidToken(Some("Token does not match account".into())).into_response(); + return Err(ApiError::InvalidToken(Some( + "Token does not match account".into(), + ))); } if Utc::now() > deletion_request.expires_at { let _ = state.infra_repo.delete_deletion_request(token).await; - return ApiError::ExpiredToken(None).into_response(); - } - if let Err(e) = state.user_repo.delete_account_complete(user_id, did).await { - error!("DB error deleting account: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::ExpiredToken(None)); } + state + .user_repo + .delete_account_complete(user_id, did) + .await + .map_err(|e| { + error!("DB error deleting account: {:?}", e); + ApiError::InternalError(None) + })?; let account_seq = tranquil_pds::repo_ops::sequence_account_event( &state, did, @@ -672,5 +649,5 @@ pub async fn delete_account( .delete(&tranquil_pds::cache_keys::handle_key(&handle)) .await; info!("Account {} deleted successfully", did); - EmptyResponse::ok().into_response() + Ok(Json(EmptyResponse {})) } diff --git a/crates/tranquil-api/src/server/password.rs b/crates/tranquil-api/src/server/password.rs index 9b26613..e0e8110 100644 --- a/crates/tranquil-api/src/server/password.rs +++ b/crates/tranquil-api/src/server/password.rs @@ -1,14 +1,10 @@ -use axum::{ - Json, - extract::State, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use bcrypt::{DEFAULT_COST, hash}; use chrono::{Duration, Utc}; use serde::Deserialize; use tracing::{error, info, warn}; use tranquil_pds::api::error::{ApiError, DbResultExt}; -use tranquil_pds::api::{EmptyResponse, HasPasswordResponse, SuccessResponse}; +use tranquil_pds::api::{EmptyResponse, HasPasswordResponse, PasswordResetOutput, SuccessResponse}; use tranquil_pds::auth::{ Active, Auth, NormalizedLoginIdentifier, require_legacy_session_mfa, require_reauth_window, require_reauth_window_if_available, @@ -32,10 +28,12 @@ pub async fn request_password_reset( State(state): State, _rate_limit: RateLimited, Json(input): Json, -) -> Response { +) -> Result, ApiError> { let identifier = input.email.trim(); if identifier.is_empty() { - return ApiError::InvalidRequest("email or handle is required".into()).into_response(); + return Err(ApiError::InvalidRequest( + "email or handle is required".into(), + )); } let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let normalized = identifier.to_lowercase(); @@ -60,11 +58,16 @@ pub async fn request_password_reset( Ok(Some(id)) => id, Ok(None) => { info!("Password reset requested for unknown identifier"); - return Json(serde_json::json!({ "success": true })).into_response(); + return Ok(Json(PasswordResetOutput { + success: true, + multiple_accounts: None, + account_count: None, + message: None, + })); } Err(e) => { error!("DB error in request_password_reset: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; let code = generate_reset_code(); @@ -75,7 +78,7 @@ pub async fn request_password_reset( .await { error!("DB error setting reset code: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } let hostname = &tranquil_config::get().server.hostname; if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_password_reset( @@ -92,14 +95,18 @@ pub async fn request_password_reset( info!("Password reset requested for user {}", user_id); match multiple_accounts_warning { - Some(count) => Json(serde_json::json!({ - "success": true, - "multipleAccounts": true, - "accountCount": count, - "message": "Multiple accounts share this email. Reset link sent to the most recent account. Use your handle for a specific account." - })) - .into_response(), - None => Json(serde_json::json!({ "success": true })).into_response(), + Some(count) => Ok(Json(PasswordResetOutput { + success: true, + multiple_accounts: Some(true), + account_count: Some(count), + message: Some("Multiple accounts share this email. Reset link sent to the most recent account. Use your handle for a specific account.".into()), + })), + None => Ok(Json(PasswordResetOutput { + success: true, + multiple_accounts: None, + account_count: None, + message: None, + })), } } @@ -113,37 +120,37 @@ pub async fn reset_password( State(state): State, _rate_limit: RateLimited, Json(input): Json, -) -> Response { +) -> Result, ApiError> { let token = input.token.trim(); let password = &input.password; if token.is_empty() { - return ApiError::InvalidToken(None).into_response(); + return Err(ApiError::InvalidToken(None)); } if password.is_empty() { - return ApiError::InvalidRequest("password is required".into()).into_response(); + return Err(ApiError::InvalidRequest("password is required".into())); } if let Err(e) = validate_password(password) { - return ApiError::InvalidRequest(e.to_string()).into_response(); + return Err(ApiError::InvalidRequest(e.to_string())); } let user = match state.user_repo.get_user_by_reset_code(token).await { Ok(Some(u)) => u, Ok(None) => { - return ApiError::InvalidToken(None).into_response(); + return Err(ApiError::InvalidToken(None)); } Err(e) => { error!("DB error in reset_password: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; let user_id = user.id; let Some(exp) = user.expires_at else { - return ApiError::InvalidToken(None).into_response(); + return Err(ApiError::InvalidToken(None)); }; if Utc::now() > exp { if let Err(e) = state.user_repo.clear_password_reset_code(user_id).await { error!("Failed to clear expired reset code: {:?}", e); } - return ApiError::ExpiredToken(None).into_response(); + return Err(ApiError::ExpiredToken(None)); } let password_clone = password.to_string(); let password_hash = @@ -151,11 +158,11 @@ pub async fn reset_password( Ok(Ok(h)) => h, Ok(Err(e)) => { error!("Failed to hash password: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } Err(e) => { error!("Failed to spawn blocking task: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; let result = match state @@ -166,7 +173,7 @@ pub async fn reset_password( Ok(r) => r, Err(e) => { error!("Failed to reset password: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; futures::future::join_all(result.session_jtis.iter().map(|jti| { @@ -197,7 +204,7 @@ pub async fn reset_password( } } info!("Password reset completed for user {}", user_id); - EmptyResponse::ok().into_response() + Ok(Json(EmptyResponse {})) } #[derive(Deserialize)] @@ -211,13 +218,10 @@ pub async fn change_password( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { use tranquil_pds::auth::verify_password_mfa; - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; if input.current_password.is_empty() { return Err(ApiError::InvalidRequest( @@ -259,35 +263,29 @@ pub async fn change_password( .log_db_err("updating password")?; info!(did = %session_mfa.did(), "Password changed successfully"); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } pub async fn get_password_status( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { let has = state .user_repo .has_password_by_did(&auth.did) .await .log_db_err("checking password status")? .ok_or(ApiError::AccountNotFound)?; - Ok(HasPasswordResponse::response(has).into_response()) + Ok(Json(HasPasswordResponse { has_password: has })) } pub async fn remove_password( State(state): State, auth: Auth, -) -> Result { - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; +) -> Result, ApiError> { + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; - let reauth_mfa = match require_reauth_window(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; + let reauth_mfa = require_reauth_window(&state, &auth).await?; let has_passkeys = state .user_repo @@ -320,7 +318,7 @@ pub async fn remove_password( .log_db_err("removing password")?; info!(did = %session_mfa.did(), "Password removed - account is now passkey-only"); - Ok(SuccessResponse::ok().into_response()) + Ok(Json(SuccessResponse { success: true })) } #[derive(Deserialize)] @@ -333,11 +331,8 @@ pub async fn set_password( State(state): State, auth: Auth, Json(input): Json, -) -> Result { - let reauth_mfa = match require_reauth_window_if_available(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; +) -> Result, ApiError> { + let reauth_mfa = require_reauth_window_if_available(&state, &auth).await?; let new_password = &input.new_password; if new_password.is_empty() { @@ -381,5 +376,5 @@ pub async fn set_password( .log_db_err("setting password")?; info!(did = %did, "Password set for passkey-only account"); - Ok(SuccessResponse::ok().into_response()) + Ok(Json(SuccessResponse { success: true })) } diff --git a/crates/tranquil-api/src/server/reauth.rs b/crates/tranquil-api/src/server/reauth.rs index 5c3fc3c..ab614f4 100644 --- a/crates/tranquil-api/src/server/reauth.rs +++ b/crates/tranquil-api/src/server/reauth.rs @@ -1,9 +1,4 @@ -use axum::{ - Json, - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; @@ -27,7 +22,7 @@ pub enum ReauthMethod { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct ReauthStatusResponse { +pub struct ReauthStatusOutput { pub last_reauth_at: Option>, pub reauth_required: bool, pub available_methods: Vec, @@ -36,7 +31,7 @@ pub struct ReauthStatusResponse { pub async fn get_reauth_status( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { let last_reauth_at = state .session_repo .get_last_reauth_at(&auth.did) @@ -44,15 +39,13 @@ pub async fn get_reauth_status( .log_db_err("getting last reauth")?; let reauth_required = is_reauth_required(last_reauth_at); - let available_methods = - get_available_reauth_methods(&*state.user_repo, &*state.session_repo, &auth.did).await; + let available_methods = get_available_reauth_methods(&*state.user_repo, &auth.did).await; - Ok(Json(ReauthStatusResponse { + Ok(Json(ReauthStatusOutput { last_reauth_at, reauth_required, available_methods, - }) - .into_response()) + })) } #[derive(Deserialize)] @@ -63,7 +56,7 @@ pub struct PasswordReauthInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct ReauthResponse { +pub struct ReauthOutput { pub reauthed_at: DateTime, } @@ -71,7 +64,7 @@ pub async fn reauth_password( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let password_hash = state .user_repo .get_password_hash_by_did(&auth.did) @@ -103,7 +96,7 @@ pub async fn reauth_password( .log_db_err("updating reauth")?; info!(did = %&auth.did, "Re-auth successful via password"); - Ok(Json(ReauthResponse { reauthed_at }).into_response()) + Ok(Json(ReauthOutput { reauthed_at })) } #[derive(Deserialize)] @@ -116,7 +109,7 @@ pub async fn reauth_totp( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let _rate_limit = check_user_rate_limit_with_message::( &state, &auth.did, @@ -139,19 +132,19 @@ pub async fn reauth_totp( .log_db_err("updating reauth")?; info!(did = %&auth.did, "Re-auth successful via TOTP"); - Ok(Json(ReauthResponse { reauthed_at }).into_response()) + Ok(Json(ReauthOutput { reauthed_at })) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct PasskeyReauthStartResponse { +pub struct PasskeyReauthStartOutput { pub options: serde_json::Value, } pub async fn reauth_passkey_start( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { let stored_passkeys = state .user_repo .get_passkeys_for_user(&auth.did) @@ -196,7 +189,7 @@ pub async fn reauth_passkey_start( .log_db_err("saving authentication state")?; let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); - Ok(Json(PasskeyReauthStartResponse { options }).into_response()) + Ok(Json(PasskeyReauthStartOutput { options })) } #[derive(Deserialize)] @@ -209,7 +202,7 @@ pub async fn reauth_passkey_finish( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let auth_state_json = state .user_repo .load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) @@ -270,7 +263,7 @@ pub async fn reauth_passkey_finish( .log_db_err("updating reauth")?; info!(did = %&auth.did, "Re-auth successful via passkey"); - Ok(Json(ReauthResponse { reauthed_at }).into_response()) + Ok(Json(ReauthOutput { reauthed_at })) } pub async fn update_last_reauth_cached( @@ -302,33 +295,25 @@ fn is_reauth_required(last_reauth_at: Option>) -> bool { async fn get_available_reauth_methods( user_repo: &dyn UserRepository, - _session_repo: &dyn SessionRepository, did: &tranquil_pds::types::Did, ) -> Vec { - let mut methods = Vec::new(); - let has_password = user_repo .get_password_hash_by_did(did) .await .ok() .flatten() .is_some(); - - if has_password { - methods.push(ReauthMethod::Password); - } - let has_totp = user_repo.has_totp_enabled(did).await.unwrap_or(false); - if has_totp { - methods.push(ReauthMethod::Totp); - } - let has_passkeys = user_repo.has_passkeys(did).await.unwrap_or(false); - if has_passkeys { - methods.push(ReauthMethod::Passkey); - } - methods + [ + (has_password, ReauthMethod::Password), + (has_totp, ReauthMethod::Totp), + (has_passkeys, ReauthMethod::Passkey), + ] + .into_iter() + .filter_map(|(enabled, method)| enabled.then_some(method)) + .collect() } pub async fn check_reauth_required( @@ -364,31 +349,6 @@ pub async fn check_reauth_required_cached( } } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReauthRequiredError { - pub error: String, - pub message: String, - pub reauth_methods: Vec, -} - -pub async fn reauth_required_response( - user_repo: &dyn UserRepository, - session_repo: &dyn SessionRepository, - did: &tranquil_pds::types::Did, -) -> Response { - let methods = get_available_reauth_methods(user_repo, session_repo, did).await; - ( - StatusCode::UNAUTHORIZED, - Json(ReauthRequiredError { - error: "ReauthRequired".to_string(), - message: "Re-authentication required for this action".to_string(), - reauth_methods: methods, - }), - ) - .into_response() -} - pub async fn check_legacy_session_mfa( session_repo: &dyn SessionRepository, did: &tranquil_pds::types::Did, @@ -419,28 +379,3 @@ pub async fn update_mfa_verified( ) -> Result<(), tranquil_db_traits::DbError> { session_repo.update_mfa_verified(did).await } - -pub async fn legacy_mfa_required_response( - user_repo: &dyn UserRepository, - session_repo: &dyn SessionRepository, - did: &tranquil_pds::types::Did, -) -> Response { - let methods = get_available_reauth_methods(user_repo, session_repo, did).await; - ( - StatusCode::FORBIDDEN, - Json(MfaVerificationRequiredError { - error: "MfaVerificationRequired".to_string(), - message: "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.".to_string(), - reauth_methods: methods, - }), - ) - .into_response() -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MfaVerificationRequiredError { - pub error: String, - pub message: String, - pub reauth_methods: Vec, -} diff --git a/crates/tranquil-api/src/server/totp.rs b/crates/tranquil-api/src/server/totp.rs index a5f0704..fa14271 100644 --- a/crates/tranquil-api/src/server/totp.rs +++ b/crates/tranquil-api/src/server/totp.rs @@ -1,8 +1,4 @@ -use axum::{ - Json, - extract::State, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; use tranquil_pds::api::EmptyResponse; @@ -21,7 +17,7 @@ const ENCRYPTION_VERSION: i32 = 1; #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct CreateTotpSecretResponse { +pub struct CreateTotpSecretOutput { pub secret: String, pub uri: String, pub qr_base64: String, @@ -30,7 +26,7 @@ pub struct CreateTotpSecretResponse { pub async fn create_totp_secret( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { use tranquil_db_traits::TotpRecordState; match state.user_repo.get_totp_record_state(&auth.did).await { @@ -74,12 +70,11 @@ pub async fn create_totp_secret( info!(did = %&auth.did, "TOTP secret created (pending verification)"); - Ok(Json(CreateTotpSecretResponse { + Ok(Json(CreateTotpSecretOutput { secret: secret_base32, uri, qr_base64: qr_code, - }) - .into_response()) + })) } #[derive(Deserialize)] @@ -89,7 +84,7 @@ pub struct EnableTotpInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct EnableTotpResponse { +pub struct EnableTotpOutput { pub backup_codes: Vec, } @@ -97,7 +92,7 @@ pub async fn enable_totp( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { use tranquil_db_traits::TotpRecordState; let _rate_limit = check_user_rate_limit_with_message::( @@ -151,7 +146,7 @@ pub async fn enable_totp( info!(did = %&auth.did, "TOTP enabled with {} backup codes", backup_codes.len()); - Ok(Json(EnableTotpResponse { backup_codes }).into_response()) + Ok(Json(EnableTotpOutput { backup_codes })) } #[derive(Deserialize)] @@ -164,11 +159,8 @@ pub async fn disable_totp( State(state): State, auth: Auth, Json(input): Json, -) -> Result { - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; +) -> Result, ApiError> { + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; let _rate_limit = check_user_rate_limit_with_message::( &state, @@ -190,12 +182,12 @@ pub async fn disable_totp( info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method()); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct GetTotpStatusResponse { +pub struct GetTotpStatusOutput { pub enabled: bool, pub has_backup_codes: bool, pub backup_codes_remaining: i64, @@ -204,7 +196,7 @@ pub struct GetTotpStatusResponse { pub async fn get_totp_status( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { use tranquil_db_traits::TotpRecordState; let enabled = match state.user_repo.get_totp_record_state(&auth.did).await { @@ -222,12 +214,11 @@ pub async fn get_totp_status( .await .log_db_err("counting backup codes")?; - Ok(Json(GetTotpStatusResponse { + Ok(Json(GetTotpStatusOutput { enabled, has_backup_codes: backup_count > 0, backup_codes_remaining: backup_count, - }) - .into_response()) + })) } #[derive(Deserialize)] @@ -238,7 +229,7 @@ pub struct RegenerateBackupCodesInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct RegenerateBackupCodesResponse { +pub struct RegenerateBackupCodesOutput { pub backup_codes: Vec, } @@ -246,7 +237,7 @@ pub async fn regenerate_backup_codes( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let _rate_limit = check_user_rate_limit_with_message::( &state, &auth.did, @@ -275,7 +266,7 @@ pub async fn regenerate_backup_codes( info!(did = %password_mfa.did(), "Backup codes regenerated (verified via {} and {})", password_mfa.method(), totp_mfa.method()); - Ok(Json(RegenerateBackupCodesResponse { backup_codes }).into_response()) + Ok(Json(RegenerateBackupCodesOutput { backup_codes })) } async fn verify_backup_code_for_user( diff --git a/crates/tranquil-api/src/server/verify_token.rs b/crates/tranquil-api/src/server/verify_token.rs index bfbe492..9baa18f 100644 --- a/crates/tranquil-api/src/server/verify_token.rs +++ b/crates/tranquil-api/src/server/verify_token.rs @@ -1,6 +1,12 @@ -use axum::{Json, extract::State}; +use crate::common; +use axum::{ + Json, + extract::State, + response::{IntoResponse, Response}, +}; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; +use tranquil_pds::api::SuccessResponse; use tranquil_pds::api::error::{ApiError, DbResultExt}; use tranquil_pds::comms::comms_repo; use tranquil_pds::types::Did; @@ -92,27 +98,7 @@ async fn handle_migration_verification( .log_db_err("updating email_verified status")?; } } - CommsChannel::Discord => { - state - .user_repo - .set_discord_verified_flag(user.id) - .await - .log_db_err("updating discord verified status")?; - } - CommsChannel::Telegram => { - state - .user_repo - .set_telegram_verified_flag(user.id) - .await - .log_db_err("updating telegram verified status")?; - } - CommsChannel::Signal => { - state - .user_repo - .set_signal_verified_flag(user.id) - .await - .log_db_err("updating signal verified status")?; - } + _ => common::set_channel_verified_flag(state.user_repo.as_ref(), user.id, channel).await?, }; info!(did = %did, channel = ?channel, "Migration verification completed successfully"); @@ -239,36 +225,7 @@ async fn handle_signup_verification( })); } - match channel { - CommsChannel::Email => { - state - .user_repo - .set_email_verified_flag(user.id) - .await - .log_db_err("updating email verified status")?; - } - CommsChannel::Discord => { - state - .user_repo - .set_discord_verified_flag(user.id) - .await - .log_db_err("updating discord verified status")?; - } - CommsChannel::Telegram => { - state - .user_repo - .set_telegram_verified_flag(user.id) - .await - .log_db_err("updating telegram verified status")?; - } - CommsChannel::Signal => { - state - .user_repo - .set_signal_verified_flag(user.id) - .await - .log_db_err("updating signal verified status")?; - } - }; + common::set_channel_verified_flag(state.user_repo.as_ref(), user.id, channel).await?; info!(did = %did, channel = ?channel, "Signup verified successfully"); @@ -293,3 +250,26 @@ async fn handle_signup_verification( channel, })) } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmChannelVerificationInput { + pub channel: CommsChannel, + pub identifier: String, + pub code: String, +} + +pub async fn confirm_channel_verification( + State(state): State, + Json(input): Json, +) -> Response { + let token_input = VerifyTokenInput { + token: input.code, + identifier: input.identifier, + }; + + match verify_token_internal(&state, token_input).await { + Ok(_output) => SuccessResponse::ok().into_response(), + Err(e) => e.into_response(), + } +}