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
22 changes: 22 additions & 0 deletions docs/security/account-lockout.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions docs/security/csrf.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions docs/security/jwt-strategy.md
Original file line number Diff line number Diff line change
@@ -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=<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.
24 changes: 24 additions & 0 deletions docs/wallet-verification.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions src/auth/account-lockout.constants.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 3 additions & 1 deletion src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import { RedisModule } from '../redis/redis.module';
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('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<string>('JWT_EXPIRES_IN') ||
'1d') as any,
'15m') as any,
},
}),
inject: [ConfigService],
Expand Down
29 changes: 29 additions & 0 deletions src/common/security/csrf.util.ts
Original file line number Diff line number Diff line change
@@ -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));
}
36 changes: 36 additions & 0 deletions src/common/wallet/wallet-signature.util.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}