diff --git a/docs/security/account-lockout.md b/docs/security/account-lockout.md new file mode 100644 index 0000000..81689f8 --- /dev/null +++ b/docs/security/account-lockout.md @@ -0,0 +1,22 @@ +# Account Lockout After Failed Logins + +Protects against brute-force attacks by locking an account after repeated failed +login attempts. Thresholds live in `src/auth/account-lockout.constants.ts`. + +## Behaviour + +- Each failed login increments a per-account `failedAttempts` counter. +- After `MAX_FAILED_ATTEMPTS` (5) consecutive failures the account is locked. +- Lockout is **progressive**: each additional failure doubles the lockout + window (`getLockoutDurationMs`), starting at 1 minute and capped at 1 hour. +- A successful login resets the counter. + +## Admin unlock + +- Admins can clear the counter and lockout for an account via an admin endpoint, + immediately restoring access. + +## Security alerts + +- When an account crosses the lockout threshold, a security alert (email) is + sent to the account owner so they can react to a possible attack. diff --git a/docs/security/csrf.md b/docs/security/csrf.md new file mode 100644 index 0000000..8714a5c --- /dev/null +++ b/docs/security/csrf.md @@ -0,0 +1,21 @@ +# CSRF Protection + +State-changing requests are protected with the double-submit cookie pattern. + +## Approach + +1. On session start the server issues a random CSRF token + (`generateCsrfToken()` in `src/common/security/csrf.util.ts`) and sets it in + a cookie readable by the frontend. +2. For every state-changing request (`POST`/`PATCH`/`DELETE`) the client sends + the same token back in the `x-csrf-token` header. +3. A validation middleware compares the header value against the cookie value + using `validateCsrfToken()` (constant-time compare) and rejects mismatches + with `403 Forbidden`. + +## Notes + +- Safe methods (`GET`, `HEAD`, `OPTIONS`) are exempt. +- Combine with `SameSite=Lax`/`Strict` cookies for defence in depth. +- Because the token is compared in constant time, token bytes are not leaked via + response timing. diff --git a/docs/security/jwt-strategy.md b/docs/security/jwt-strategy.md new file mode 100644 index 0000000..c218482 --- /dev/null +++ b/docs/security/jwt-strategy.md @@ -0,0 +1,28 @@ +# JWT Expiration & Refresh Strategy + +## Tokens + +- **Access token** — short-lived, sent as a Bearer token on every request. + Default lifetime `15m`, configurable via `JWT_EXPIRES_IN`. +- **Refresh token** — long-lived, used only to obtain a new access token. + Default lifetime `7d`, configurable via `JWT_REFRESH_EXPIRES_IN`. + +## Refresh flow + +1. On login the API returns an access token and a refresh token. +2. When the access token expires the client calls `POST /auth/refresh` with the + refresh token. +3. The server validates the refresh token, then issues a **new** access token + and a **new** refresh token (rotation), invalidating the previous refresh + token so a leaked token cannot be reused. + +## Configuration + +``` +JWT_SECRET= +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d +``` + +Keeping the access token short-lived limits the blast radius of a stolen token, +while refresh-token rotation provides reuse detection. diff --git a/docs/wallet-verification.md b/docs/wallet-verification.md new file mode 100644 index 0000000..cfed50c --- /dev/null +++ b/docs/wallet-verification.md @@ -0,0 +1,24 @@ +# Wallet Signature Verification + +Proves that a user controls the Stellar wallet they are linking to their +account, using a sign-a-challenge flow. Helpers live in +`src/common/wallet/wallet-signature.util.ts`. + +## Flow + +1. **Challenge** — the client requests a challenge for a public key + (`GET /auth/wallet/challenge?publicKey=G...`). The server returns a random, + single-use challenge string (`generateWalletChallenge()`) and stores it + briefly against the session/user. +2. **Sign** — the wallet signs the challenge with its secret key. +3. **Verify** — the client submits the challenge and the base64 signature + (`POST /auth/wallet/verify`). The server calls `verifyWalletSignature()`, + which checks the signature against the public key. +4. **Associate** — on success the wallet's public key is associated with the + authenticated user account. + +## Notes + +- Challenges are single-use and short-lived to prevent replay. +- Verification never requires the user's secret key — only the public key, + challenge and signature. diff --git a/src/auth/account-lockout.constants.ts b/src/auth/account-lockout.constants.ts new file mode 100644 index 0000000..4e5ec44 --- /dev/null +++ b/src/auth/account-lockout.constants.ts @@ -0,0 +1,25 @@ +/** + * Configuration for progressive account lockout after repeated failed logins. + * See `docs/security/account-lockout.md`. + */ + +/** Number of consecutive failed attempts before the first lockout kicks in. */ +export const MAX_FAILED_ATTEMPTS = 5; + +/** Base lockout duration (ms) applied at the threshold. */ +export const BASE_LOCKOUT_MS = 60_000; // 1 minute + +/** Upper bound on the lockout duration (ms). */ +export const MAX_LOCKOUT_MS = 60 * 60_000; // 1 hour + +/** + * Progressive lockout: each additional failed attempt beyond the threshold + * doubles the lockout duration, capped at {@link MAX_LOCKOUT_MS}. + */ +export function getLockoutDurationMs(failedAttempts: number): number { + if (failedAttempts < MAX_FAILED_ATTEMPTS) { + return 0; + } + const over = failedAttempts - MAX_FAILED_ATTEMPTS; + return Math.min(BASE_LOCKOUT_MS * 2 ** over, MAX_LOCKOUT_MS); +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 22fd991..97706dd 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -18,8 +18,10 @@ import { RedisModule } from '../redis/redis.module'; useFactory: (configService: ConfigService) => ({ secret: configService.get('JWT_SECRET'), signOptions: { + // Short-lived access token. Long-lived refresh tokens are issued + // separately (JWT_REFRESH_EXPIRES_IN). See docs/security/jwt-strategy.md. expiresIn: (configService.get('JWT_EXPIRES_IN') || - '1d') as any, + '15m') as any, }, }), inject: [ConfigService], diff --git a/src/common/security/csrf.util.ts b/src/common/security/csrf.util.ts new file mode 100644 index 0000000..346cf90 --- /dev/null +++ b/src/common/security/csrf.util.ts @@ -0,0 +1,29 @@ +import * as crypto from 'crypto'; + +/** + * Minimal helpers for CSRF token generation and validation. + * + * A random token is issued to the client (e.g. in a cookie) and must be echoed + * back in a request header (`x-csrf-token`) for state-changing requests. The + * comparison is constant-time to avoid leaking token bytes via timing. + * See `docs/security/csrf.md`. + */ + +/** Generate a new random CSRF token (hex-encoded, 32 bytes). */ +export function generateCsrfToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * Validate a submitted CSRF token against the expected token using a + * constant-time comparison. + */ +export function validateCsrfToken( + expected: string | undefined, + actual: string | undefined, +): boolean { + if (!expected || !actual || expected.length !== actual.length) { + return false; + } + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(actual)); +} diff --git a/src/common/wallet/wallet-signature.util.ts b/src/common/wallet/wallet-signature.util.ts new file mode 100644 index 0000000..74f91b7 --- /dev/null +++ b/src/common/wallet/wallet-signature.util.ts @@ -0,0 +1,36 @@ +import { Keypair } from '@stellar/stellar-sdk'; +import * as crypto from 'crypto'; + +/** + * Helpers for verifying that a user controls the Stellar wallet they claim. + * + * Flow: the server issues a random challenge, the user signs it with their + * wallet secret key, and the server verifies the signature against the wallet's + * public key. See `docs/wallet-verification.md`. + */ + +/** Generate a random challenge string for the user to sign. */ +export function generateWalletChallenge(): string { + return `stellar-aid-verify:${crypto.randomBytes(24).toString('hex')}`; +} + +/** + * Verify that `signatureBase64` is a valid signature of `challenge` produced by + * the secret key corresponding to `publicKey` (a Stellar `G...` address). + * + * @returns `true` when the signature is valid, `false` otherwise (including on + * a malformed public key or signature). + */ +export function verifyWalletSignature( + publicKey: string, + challenge: string, + signatureBase64: string, +): boolean { + try { + const keypair = Keypair.fromPublicKey(publicKey); + const signature = Buffer.from(signatureBase64, 'base64'); + return keypair.verify(Buffer.from(challenge, 'utf8'), signature); + } catch { + return false; + } +}