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

This commit is contained in:
Lewis
2026-03-20 13:39:11 +00:00
committed by Tangled
parent b337d2b154
commit d123168787
5 changed files with 205 additions and 327 deletions
+81 -104
View File
@@ -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<AppState>,
auth: Auth<Permissive>,
) -> Result<Response, ApiError> {
) -> Result<Json<CheckAccountStatusOutput>, 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::<Vec<_>>())
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(Value::as_str).collect::<Vec<_>>())
.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<AppState>,
auth: Auth<Permissive>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, 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<AppState>,
auth: Auth<Permissive>,
Json(input): Json<DeactivateAccountInput>,
) -> Result<Response, ApiError> {
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<Json<EmptyResponse>, ApiError> {
auth.check_account_scope(AccountAttr::Repo, AccountAction::Manage)?;
let delete_after: Option<chrono::DateTime<chrono::Utc>> = 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<AppState>,
auth: Auth<NotTakendown>,
) -> Result<Response, ApiError> {
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
) -> Result<Json<EmptyResponse>, 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<AppState>,
Json(input): Json<DeleteAccountInput>,
) -> Response {
) -> Result<Json<EmptyResponse>, 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 {}))
}
+50 -55
View File
@@ -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<AppState>,
_rate_limit: RateLimited<PasswordResetLimit>,
Json(input): Json<RequestPasswordResetInput>,
) -> Response {
) -> Result<Json<PasswordResetOutput>, 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<AppState>,
_rate_limit: RateLimited<ResetPasswordLimit>,
Json(input): Json<ResetPasswordInput>,
) -> Response {
) -> Result<Json<EmptyResponse>, 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<AppState>,
auth: Auth<Active>,
Json(input): Json<ChangePasswordInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, 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<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<HasPasswordResponse>, 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<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
) -> Result<Json<SuccessResponse>, 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<AppState>,
auth: Auth<Active>,
Json(input): Json<SetPasswordInput>,
) -> Result<Response, ApiError> {
let reauth_mfa = match require_reauth_window_if_available(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
) -> Result<Json<SuccessResponse>, 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 }))
}
+24 -89
View File
@@ -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<DateTime<Utc>>,
pub reauth_required: bool,
pub available_methods: Vec<ReauthMethod>,
@@ -36,7 +31,7 @@ pub struct ReauthStatusResponse {
pub async fn get_reauth_status(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<ReauthStatusOutput>, 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<Utc>,
}
@@ -71,7 +64,7 @@ pub async fn reauth_password(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<PasswordReauthInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<ReauthOutput>, 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<AppState>,
auth: Auth<Active>,
Json(input): Json<TotpReauthInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<ReauthOutput>, ApiError> {
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&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<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<PasskeyReauthStartOutput>, 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<AppState>,
auth: Auth<Active>,
Json(input): Json<PasskeyReauthFinishInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<ReauthOutput>, 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<DateTime<Utc>>) -> bool {
async fn get_available_reauth_methods(
user_repo: &dyn UserRepository,
_session_repo: &dyn SessionRepository,
did: &tranquil_pds::types::Did,
) -> Vec<ReauthMethod> {
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<ReauthMethod>,
}
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<ReauthMethod>,
}
+18 -27
View File
@@ -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<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<CreateTotpSecretOutput>, 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<String>,
}
@@ -97,7 +92,7 @@ pub async fn enable_totp(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<EnableTotpInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EnableTotpOutput>, ApiError> {
use tranquil_db_traits::TotpRecordState;
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
@@ -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<AppState>,
auth: Auth<Active>,
Json(input): Json<DisableTotpInput>,
) -> Result<Response, ApiError> {
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
) -> Result<Json<EmptyResponse>, ApiError> {
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&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<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<GetTotpStatusOutput>, 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<String>,
}
@@ -246,7 +237,7 @@ pub async fn regenerate_backup_codes(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<RegenerateBackupCodesInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<RegenerateBackupCodesOutput>, ApiError> {
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&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(
+32 -52
View File
@@ -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<AppState>,
Json(input): Json<ConfirmChannelVerificationInput>,
) -> 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(),
}
}