Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api-server/src/auth_2fa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ pub struct UseBackupCodeRequest {
pub struct Verify2faResponse {
pub success: bool,
pub message: String,
/// Opaque token for the session created after successful verification.
pub session_token: String,
}

/// In-process 2FA store (for demo/testing)
Expand Down
35 changes: 32 additions & 3 deletions api-server/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2429,14 +2429,14 @@ pub async fn enable_2fa(
}

/// #984: Verify 2FA code during login
/// Returns success if TOTP code is valid and marks 2FA as verified
/// Returns success if TOTP code is valid, marks 2FA as verified, and creates a session.
#[utoipa::path(
post,
path = "/auth/2fa/verify",
tag = "Authentication",
request_body = Verify2faRequest,
responses(
(status = 200, description = "2FA code verified successfully", body = Verify2faResponse),
(status = 200, description = "2FA code verified and session created", body = Verify2faResponse),
(status = 401, description = "Invalid 2FA code", body = ErrorResponse),
(status = 400, description = "Invalid request", body = ErrorResponse),
(status = 404, description = "2FA not configured for user", body = ErrorResponse),
Expand Down Expand Up @@ -2486,6 +2486,7 @@ pub async fn verify_2fa(
Ok(Json(Verify2faResponse {
success: true,
message: "2FA verified successfully".to_string(),
session_token,
}))
}
Some(false) => Err((
Expand All @@ -2511,7 +2512,7 @@ pub async fn verify_2fa(
tag = "Authentication",
request_body = UseBackupCodeRequest,
responses(
(status = 200, description = "Backup code accepted, account recovered", body = Verify2faResponse),
(status = 200, description = "Backup code accepted, account recovered, and session created", body = Verify2faResponse),
(status = 401, description = "Invalid or already used backup code", body = ErrorResponse),
(status = 404, description = "User not found", body = ErrorResponse),
)
Expand Down Expand Up @@ -2557,9 +2558,11 @@ pub async fn use_backup_code(
));
}

let session_token = issue_session_token(&body.user_id)?;
Ok(Json(Verify2faResponse {
success: true,
message: "Account recovered with backup code. Please update your 2FA settings.".to_string(),
session_token,
}))
}

Expand All @@ -2568,6 +2571,32 @@ pub async fn use_backup_code(
pub(crate) static SESSION_STORE: once_cell::sync::Lazy<crate::session::SessionStore> =
once_cell::sync::Lazy::new(|| crate::session::SessionStore::new(crate::session::SessionConfig::default()));

fn issue_session_token(
user_id: &str,
) -> Result<String, (StatusCode, Json<ErrorResponse>)> {
let token = uuid::Uuid::new_v4().to_string();
SESSION_STORE
.create_session(user_id, &token)
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error })))?;
Ok(token)
}

pub fn spawn_session_cleanup_task() {
tokio::spawn(async {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
interval.tick().await;
match SESSION_STORE.cleanup_expired_sessions() {
Ok(removed) if removed > 0 => {
tracing::debug!(removed, "cleaned up expired sessions");
}
Ok(_) => {}
Err(error) => tracing::error!(%error, "failed to clean up expired sessions"),
}
}
});
}

/// #985: Check current session status
/// Returns session status including timeout warnings and grace period info
#[utoipa::path(
Expand Down
14 changes: 6 additions & 8 deletions api-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ async fn main() {
metrics::init();
// #1064/#1065: periodic lifecycle anomaly detection + alert escalation.
commitment_monitoring::spawn_background_evaluator();
handlers::spawn_session_cleanup_task();

let subscription_broadcaster = init_subscription_broadcaster().await;
let rpc_client: Arc<dyn graphql::SorobanRpcClient> = Arc::new(graphql::MockSorobanRpcClient::default());
Expand Down Expand Up @@ -469,6 +470,11 @@ async fn main() {
.route("/v1/auth/recovery/verify-token", post(account_recovery::verify_recovery_token))
.route("/v1/auth/recovery/questions", get(account_recovery::get_security_questions))
.route("/v1/auth/recovery/verify-question", post(account_recovery::verify_security_question))
.route("/auth/2fa/enable", post(handlers::enable_2fa))
.route("/auth/2fa/verify", post(handlers::verify_2fa))
.route("/auth/2fa/backup-code", post(handlers::use_backup_code))
.route("/auth/session/status", post(handlers::check_session))
.route("/auth/extend-session", post(handlers::extend_session))
.route("/ip/{ip_id}", get(handlers::get_ip))
.route("/ip/verify", post(handlers::verify_commitment))
.route("/ip/reveal-batch", post(handlers::batch_reveal_commitments))
Expand All @@ -488,12 +494,6 @@ async fn main() {
.route("/swap/marketplace/seller/{seller}", get(handlers::marketplace_by_seller))
.route("/swap/marketplace/buyer/{buyer}", get(handlers::marketplace_by_buyer))
.route("/swap/marketplace/ip/{ip_id}", get(handlers::marketplace_by_ip))
.route("/swap/initiate", post(handlers::initiate_swap).layer(signed.clone()))
.route("/swap/batch-initiate", post(handlers::batch_initiate_swap))
.route("/swap/{swap_id}/accept", post(handlers::accept_swap).layer(signed.clone()))
.route("/swap/{swap_id}/reveal", post(handlers::reveal_key).layer(signed.clone()))
.route("/swap/{swap_id}/cancel", post(handlers::cancel_swap).layer(signed.clone()))
.route("/swap/{swap_id}/cancel-expired", post(handlers::cancel_expired_swap))
.route("/swap/{swap_id}", get(handlers::get_swap))
.route("/openapi.json", get(openapi_handler))
.route("/docs", get(docs::docs_handler))
Expand Down Expand Up @@ -662,8 +662,6 @@ fn build_app() -> Router {
.route("/v1/swap/{swap_id}/history", get(handlers::get_swap_history))
.route("/v1/swap/{swap_id}/disputes", get(handlers::get_swap_disputes))
.route("/v1/swaps", get(handlers::list_swaps))
.route("/v1/webhooks", post(handlers::register_webhook))
.route("/v1/webhooks/{id}", axum::routing::delete(handlers::unregister_webhook))
.route("/v1/bulk/commit-ip", post(handlers::bulk_commit_ip))
.route("/v1/bulk/initiate-swap", post(handlers::bulk_initiate_swap))
.route("/v1/swaps/execute-batch", post(handlers::execute_batch_swaps))
Expand Down
2 changes: 2 additions & 0 deletions api-server/src/schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ pub struct Verify2faRequest {
pub struct Verify2faResponse {
pub success: bool,
pub message: String,
/// Opaque token used with the session status and extension endpoints.
pub session_token: String,
}

/// #984: Request to use backup code for account recovery
Expand Down
Loading