mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-06 18:26:56 +00:00
refactor(api): update password, reauth, verify, account_status, and totp endpoints
This commit is contained in:
@@ -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 {}))
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user