mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat: core auth handlers (register, restore, pow, refresh, logout, me, nostr)
api/middleware.rs: Bearer access-token extraction; client_ip() trusts XFF only behind config.trusted_proxies (fail-closed against spoofing). api/auth.rs: extension_register (PoW-gated; derivation-rotation requires a signature from the on-file key, no fingerprint hijack), check_restore (per-IP + per-fingerprint rate limits, enumeration/timing-resistant), pow_challenge, refresh_token (re-checks active session + revoke-then-issue rotation, race-safe), logout, get_me, nostr_login (login-grade NIP-98, never creates a user, bound to config public_api_url), nostr_link (JWT + state-change verify_signed_action, fail-closed nonce stub) + DTOs + routes(). Wired into api/mod.rs + nested at /api/v1 in main.rs; all 8 paths registered in ApiDoc so openapi.json covers the auth surface (was vacuous). Drafted+hardened via draft->adversarial-review->revise workflow; independently re-reviewed (no critical/high). 58 tests, all gates green. Known LOW (tracked): concurrent first-registration of the same new pubkey_hash can 500 instead of idempotent re-read; client-retryable.
This commit is contained in:
+506
@@ -13,6 +13,235 @@
|
||||
"version": "0.3.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/check-restore": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Check whether a wallet restore is valid (created here + keys match).",
|
||||
"description": "Two governors run BEFORE any user lookup: a per-IP limit\n([`RESTORE_IP_THRESHOLD`]) that bounds distinct-fingerprint scanning, and a\nper-fingerprint failure limit ([`RESTORE_FAIL_THRESHOLD`]); either trips a\n429. The per-IP limit uses [`client_ip`] so an untrusted `X-Forwarded-For`\ncannot evade it. Every attempt is recorded (peppered fingerprint, salted IP).\n\nThe known/unknown branches are equalized: the unknown branch runs the same\nkey-comparison loop against an empty stored set, so the two paths do the same\nwork and the response does not become a timing oracle for existence beyond the\nalready-intentional `exists` field.",
|
||||
"operationId": "check_restore",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CheckRestoreRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Restore validation result",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CheckRestoreResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/extension": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Register a new extension wallet or authenticate an existing one.",
|
||||
"description": "The BTC pubkey hash is the unique identity. A signed timestamp proves control\nof the BTC private key (defeats registration with a stolen public key). When\nthe PoW gate applies and the wallet is new, a valid `altcha_solution` is\nrequired; a returning user (a known `pubkey_hash`) bypasses PoW.\n\n## Derivation rotation is authenticated (account-takeover defense)\n\nA known `seed_fingerprint` at a NEW `pubkey_hash` (the wallet changed its\nderivation scheme) may re-point the existing user row — but ONLY when the\nrequest also carries a `rotation_signature` that verifies against the BTC key\nalready on file for that user (proving control of the seed-derived key, not\nmerely knowledge of the fingerprint, which `check_restore` discloses and is\nnot secret). A bare fingerprint match WITHOUT a valid rotation proof is\ntreated as a brand-new identity: a fresh user row is created on the new\n`pubkey_hash` and the matched victim row is never modified. The rotation path\nis gated by PoW exactly like any other new-pubkey registration.",
|
||||
"operationId": "extension_register",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExtensionRegisterRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Wallet registered or authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Revoke the session backing a refresh token. Idempotent: an unknown or\nalready-revoked token still returns success (logout should never error).",
|
||||
"operationId": "logout",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LogoutRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Session revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LogoutResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "The authenticated user's own info.",
|
||||
"operationId": "get_me",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current authenticated user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/nostr": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Sign in with a Nostr identity (NIP-98 HTTP Auth, login grade).",
|
||||
"description": "The `Nostr <base64(event)>` token is read from the `Authorization` header and\nverified against the canonical `config.identity.public_api_url` + `/auth/nostr`\n(never the request Host). The npub must ALREADY be linked to a user (see\n[`nostr_link`]); this endpoint NEVER creates a user — an unlinked npub is 401.",
|
||||
"operationId": "nostr_login",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Session for the linked Nostr identity",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid NIP-98 token or no linked account"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/nostr/link": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Link a Nostr identity (npub) to the authenticated user.",
|
||||
"description": "Dual auth: the Bearer JWT identifies the user; a NIP-98 *signed-action* proof\n(not a login token) proves control of the npub AND binds a server-issued\nsingle-use `nonce`, the purpose `nostr_link`, and this exact request via the\nrequest-descriptor hash. On success the npub is stored so a later\n[`nostr_login`] resolves to the same wallet; a collision is 409.\n\n## Descriptor binding is a CONTRACT (not an implementation detail)\n\nThe `payload` tag binds `descriptor_sha256(request_descriptor(\"POST\",\n\"/api/v1/auth/nostr/link\", \"\", b\"\"))` — an EMPTY body hash, with no query.\nThe JSON `{nostr_token, nonce}` rides in the HTTP body but is deliberately NOT\npart of the signed descriptor (it carries the proof itself, so it cannot also\nbe inside it). The wallet MUST build the identical descriptor. This exact\nmethod/path/query/empty-body shape is a cross-impl contract and must be pinned\nin a shared test vector (mirroring the nip98.rs interop test) before\n`consume_link_nonce` is un-stubbed — otherwise the binding silently breaks or\nweakens at integration time.\n\nTODO(operator-surface): the server-nonce *issue* (`GET\n/auth/nostr/link-challenge`, rand 32 bytes, stored) + the ATOMIC single-use\n*consume* (a `DELETE ... WHERE nonce=$1 RETURNING` so a replay loses the race,\nnever check-then-delete), plus the wallet's signed-action token builder, are\nwired in the operator-surface phase. Until then [`consume_link_nonce`] always\nreturns `false` and this handler FAILS CLOSED: it validates the full proof\nshape but refuses every link, because it cannot yet prove the nonce was issued\nby this server and is unused.",
|
||||
"operationId": "nostr_link",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NostrLinkRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Linked npub",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NostrLinkResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid proof or missing session"
|
||||
},
|
||||
"409": {
|
||||
"description": "npub already linked to another account"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/pow-challenge": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Issue a fresh proof-of-work challenge for the wallet to solve before calling\n`/auth/extension`. The solved payload is sent back as the request's\n`altcha_solution`.",
|
||||
"description": "Stateless: the challenge embeds an HMAC signature over its own fields plus an\nexpiry, so no issued-challenge store is needed. See [`crate::core::pow`].",
|
||||
"operationId": "pow_challenge",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Proof-of-work challenge for wallet registration"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/refresh": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Rotate a refresh token.",
|
||||
"description": "Verifies the JWT, then re-looks-up the ACTIVE session by peppered hash\n(`get_session_by_token_hash` already filters revoked/expired) — a missing row\nmeans the token was revoked, expired, or already rotated, and is rejected.\nThe session's `user_id` is asserted equal to the JWT `sub` (defense-in-depth\nagainst any future token-confusion class). The old session is revoked and a\nfresh pair issued (revoke-then-issue); the `revoke_session` race-loser also\nrejects, so a stolen token cannot be reused.",
|
||||
"operationId": "refresh_token",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RefreshTokenRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Token refreshed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -38,6 +267,191 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AssetPublicKey": {
|
||||
"type": "object",
|
||||
"description": "A per-asset public key submitted by the wallet.",
|
||||
"required": [
|
||||
"asset",
|
||||
"public_key"
|
||||
],
|
||||
"properties": {
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_spend_key": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "XMR/WOW only: public spend key."
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuthResponse": {
|
||||
"type": "object",
|
||||
"description": "Successful session response: a JWT pair plus minimal user info.",
|
||||
"required": [
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"expires_in",
|
||||
"user",
|
||||
"is_new"
|
||||
],
|
||||
"properties": {
|
||||
"access_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Access-token lifetime in seconds."
|
||||
},
|
||||
"is_new": {
|
||||
"type": "boolean",
|
||||
"description": "`true` when this request created the user (extension registration only)."
|
||||
},
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/UserInfo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CheckRestoreRequest": {
|
||||
"type": "object",
|
||||
"description": "Ask whether a wallet (by seed fingerprint) was created on this backend, and\nwhether the submitted keys match.",
|
||||
"required": [
|
||||
"fingerprint",
|
||||
"keys"
|
||||
],
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"description": "Seed fingerprint (16 or 64 hex chars)."
|
||||
},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetPublicKey"
|
||||
},
|
||||
"description": "Submitted per-asset public keys, for verification."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CheckRestoreResponse": {
|
||||
"type": "object",
|
||||
"description": "Restore-check result. Constant-shape: the same fields are always present so\nthe response is not a structure oracle, and `user_id` is never returned (no\nenumeration).\n\nNOTE: `exists` is an INTENTIONAL disclosure (the wallet uses it to decide\nwhether to offer a restore). It is throttled per fingerprint AND per IP, and\nnothing downstream treats fingerprint-existence as authority — the\nderivation-rotation path in [`extension_register`] requires a key-control\nproof, not a bare fingerprint.",
|
||||
"required": [
|
||||
"exists"
|
||||
],
|
||||
"properties": {
|
||||
"exists": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the fingerprint exists on this backend."
|
||||
},
|
||||
"keys_valid": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "Whether every submitted key matches the stored key. `None` when the\nfingerprint does not exist."
|
||||
},
|
||||
"wow_start_height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "WOW scan-start height, if the wallet was created here."
|
||||
},
|
||||
"xmr_start_height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "XMR scan-start height, if the wallet was created here."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExtensionRegisterRequest": {
|
||||
"type": "object",
|
||||
"description": "Register a new extension wallet or re-authenticate an existing one.",
|
||||
"required": [
|
||||
"keys",
|
||||
"signed_timestamp",
|
||||
"signature"
|
||||
],
|
||||
"properties": {
|
||||
"altcha_solution": {
|
||||
"type": "object",
|
||||
"description": "Optional proof-of-work solution. Required when the PoW gate applies to\nthis pubkey (see [`pow_applies`]); otherwise ignored."
|
||||
},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetPublicKey"
|
||||
},
|
||||
"description": "Public keys for each supported asset. A `btc` key is required (it is the\nidentity)."
|
||||
},
|
||||
"rotation_signature": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Derivation-rotation proof: BIP-137 base64 signature of the SAME\n`smirk-auth-{signed_timestamp}` message under the BTC key ALREADY ON FILE\nfor the user identified by `seed_fingerprint`. Required to re-point an\nexisting user row; without it (or if it does not verify against the stored\nkey) a fingerprint match is treated as a brand-new identity and the\nexisting row is never touched. See [`extension_register`]."
|
||||
},
|
||||
"seed_fingerprint": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Seed fingerprint `hex(SHA256(SHA256(seed))[..])`. Used for restore and to\nLOCATE a candidate user row for the derivation-rotation path. By itself it\nis NOT authority: a rotation also requires `rotation_signature` below."
|
||||
},
|
||||
"signature": {
|
||||
"type": "string",
|
||||
"description": "BIP-137 base64 signature of `smirk-auth-{signed_timestamp}` under the\nSUBMITTED (new) BTC key. Proves control of the key in `keys`."
|
||||
},
|
||||
"signed_timestamp": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Unix seconds that were signed to prove BTC key ownership."
|
||||
},
|
||||
"username": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Optional reserved username."
|
||||
},
|
||||
"wallet_birthday": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Wallet creation time (unix seconds), to bound chain scans."
|
||||
},
|
||||
"wow_start_height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"xmr_start_height": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"HealthResponse": {
|
||||
"type": "object",
|
||||
"description": "Liveness response.",
|
||||
@@ -50,6 +464,94 @@
|
||||
"description": "Always `\"ok\"` when the service is reachable."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LogoutRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LogoutResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"success"
|
||||
],
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NostrLinkRequest": {
|
||||
"type": "object",
|
||||
"description": "Link a Nostr identity to the authenticated user. This is a STATE CHANGE, so\nit carries a signed-action proof, not a login-grade token.",
|
||||
"required": [
|
||||
"nostr_token",
|
||||
"nonce"
|
||||
],
|
||||
"properties": {
|
||||
"nonce": {
|
||||
"type": "string",
|
||||
"description": "The server-issued single-use nonce the signed action must bind (the\nevent's `challenge` tag)."
|
||||
},
|
||||
"nostr_token": {
|
||||
"type": "string",
|
||||
"description": "The `Nostr <base64(event)>` signed-action token proving control of the\nnpub AND committing to the server nonce + this request."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NostrLinkResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"nostr_pubkey"
|
||||
],
|
||||
"properties": {
|
||||
"nostr_pubkey": {
|
||||
"type": "string",
|
||||
"description": "The linked x-only Nostr pubkey (hex)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RefreshTokenRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserInfo": {
|
||||
"type": "object",
|
||||
"description": "Minimal, non-enumerable user info returned to a signed-in client.",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"nostr_pubkey": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Linked Nostr pubkey (x-only hex), if any."
|
||||
},
|
||||
"username": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -57,6 +559,10 @@
|
||||
{
|
||||
"name": "system",
|
||||
"description": "Service health and metadata."
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Wallet authentication: registration, restore, refresh, sessions, Nostr identity."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1181
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
//! HTTP middleware and request-scoped extractors for the auth surface.
|
||||
//!
|
||||
//! Two helpers live here:
|
||||
//!
|
||||
//! * [`extract_user_id_from_token`] — pull a Bearer access token from the
|
||||
//! `Authorization` header and resolve it to a `user_id` via the shared
|
||||
//! [`SessionManager`] in [`AppState`]. The error is a literal (`Invalid or
|
||||
//! expired token` / a missing-header literal) so the endpoint never becomes an
|
||||
//! auth oracle.
|
||||
//! * [`client_ip`] — derive the client IP used for rate-limiting and audit. It
|
||||
//! trusts `X-Forwarded-For` **only** when the TCP peer is inside
|
||||
//! `config.trusted_proxies`; otherwise it uses the real peer. A spoofed
|
||||
//! `X-Forwarded-For` from an untrusted peer cannot evade per-IP limits.
|
||||
//!
|
||||
//! The session manager is read from `state.sessions` (constructed once at
|
||||
//! startup) rather than rebuilt per request, so the HS256 key never has to be
|
||||
//! re-derived on the hot path.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::AppState;
|
||||
|
||||
/// Resolve a Bearer access token in the `Authorization` header to its `user_id`.
|
||||
///
|
||||
/// All failure modes (missing header, wrong scheme, invalid/expired/forged JWT)
|
||||
/// collapse to `AppError::AuthError` with a literal message, so a caller cannot
|
||||
/// distinguish "no token" from "bad token" from "expired token".
|
||||
pub async fn extract_user_id_from_token(
|
||||
state: &Arc<AppState>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Uuid, AppError> {
|
||||
let token = bearer_token(headers)?;
|
||||
let info = state.sessions.verify_access_token(token)?;
|
||||
Ok(info.user_id)
|
||||
}
|
||||
|
||||
/// Extract the raw Bearer token from an `Authorization` header, or a literal
|
||||
/// `AuthError`. Split out so login-grade and state-change handlers share one
|
||||
/// parser (and one error shape).
|
||||
pub(crate) fn bearer_token(headers: &HeaderMap) -> Result<&str, AppError> {
|
||||
let auth = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| AppError::AuthError("Missing authorization header".into()))?;
|
||||
|
||||
auth.strip_prefix("Bearer ")
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or_else(|| AppError::AuthError("Invalid authorization format".into()))
|
||||
}
|
||||
|
||||
/// The client IP to use for rate-limiting and audit logging.
|
||||
///
|
||||
/// `peer` is the real TCP source (from axum `ConnectInfo<SocketAddr>`). We honor
|
||||
/// `X-Forwarded-For` **only** when `peer` falls inside one of
|
||||
/// `config.trusted_proxies`; otherwise the header is attacker-controlled and is
|
||||
/// ignored. The default config has an empty trusted-proxy list, so by default
|
||||
/// the real peer is always used — fail-closed against header spoofing.
|
||||
///
|
||||
/// When trusted, the *left-most* XFF entry (the original client, per the de-facto
|
||||
/// convention) is taken; if it does not parse as an IP we fall back to the peer
|
||||
/// rather than trusting an unparseable value.
|
||||
pub fn client_ip(state: &AppState, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||
let peer_ip = peer.ip();
|
||||
|
||||
let peer_is_trusted = state
|
||||
.config
|
||||
.trusted_proxies
|
||||
.iter()
|
||||
.any(|net| net.contains(peer_ip));
|
||||
|
||||
if !peer_is_trusted {
|
||||
return peer_ip;
|
||||
}
|
||||
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|xff| xff.split(',').next())
|
||||
.map(str::trim)
|
||||
.and_then(|first| first.parse::<IpAddr>().ok())
|
||||
.unwrap_or(peer_ip)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ipnetwork::IpNetwork;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn headers_with_xff(value: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("x-forwarded-for", value.parse().unwrap());
|
||||
h
|
||||
}
|
||||
|
||||
fn peer(s: &str) -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::from_str(s).unwrap(), 12345)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_token_parses_and_rejects() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
"Bearer abc.def.ghi".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(bearer_token(&h).unwrap(), "abc.def.ghi");
|
||||
|
||||
let mut bad = HeaderMap::new();
|
||||
bad.insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
"Basic xyz".parse().unwrap(),
|
||||
);
|
||||
assert!(bearer_token(&bad).is_err());
|
||||
assert!(bearer_token(&HeaderMap::new()).is_err());
|
||||
}
|
||||
|
||||
/// Build a throwaway AppState is heavy; instead exercise the trust decision
|
||||
/// against the proxy list directly through a tiny shim mirroring `client_ip`.
|
||||
fn pick_ip(trusted: &[IpNetwork], headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||
let peer_ip = peer.ip();
|
||||
if !trusted.iter().any(|n| n.contains(peer_ip)) {
|
||||
return peer_ip;
|
||||
}
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|xff| xff.split(',').next())
|
||||
.map(str::trim)
|
||||
.and_then(|f| f.parse::<IpAddr>().ok())
|
||||
.unwrap_or(peer_ip)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_peer_ignores_xff() {
|
||||
// No trusted proxies: the spoofed header is ignored, peer wins.
|
||||
let h = headers_with_xff("1.2.3.4");
|
||||
let got = pick_ip(&[], &h, peer("9.9.9.9"));
|
||||
assert_eq!(got, IpAddr::from_str("9.9.9.9").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_peer_honors_leftmost_xff() {
|
||||
let trusted = vec![IpNetwork::from_str("10.0.0.0/8").unwrap()];
|
||||
let h = headers_with_xff("1.2.3.4, 10.0.0.5");
|
||||
let got = pick_ip(&trusted, &h, peer("10.0.0.5"));
|
||||
assert_eq!(got, IpAddr::from_str("1.2.3.4").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_peer_with_garbage_xff_falls_back_to_peer() {
|
||||
let trusted = vec![IpNetwork::from_str("10.0.0.0/8").unwrap()];
|
||||
let h = headers_with_xff("not-an-ip");
|
||||
let got = pick_ip(&trusted, &h, peer("10.0.0.5"));
|
||||
assert_eq!(got, IpAddr::from_str("10.0.0.5").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
//! HTTP API layer: handlers and the OpenAPI assembly.
|
||||
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
pub mod middleware;
|
||||
pub mod openapi;
|
||||
|
||||
+29
-3
@@ -14,8 +14,34 @@ use utoipa::OpenApi;
|
||||
version = "0.3.0",
|
||||
description = "Open, self-hostable backend for the Smirk non-custodial multi-chain wallet."
|
||||
),
|
||||
paths(crate::api::health::health),
|
||||
components(schemas(crate::api::health::HealthResponse)),
|
||||
tags((name = "system", description = "Service health and metadata."))
|
||||
paths(
|
||||
crate::api::health::health,
|
||||
crate::api::auth::pow_challenge,
|
||||
crate::api::auth::extension_register,
|
||||
crate::api::auth::check_restore,
|
||||
crate::api::auth::refresh_token,
|
||||
crate::api::auth::logout,
|
||||
crate::api::auth::get_me,
|
||||
crate::api::auth::nostr_login,
|
||||
crate::api::auth::nostr_link,
|
||||
),
|
||||
components(schemas(
|
||||
crate::api::health::HealthResponse,
|
||||
crate::api::auth::AuthResponse,
|
||||
crate::api::auth::UserInfo,
|
||||
crate::api::auth::AssetPublicKey,
|
||||
crate::api::auth::ExtensionRegisterRequest,
|
||||
crate::api::auth::CheckRestoreRequest,
|
||||
crate::api::auth::CheckRestoreResponse,
|
||||
crate::api::auth::RefreshTokenRequest,
|
||||
crate::api::auth::LogoutRequest,
|
||||
crate::api::auth::LogoutResponse,
|
||||
crate::api::auth::NostrLinkRequest,
|
||||
crate::api::auth::NostrLinkResponse,
|
||||
)),
|
||||
tags(
|
||||
(name = "system", description = "Service health and metadata."),
|
||||
(name = "auth", description = "Wallet authentication: registration, restore, refresh, sessions, Nostr identity.")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
@@ -45,6 +45,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.nest("/api/v1", smirk_backend_core::api::auth::routes())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
Reference in New Issue
Block a user