mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-22 01:38:43 +02:00
security(auth): harden registration/identity against verified review findings
Adversarial review of the auth/registration surface (findings tracked privately). Backend half: - F2: npub-native register no longer binds an npub onto an existing wallet row on a seed_fingerprint match alone (unauthenticated account takeover). get_or_create_user_by_nostr_pubkey fails closed (409); linking an npub goes through the authenticated /auth/nostr/link flow. Integration test rewritten to assert the refuse-and-409 contract. - F3: `all`-mode gate verifies payment is settled (non-consuming) BEFORE consuming the single-use invite, so a still-confirming payment can't permanently burn the invite. verify_payment_settled split out. - F4: username availability is pre-checked before any gate token is consumed; collisions return 409, not 500 — a taken username no longer burns a paid invoice / invite. - F6: nostr_register now persists the submitted chain keys, so receive-by-username and restore self-verification work for npub wallets. - F10: the pay-to-register not-yet-settled response carries a stable PAYMENT_PENDING code so polling clients don't parse a free-text string. Validated against Postgres: full suite 283 passed / 0 failed.
This commit is contained in:
@@ -32,6 +32,11 @@ pub enum AppError {
|
||||
Forbidden(String),
|
||||
/// Conflict: a unique resource (e.g. a username) is already taken.
|
||||
Conflict(String),
|
||||
/// Pay-to-register invoice exists and is bound but has not settled yet. A
|
||||
/// SAFE variant with its OWN machine-readable code (`PAYMENT_PENDING`) so a
|
||||
/// polling client can distinguish "keep waiting" from a terminal failure
|
||||
/// without matching on the human-readable string.
|
||||
PaymentPending(String),
|
||||
/// Rate limit exceeded.
|
||||
RateLimited,
|
||||
/// Internal server error.
|
||||
@@ -49,6 +54,7 @@ impl std::fmt::Display for AppError {
|
||||
Self::NotFound(msg) => write!(f, "Not found: {}", msg),
|
||||
Self::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
|
||||
Self::Conflict(msg) => write!(f, "Conflict: {}", msg),
|
||||
Self::PaymentPending(msg) => write!(f, "Payment pending: {}", msg),
|
||||
Self::RateLimited => write!(f, "Rate limited"),
|
||||
Self::Internal(msg) => write!(f, "Internal error: {}", msg),
|
||||
}
|
||||
@@ -69,6 +75,7 @@ impl AppError {
|
||||
AppError::NotFound(_) => "NOT_FOUND",
|
||||
AppError::Forbidden(_) => "FORBIDDEN",
|
||||
AppError::Conflict(_) => "CONFLICT",
|
||||
AppError::PaymentPending(_) => "PAYMENT_PENDING",
|
||||
AppError::RateLimited => "RATE_LIMITED",
|
||||
AppError::Internal(_) => "INTERNAL_ERROR",
|
||||
}
|
||||
@@ -126,6 +133,9 @@ impl IntoResponse for AppError {
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
||||
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||
// Keep 400 (not 402) so a legacy client still polling on the status +
|
||||
// string keeps working during rollout; new clients match `code`.
|
||||
AppError::PaymentPending(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limited".to_string()),
|
||||
};
|
||||
|
||||
@@ -254,4 +264,17 @@ mod tests {
|
||||
assert_eq!(json["error"].as_str().unwrap(), msg);
|
||||
assert_eq!(json["code"].as_str().unwrap(), "CONFLICT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn payment_pending_has_stable_code_and_400() {
|
||||
// A still-confirming pay-to-register invoice: 400 (so legacy string-poll
|
||||
// clients keep working) but with the stable PAYMENT_PENDING code new
|
||||
// clients match on to distinguish "keep waiting" from a terminal error.
|
||||
let msg = "Payment not yet confirmed. Complete the payment and retry.";
|
||||
let err = AppError::PaymentPending(msg.into());
|
||||
let (status, json) = extract_json(err.into_response()).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(json["error"].as_str().unwrap(), msg);
|
||||
assert_eq!(json["code"].as_str().unwrap(), "PAYMENT_PENDING");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user