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
114 changes: 114 additions & 0 deletions meridian-api/docs/auth-resource-rate-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Auth Resource and Rate Limits — Issue #1651

## Summary

This change adds production-grade resource and rate limits to all authentication
and account-recovery flows so the system provides a deterministic, reviewable
guarantee under normal, invalid, repeated, concurrent, and failure conditions.

## What changed

### 1. Account lockout for failed sign-in attempts (`AccountLockoutService`)

**File:** `src/auth/providers/account-lockout.service.ts`

Tracks per-email failed sign-in attempts using a bounded in-memory store.
After `maxAttempts` failures (default: 5) within a configurable window
(default: 15 min), the account is locked for `lockoutMs` (default: 15 min).

**New env vars:**
| Variable | Default | Description |
|---|---|---|
| `AUTH_LOCKOUT_MAX_ATTEMPTS` | `5` | Consecutive failures before lockout |
| `AUTH_LOCKOUT_WINDOW_MS` | `900000` (15 min) | Window for counting failures |
| `AUTH_LOCKOUT_DURATION_MS` | `900000` (15 min) | How long the account is locked |

**Integration:** `SignInProviders.SignIn()` now checks lockout *before* querying
the database, records failures on wrong passwords, and resets the counter on
success. Lockout returns HTTP 429 with a `RetryAfter` guidance in the message.

### 2. Rate limiting on logout/logout-all endpoints

**File:** `src/auth/auth.controller.ts`

Added `@Throttle` decorators:
- `POST /auth/logout` → 10 requests/minute
- `POST /auth/logout-all` → 5 requests/minute

These complement the existing limits on sign-in (5/15s), refresh-token (10/60s),
verify-email (10/60s), and resend-verification (3/60s).

### 3. Input size bounds on auth DTOs

Added `@MaxLength` and `@MinLength` validators to all auth DTOs to reject
oversized payloads before expensive operations:

| DTO | Field | Max Length | Rationale |
|---|---|---|---|
| `SignInDto` | `email` | 254 | RFC 5321 §4.5.3.1 |
| `SignInDto` | `password` | 72 | bcrypt truncation limit |
| `RefreshTokenDto` | `refreshToken` | 4096 | JWT with RSA-4096 + claims |
| `LogoutDto` | `refreshToken` | 4096 | Same as above |
| `VerifyEmailDto` | `token` | 256 | 64-char hex + safety margin |
| `ResendVerificationDto` | `email` | 254 | RFC 5321 §4.5.3.1 |

### 4. Missing `AuditAction` enum values

Added auth-flow action values (`SIGN_IN`, `REFRESH`, `LOGOUT`, `LOGOUT_ALL`,
`ISSUE_VERIFICATION_TOKEN`, `VERIFY_EMAIL`, `RESEND_VERIFICATION`) to the
`AuditAction` enum so the auth module compiles correctly.

## Security invariants

1. **Stale tokens rejected** — expired or revoked tokens never advance to an
authoritative state (existing, verified by existing tests).
2. **Account lockout is time-bounded** — after `lockoutMs` the counter resets
automatically; no manual intervention required.
3. **Lockout counter resets on success** — a successful sign-in immediately
clears the failure counter.
4. **Partial state on failure** — failed sign-in attempts with lockout leave no
unauthorized or partial state (no tokens issued, no sessions created).
5. **Bounded store** — the lockout store is capped at 10,000 entries with
LRU-style eviction to prevent OOM under sustained attack.
6. **Input validation before work** — oversized payloads are rejected by
`class-validator` before any database query or hash computation.
7. **Rate limits cover all mutating endpoints** — sign-in, refresh, logout,
logout-all, verify-email, and resend-verification are all throttled.
8. **Rejected operations leave no state** — idempotency keys are deleted on
failure, lockout entries expire, and rate limit windows slide.

## Failure behavior and compatibility

- **HTTP 429 responses** now returned when:
- An account is locked (with message indicating retry time)
- Rate limits exceeded on any auth endpoint
- **HTTP 400 responses** now returned when:
- Input exceeds max length bounds (e.g., email > 254 chars)
- **Public behavior preserved** — all existing successful-path responses are
unchanged. The only new error codes are 429 (lockout/throttle) and tighter
400 validation.

## Migration / rollback

- **Env vars** — all new env vars have safe defaults; no migration required.
- **Rollback** — revert the branch. The lockout service is additive and only
injected into `SignInProviders`. The throttler decorators are metadata-only.
- **Database** — no schema changes. Lockout state is in-memory.

## Operational limitations

- **Single-process only** — the `AccountLockoutService` uses an in-memory store.
For horizontal scaling, replace with Redis-backed storage (the infrastructure
already exists in `RateLimitService`).
- **Lockout is per-email** — if the same email is used across environments, each
process maintains independent counters.
- **Not a substitute for CAPTCHA** — lockout slows brute-force but does not
replace bot-detection measures.

## Security assumptions

- The email is the sole lockout key (matching the sign-in identifier).
- The `ConfigService` values are trusted (set by ops, not user input).
- The process clock is monotonic (lockout expiry depends on `Date.now()`).
- The 10,000-entry cap is sufficient for legitimate traffic; extreme abuse is
handled at the infrastructure layer (WAF, CDN rate limiting).
9 changes: 9 additions & 0 deletions meridian-api/src/audit/audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ export enum AuditAction {
CONTRACT_EVENT = 'CONTRACT_EVENT',
AUTHORIZATION_GRANTED = 'AUTHORIZATION_GRANTED',
AUTHORIZATION_DENIED = 'AUTHORIZATION_DENIED',

// Auth-flow actions (issue #1651)
SIGN_IN = 'SIGN_IN',
REFRESH = 'REFRESH',
LOGOUT = 'LOGOUT',
LOGOUT_ALL = 'LOGOUT_ALL',
ISSUE_VERIFICATION_TOKEN = 'ISSUE_VERIFICATION_TOKEN',
VERIFY_EMAIL = 'VERIFY_EMAIL',
RESEND_VERIFICATION = 'RESEND_VERIFICATION',
}

@Entity('audit_logs')
Expand Down
10 changes: 10 additions & 0 deletions meridian-api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class AuthController {

@Post('/logout')
@Public()
@Throttle({ write: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Revoke the current refresh token' })
@ApiResponse({
Expand All @@ -81,19 +82,28 @@ export class AuthController {
status: 401,
description: 'Unauthorized / Invalid refresh token',
})
@ApiResponse({
status: 429,
description: 'Too Many Requests - Limit 10 attempts per minute',
})
public async logout(@Body() logoutDto: LogoutDto) {
return this.authService.logout(logoutDto);
}

// Authenticated via the global RbacGuard (default posture) — no @Public().
@Post('/logout-all')
@Throttle({ write: { limit: 5, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Revoke all refresh tokens for the current user' })
@ApiBearerAuth()
@ApiResponse({
status: 200,
description: 'Successfully revoked all sessions',
})
@ApiResponse({
status: 429,
description: 'Too Many Requests - Limit 5 attempts per minute',
})
public async logoutAll(@Req() req: Request) {
const user = req[REQUEST_USER_KEY] as { sub?: string | number };
const userId = Number(user?.sub);
Expand Down
2 changes: 2 additions & 0 deletions meridian-api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { CryptoModule } from 'src/crypto/crypto.module';
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { Observable, mergeMap, catchError } from 'rxjs';
import { AccountLockoutService } from './providers/account-lockout.service';

// Entity for idempotency records
@Entity('auth_idempotency_keys')
Expand Down Expand Up @@ -126,6 +127,7 @@ wtConfig),
RefreshTokenProvider,
{ provide: HashingProvider, useClass: BcryptProvider },
SignInProviders,
AccountLockoutService,
VerifyEmailProvider,
{
provide: VerificationTokenProvider,
Expand Down
7 changes: 6 additions & 1 deletion meridian-api/src/auth/dto/logout.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';

/** JWT tokens are typically < 2 KB; 4 KB covers RSA-4096 with generous claims. */
const MAX_REFRESH_TOKEN_LENGTH = 4096;

export class LogoutDto {
@ApiProperty({
Expand All @@ -8,5 +11,7 @@ export class LogoutDto {
})
@IsString()
@IsNotEmpty()
@MinLength(1)
@MaxLength(MAX_REFRESH_TOKEN_LENGTH)
refreshToken: string;
}
7 changes: 6 additions & 1 deletion meridian-api/src/auth/dto/refresh-token-dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

/** JWT tokens are typically < 2 KB; 4 KB covers RSA-4096 with generous claims. */
const MAX_REFRESH_TOKEN_LENGTH = 4096;

export class RefreshTokenDto {
@ApiProperty({
description: 'The JWT refresh token issued during login',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
@MinLength(1)
@MaxLength(MAX_REFRESH_TOKEN_LENGTH)
refreshToken: string;
}
7 changes: 6 additions & 1 deletion meridian-api/src/auth/dto/resend-verification.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { IsEmail, IsNotEmpty, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

/** Maximum email length (RFC 5321 §4.5.3.1). */
const MAX_EMAIL_LENGTH = 254;

export class ResendVerificationDto {
@IsEmail()
@IsNotEmpty()
@MaxLength(MAX_EMAIL_LENGTH)
@ApiProperty({
description: 'Email address to resend the verification link to.',
example: 'john.doe@example.com',
maxLength: MAX_EMAIL_LENGTH,
})
email: string;
}
18 changes: 16 additions & 2 deletions meridian-api/src/auth/dto/sign-in.dto.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

/** Maximum email length (RFC 5321 §4.5.3.1). */
const MAX_EMAIL_LENGTH = 254;

/** Maximum password length — bcrypt silently truncates at 72 bytes. */
const MAX_PASSWORD_LENGTH = 72;

export class SignInDto {
@IsEmail()
@IsNotEmpty()
@MaxLength(MAX_EMAIL_LENGTH)
@ApiProperty({
description: 'Email address of the user',
example: 'john.doe@example.com',
maxLength: MAX_EMAIL_LENGTH,
})
email: string;

@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Password of the user', example: 'Password123!' })
@MinLength(1)
@MaxLength(MAX_PASSWORD_LENGTH)
@ApiProperty({
description: 'Password of the user',
example: 'Password123!',
maxLength: MAX_PASSWORD_LENGTH,
})
password: string;
}
7 changes: 6 additions & 1 deletion meridian-api/src/auth/dto/verify-email.dto.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
import { IsNotEmpty, IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

/** Verification tokens are 64-char hex; cap at 256 to reject oversized payloads. */
const MAX_TOKEN_LENGTH = 256;

export class VerifyEmailDto {
@IsString()
@IsNotEmpty()
@MinLength(32)
@MaxLength(MAX_TOKEN_LENGTH)
@ApiProperty({
description:
'Raw verification token delivered in the signup email. 32-byte hex string.',
example:
'9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7',
maxLength: MAX_TOKEN_LENGTH,
})
token: string;
}
Loading