diff --git a/GUIDELINES_NEST_REFERENCE_APP.md b/GUIDELINES_NEST_REFERENCE_APP.md index 71c8797..e35ac44 100644 --- a/GUIDELINES_NEST_REFERENCE_APP.md +++ b/GUIDELINES_NEST_REFERENCE_APP.md @@ -1,13 +1,13 @@ # GUIDELINES_NEST_REFERENCE_APP.md -## Core Philosophy - This app SERVES the seven nest-native libraries +## Core Philosophy - This app SERVES the eight nest-native libraries This is `nest-native/reference-app`: a production-shaped, multi-tenant -work-tracking SaaS whose only job is to put **all seven +work-tracking SaaS whose only job is to put **all eight [nest-native](https://github.com/nest-native) libraries** — `drizzle`, `trpc`, -`messaging`, `kafka`, `jobs`, `asyncapi`, and `ai-sdk` — under realistic -backend pressure and show how they compose. It is **read first and run -second**. Every decision optimizes for a reader who wants to lift a pattern +`messaging`, `kafka`, `jobs`, `asyncapi`, `ai-sdk`, and `lockout` — under +realistic backend pressure and show how they compose. It is **read first and +run second**. Every decision optimizes for a reader who wants to lift a pattern into their own app. Follow these assumptions in **EVERY** file you generate or modify. This is the @@ -15,10 +15,10 @@ app's constitution. ### 1. What This App Is (and Is Not) -- It **serves the libraries**. Every module exists to exercise one of the seven +- It **serves the libraries**. Every module exists to exercise one of the eight under realistic pressure — persistence, a typed API, reliable domain events, an event backbone, deferred jobs, a documented event catalog, a streaming AI - assistant. + assistant, and brute-force login lockout. - It is **not** a product, **not** a starter or scaffold, and **not** a generic NestJS boilerplate. There is no CLI, no second frontend, no multi-database story, no GraphQL — those are deliberate omissions, not gaps to fill. diff --git a/README.md b/README.md index 31552cb..ed17027 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@

License Node version - All seven nest-native libraries + All eight nest-native libraries

-A production-shaped **multi-tenant work-tracking SaaS** — think a small Linear/Asana — that puts **all seven [nest-native](https://github.com/nest-native) libraries** under realistic backend pressure and composes them the way a real product would: persistence, a typed API, reliable domain events, an event backbone, deferred background jobs, a documented event catalog, and a streaming AI assistant. +A production-shaped **multi-tenant work-tracking SaaS** — think a small Linear/Asana — that puts **all eight [nest-native](https://github.com/nest-native) libraries** under realistic backend pressure and composes them the way a real product would: persistence, a typed API, reliable domain events, an event backbone, deferred background jobs, a documented event catalog, a streaming AI assistant, and brute-force login lockout. It is not a product. It **serves the libraries** — a credible demo a team could adapt, and a feedback loop: if something feels awkward here, that's signal to add API in a separate PR to the relevant library (that's how, for example, `MessagingModule.forRoot`'s `imports` option and `KafkaInboxConsumer`'s `dedupKey` argument came to exist). @@ -21,7 +21,7 @@ Follow one journey through the code and every library shows up where a real syst 5. **The event contracts are published.** An **AsyncAPI 3.0 catalog** at `/asyncapi` documents every event so another team could subscribe to your streams. 6. **AI reads the activity.** A streaming **project assistant** turns a project's recent activity into a status update, token by token. -## Seven libraries, seven chapters +## Eight libraries, eight chapters | Library | Its job in the story | Where in the code | | --- | --- | --- | @@ -32,6 +32,7 @@ Follow one journey through the code and every library shows up where a real syst | [`@nest-native/jobs`](https://github.com/nest-native/jobs) | **Deferred work** — the assignment reminder: enqueued in the same transaction as the `task.assigned` projection (`uniqueKey` = the event's dedup key), executed exactly once by the worker | `src/modules/reminders/`, `TaskAssignedProjection` in `src/modules/activity/`, `src/database/schema/jobs.ts` | | [`@nest-native/asyncapi`](https://github.com/nest-native/asyncapi) | **The event catalog** — an AsyncAPI 3.0 doc so other teams integrate with your streams | `src/modules/events-catalog/`, served at `/asyncapi` from `src/main.ts` | | [`@nest-native/ai-sdk`](https://github.com/nest-native/ai-sdk) | **AI over your data** — a streaming assistant that summarizes a project's activity | `src/modules/assistant/` (`@AiStream`), `POST /projects/:projectId/assistant` | +| [`@nest-native/lockout`](https://github.com/nest-native/lockout) | **Brute-force defense** — failed logins lock an identity (by email OR source IP) *before* the credential is checked; a Drizzle-backed counter on the same SQLite DB, `Retry-After` on the lock, reset on success. tRPC has no HTTP body for the guard's extractor to read, so the login handler calls `LockoutService` directly — the library's documented non-HTTP-transport recipe | `src/auth/lockout.setup.ts` (`LockoutModule.forRootAsync`), `src/auth/auth.service.ts` (`check` → `reportFailure`/`reportSuccess`) | Underneath, [`@nestjs-cls/transactional`](https://www.npmjs.com/package/@nestjs-cls/transactional) (with the official Drizzle adapter) ties the outbox write to the business write — its `transactionMode: 'auto'` runs the same `@Transactional()` code against better-sqlite3's synchronous driver locally and an async driver in production. @@ -90,7 +91,7 @@ src/ app.module.ts Root module, ClsPluginTransactional, in-process/Kafka messaging profiles config/env.ts loadEnv() — single source of truth (incl. the optional kafka block) database/ DrizzleModule wiring + schema (orgs/users/projects/tasks/activity/...) + migrations - auth/ scrypt passwords, HS256 JWT, AuthGuard, middleware + auth/ scrypt passwords, HS256 JWT, AuthGuard, middleware; @nest-native/lockout login lockout (lockout.setup.ts) context/ request-scoped CURRENT_USER / CURRENT_ORGANIZATION modules/ organizations/ users/ projects/ repos + services + tRPC routers (tenant-scoped) @@ -116,7 +117,7 @@ npm run test:cov # with c8 coverage npm run ci # typecheck, lint, complexity (≤15), test:cov, security:audit, build ``` -Coverage here is **pragmatic, not 100%** — the 100% bar belongs to the libraries. The transactional workflow, the outbox worker, the inbox dedup, the reminder job's exactly-once scheduling and execution, the AsyncAPI catalog, and the AI stream all have explicit tests. CI runs on **Node 22**. +Coverage here is **pragmatic, not 100%** — the 100% bar belongs to the libraries. The transactional workflow, the outbox worker, the inbox dedup, the reminder job's exactly-once scheduling and execution, the AsyncAPI catalog, the AI stream, and the login-lockout gate (fail N times → 429, even the right password is refused while locked) all have explicit tests. CI runs on **Node 22**. Two **optional, local-only** layers sit on top (neither runs in CI, and forks work without them): @@ -141,6 +142,7 @@ ritual, and agent instructions — in | NestJS | `11.x` | | `@nest-native/drizzle` | `0.3.x` · `@nest-native/trpc` `0.6.x` · `@nest-native/kafka` `0.2.x` | | `@nest-native/messaging` | `0.2.x` · `@nest-native/asyncapi` `0.1.x` · `@nest-native/ai-sdk` `0.4.x` (on `ai@7`) | +| `@nest-native/lockout` | `0.3.x` (on `@authlock/core` `0.3.x`) | | Drizzle ORM | `0.45.x` · `@nestjs-cls/transactional` `^3` | ## Philosophy diff --git a/package-lock.json b/package-lock.json index 274ed38..183ddf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,13 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@ai-sdk/openai": "4.0.11", + "@authlock/core": "^0.3.0", "@nest-native/ai-sdk": "^0.5.0", "@nest-native/asyncapi": "^0.2.0", "@nest-native/drizzle": "^0.4.0", "@nest-native/jobs": "^0.1.0", "@nest-native/kafka": "^0.3.0", + "@nest-native/lockout": "^0.3.1", "@nest-native/messaging": "^0.3.1", "@nest-native/trpc": "^0.6.0", "@nestjs-cls/transactional": "^3.2.1", @@ -212,6 +213,23 @@ "@types/json-schema": "^7.0.11" } }, + "node_modules/@authlock/core": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@authlock/core/-/core-0.3.0.tgz", + "integrity": "sha512-u31tgHriUG9YwoeKHyVh9b/cqSmNXwgr+zDTdF49+hOdXqyvbpwYGcOjrG8pRSJmXqhNkcNKEUgdwggW0uCgbQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "drizzle-orm": "^0.44.0 || ^0.45.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2518,6 +2536,24 @@ } } }, + "node_modules/@nest-native/lockout": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@nest-native/lockout/-/lockout-0.3.1.tgz", + "integrity": "sha512-dLBHPAAV15WYCMC6UfDOAp4zN4r9K8VZf+0IfgAd5kn3wxvu3wkNHJCpg1NYJtwIIPKR2vxujjDUxss5dRKXYw==", + "license": "MIT", + "dependencies": { + "@authlock/core": "0.3.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.0.0" + } + }, "node_modules/@nest-native/messaging": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@nest-native/messaging/-/messaging-0.3.1.tgz", diff --git a/package.json b/package.json index 327951a..1dfd581 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,13 @@ "test:mutant:full": "npm run test:mutant && npm run test:kafka:mutant" }, "dependencies": { + "@authlock/core": "^0.3.0", "@nest-native/ai-sdk": "^0.5.0", "@nest-native/asyncapi": "^0.2.0", "@nest-native/drizzle": "^0.4.0", "@nest-native/jobs": "^0.1.0", "@nest-native/kafka": "^0.3.0", + "@nest-native/lockout": "^0.3.1", "@nest-native/messaging": "^0.3.1", "@nest-native/trpc": "^0.6.0", "@nestjs-cls/transactional": "^3.2.1", diff --git a/src/auth/auth-context.ts b/src/auth/auth-context.ts index 0aae305..1c63752 100644 --- a/src/auth/auth-context.ts +++ b/src/auth/auth-context.ts @@ -15,5 +15,8 @@ export interface AuthContext { export interface AuthenticatedRequest { headers: Record; + /** Connection address (populated by the HTTP platform). NOT a proxy header. */ + ip?: string; + socket?: { remoteAddress?: string }; authContext?: AuthContext; } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index c389194..462e9b5 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -10,9 +10,10 @@ import { AuthGuard } from './auth.guard'; import { AuthMiddleware } from './auth.middleware'; import { AuthRouter } from './auth.router'; import { AuthService } from './auth.service'; +import { AppLockoutModule } from './lockout.setup'; @Module({ - imports: [DatabaseModule], + imports: [DatabaseModule, AppLockoutModule], providers: [ { provide: AUTH_CONFIG, diff --git a/src/auth/auth.router.ts b/src/auth/auth.router.ts index d5db06c..eddd32d 100644 --- a/src/auth/auth.router.ts +++ b/src/auth/auth.router.ts @@ -27,8 +27,11 @@ export class AuthRouter { constructor(@Inject(AuthService) private readonly auth: AuthService) {} @Mutation({ input: LoginInputSchema, output: LoginOutputSchema }) - login(@Input() input: z.infer) { - return this.auth.login(input); + login( + @Input() input: z.infer, + @TrpcContext('ip') ip: string | undefined, + ) { + return this.auth.login(input, ip); } @Query({ output: MeOutputSchema }) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3ba059c..07f1c3e 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,6 +1,13 @@ -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { + HttpException, + HttpStatus, + Inject, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; import { eq } from 'drizzle-orm'; import { InjectDrizzle } from '@nest-native/drizzle'; +import { LockoutService } from '@nest-native/lockout'; import type { AppDatabase } from '../database/database'; import { memberships, users } from '../database/schema'; import { AUTH_CONFIG, type AuthConfig } from './auth.config'; @@ -25,17 +32,45 @@ export class AuthService { constructor( @InjectDrizzle() private readonly db: AppDatabase, @Inject(AUTH_CONFIG) private readonly config: AuthConfig, + // Explicit token like every other provider here: esbuild/tsx doesn't emit + // `design:paramtypes`, so this app never relies on type-based DI. + @Inject(LockoutService) private readonly lockout: LockoutService, ) {} - login(input: LoginInput): LoginResult { + async login( + input: LoginInput, + ip: string | undefined, + ): Promise { + // Lock by email OR source IP. `email` is a custom identity dimension; the IP + // comes from the tRPC context (see trpc.module.ts) — do NOT trust proxy + // headers unless the platform's `trust proxy` is configured for them. + const identity = { email: input.email, ip }; + + // Pre-auth gate: reject a locked identity before touching the credential. + const gate = await this.lockout.check(identity); + if (gate.locked) { + // @nest-native/trpc maps this HttpException to a TRPCError('TOO_MANY_REQUESTS'). + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + error: 'Too Many Requests', + message: 'Too many failed login attempts. Try again later.', + retryAfterMs: gate.retryAfterMs, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + const user = this.db .select() .from(users) .where(eq(users.email, input.email)) .get(); if (!user || !verifyPassword(input.password, user.passwordHash)) { + await this.lockout.reportFailure(identity); throw new UnauthorizedException('Invalid email or password'); } + await this.lockout.reportSuccess(identity); const membership = this.db .select() diff --git a/src/auth/lockout.setup.ts b/src/auth/lockout.setup.ts new file mode 100644 index 0000000..8f8cda6 --- /dev/null +++ b/src/auth/lockout.setup.ts @@ -0,0 +1,40 @@ +import { Logger, Module } from '@nestjs/common'; +import { SqliteLockoutStore } from '@authlock/core/sqlite'; +import { getDrizzleClientToken } from '@nest-native/drizzle'; +import { LockoutModule } from '@nest-native/lockout'; +import { loadEnv } from '../config/env'; +import type { AppDatabase } from '../database/database'; +import { lockoutAttempts } from '../database/schema'; + +/** + * Wires @authlock/core into the app: a Drizzle-backed lockout store on the SAME + * SQLite database as everything else (its atomic increment counts failed logins + * exactly once, even across instances), keyed by email OR source IP. The counter + * is written on the base Drizzle connection — deliberately NOT inside the request + * transaction — so a failed attempt is recorded even if the login transaction + * rolls back. `LockoutService` is global (LockoutModule default), so AuthService + * injects it directly. + */ +@Module({ + imports: [ + LockoutModule.forRootAsync({ + // The drizzle client token is global (see DatabaseModule), so the store + // factory resolves the base Drizzle instance directly. + inject: [getDrizzleClientToken()], + useFactory: (db: AppDatabase) => { + const env = loadEnv(); + const logger = new Logger('Lockout'); + return { + store: new SqliteLockoutStore(db, lockoutAttempts), + limit: env.lockoutLimit, + cooloffMs: env.lockoutCooloffMs, + // A lock trips if EITHER the email OR the source IP crosses the limit. + parameters: [['email'], ['ip']], + logger: (error, context) => + logger.error(`lockout store error (${context})`, error), + }; + }, + }), + ], +}) +export class AppLockoutModule {} diff --git a/src/config/env.ts b/src/config/env.ts index 23d7f12..40ec48f 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -21,6 +21,10 @@ export interface AppEnv { trpcPath: string; authSecret: string; authTtlSeconds: number; + /** Login lockout: failures allowed before an identity is locked. */ + lockoutLimit: number; + /** Login lockout: how long a locked identity stays locked, in ms. */ + lockoutCooloffMs: number; outbox: { pollIntervalMs: number; batchSize: number; @@ -125,6 +129,8 @@ export function loadEnv(): AppEnv { trpcPath: process.env.TRPC_PATH ?? '/trpc', authSecret: readAuthSecret(nodeEnv), authTtlSeconds: Number.parseInt(process.env.AUTH_TTL_SECONDS ?? '3600', 10), + lockoutLimit: readIntFromEnv('LOCKOUT_LIMIT', 5), + lockoutCooloffMs: readIntFromEnv('LOCKOUT_COOLOFF_MS', 15 * 60_000), outbox: { pollIntervalMs: readIntFromEnv('OUTBOX_POLL_MS', 2_000), batchSize: readIntFromEnv('OUTBOX_BATCH_SIZE', 32), diff --git a/src/database/migrations/0005_groovy_war_machine.sql b/src/database/migrations/0005_groovy_war_machine.sql new file mode 100644 index 0000000..b52944e --- /dev/null +++ b/src/database/migrations/0005_groovy_war_machine.sql @@ -0,0 +1,6 @@ +CREATE TABLE `lockout_attempts` ( + `key` text PRIMARY KEY NOT NULL, + `failures` integer NOT NULL, + `first_failure_at` integer NOT NULL, + `last_failure_at` integer NOT NULL +); diff --git a/src/database/migrations/meta/0005_snapshot.json b/src/database/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..a51888c --- /dev/null +++ b/src/database/migrations/meta/0005_snapshot.json @@ -0,0 +1,932 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b32423b0-dc5f-4cc6-b364-565cd3ea17cd", + "prevId": "ba4c98a7-99f7-4d8a-a44e-4c36f58c4da4", + "tables": { + "activity_events": { + "name": "activity_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dedup_key": { + "name": "dedup_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "activity_events_org_dedup_key_unique": { + "name": "activity_events_org_dedup_key_unique", + "columns": [ + "org_id", + "dedup_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "activity_events_org_id_organizations_id_fk": { + "name": "activity_events_org_id_organizations_id_fk", + "tableFrom": "activity_events", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_events_actor_user_id_users_id_fk": { + "name": "activity_events_actor_user_id_users_id_fk", + "tableFrom": "activity_events", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_events": { + "name": "audit_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_events_org_id_organizations_id_fk": { + "name": "audit_events_org_id_organizations_id_fk", + "tableFrom": "audit_events", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_events_actor_user_id_users_id_fk": { + "name": "audit_events_actor_user_id_users_id_fk", + "tableFrom": "audit_events", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inbox_events": { + "name": "inbox_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_key": { + "name": "message_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "inbox_events_source_message_key_unique": { + "name": "inbox_events_source_message_key_unique", + "columns": [ + "source", + "message_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "unique_key": { + "name": "unique_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "jobs_name_unique_key_unique": { + "name": "jobs_name_unique_key_unique", + "columns": [ + "name", + "unique_key" + ], + "isUnique": true + }, + "jobs_status_available_idx": { + "name": "jobs_status_available_idx", + "columns": [ + "status", + "available_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lockout_attempts": { + "name": "lockout_attempts", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "failures": { + "name": "failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_failure_at": { + "name": "first_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_org_user_unique": { + "name": "memberships_org_user_unique", + "columns": [ + "org_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_org_id_organizations_id_fk": { + "name": "memberships_org_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "outbox_events": { + "name": "outbox_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "available_at": { + "name": "available_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "outbox_events_idempotency_key_unique": { + "name": "outbox_events_idempotency_key_unique", + "columns": [ + "idempotency_key" + ], + "isUnique": true, + "where": "\"outbox_events\".\"idempotency_key\" IS NOT NULL" + }, + "outbox_events_status_available_idx": { + "name": "outbox_events_status_available_idx", + "columns": [ + "status", + "available_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_org_id_organizations_id_fk": { + "name": "projects_org_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_org_id_organizations_id_fk": { + "name": "tasks_org_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_created_by_users_id_fk": { + "name": "tasks_created_by_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/database/migrations/meta/_journal.json b/src/database/migrations/meta/_journal.json index f9ceb8b..ddd56a2 100644 --- a/src/database/migrations/meta/_journal.json +++ b/src/database/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1783187120222, "tag": "0004_loud_corsair", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1784415825690, + "tag": "0005_groovy_war_machine", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/database/schema/index.ts b/src/database/schema/index.ts index 9e33302..40f2aac 100644 --- a/src/database/schema/index.ts +++ b/src/database/schema/index.ts @@ -2,6 +2,7 @@ import { activityEvents } from './activity'; import { auditEvents } from './audit-events'; import { inboxEvents } from './inbox-events'; import { jobs } from './jobs'; +import { lockoutAttempts } from './lockout-attempts'; import { memberships } from './memberships'; import { organizations } from './organizations'; import { outboxEvents } from './outbox-events'; @@ -20,6 +21,7 @@ export const schema = { outboxEvents, inboxEvents, jobs, + lockoutAttempts, }; export { @@ -27,6 +29,7 @@ export { auditEvents, inboxEvents, jobs, + lockoutAttempts, memberships, organizations, outboxEvents, @@ -40,6 +43,7 @@ export { INBOX_STATUSES } from './inbox-events'; export type { AuditEvent, NewAuditEvent } from './audit-events'; export type { Job, JobStatus, NewJob } from './jobs'; export { JOB_STATUSES } from './jobs'; +export type { LockoutAttempt, NewLockoutAttempt } from './lockout-attempts'; export type { Membership, MembershipRole, diff --git a/src/database/schema/lockout-attempts.ts b/src/database/schema/lockout-attempts.ts new file mode 100644 index 0000000..b3d9b4e --- /dev/null +++ b/src/database/schema/lockout-attempts.ts @@ -0,0 +1,11 @@ +// The lockout counter table ships from @authlock/core so the app and the engine +// reason about byte-identical DDL (key PK, failures, first_failure_at, +// last_failure_at) — the same precedent as the jobs and messaging tables. The +// engine's SqliteLockoutStore is constructed with this exact table instance +// (see auth/lockout.setup.ts), so it is created once here and shared. +import { sqliteLockoutTable } from '@authlock/core/sqlite'; + +export const lockoutAttempts = sqliteLockoutTable('lockout_attempts'); + +export type LockoutAttempt = typeof lockoutAttempts.$inferSelect; +export type NewLockoutAttempt = typeof lockoutAttempts.$inferInsert; diff --git a/src/trpc/trpc.module.ts b/src/trpc/trpc.module.ts index adf5cf5..0c3d688 100644 --- a/src/trpc/trpc.module.ts +++ b/src/trpc/trpc.module.ts @@ -15,6 +15,8 @@ const env = loadEnv(); export interface AppTrpcContext { authContext: AuthContext | null; + /** Source IP, exposed so the login procedure can key lockout on it. */ + ip: string | undefined; } const logger = new Logger('Trpc'); @@ -34,6 +36,7 @@ const PUBLIC_CACHEABLE_QUERIES = new Set(['ping']); autoSchemaFile: join(process.cwd(), 'src/@generated/server.ts'), createContext: ({ req }: { req: AuthenticatedRequest }) => ({ authContext: req.authContext ?? null, + ip: req.ip ?? req.socket?.remoteAddress, }), // superjson (de)serializes every payload, so `Date` fields — e.g. the diff --git a/test/e2e/auth-lockout.spec.ts b/test/e2e/auth-lockout.spec.ts new file mode 100644 index 0000000..17ad37d --- /dev/null +++ b/test/e2e/auth-lockout.spec.ts @@ -0,0 +1,86 @@ +import 'reflect-metadata'; +import { strict as assert } from 'node:assert'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import type { INestApplication } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import superjson from 'superjson'; +import type { SuperJSONResult } from 'superjson'; +import { seedDatabase } from '../../scripts/seed'; + +const trpcPath = '/trpc'; +const AUTH_SECRET = 'e2e-lockout-secret-must-be-at-least-32-characters'; +const LIMIT = 3; + +let app: INestApplication; +let baseUrl: string; + +before(async () => { + const dbPath = join( + tmpdir(), + `nest-native-reference-app-e2e-lockout-${process.pid}-${Date.now()}.db`, + ); + process.env.DATABASE_URL = dbPath; + process.env.TRPC_PATH = trpcPath; + process.env.AUTH_SECRET = AUTH_SECRET; + // Lock after a few failures so the test is fast and deterministic. + process.env.LOCKOUT_LIMIT = String(LIMIT); + process.env.LOCKOUT_COOLOFF_MS = String(15 * 60_000); + + seedDatabase(dbPath); + + const { AppModule } = await import('../../src/app.module'); + app = await NestFactory.create(AppModule, { logger: false }); + await app.listen(0, '127.0.0.1'); + baseUrl = await app.getUrl(); +}); + +after(async () => { + await app.close(); +}); + +interface TrpcSuccess { + result?: unknown; + error?: SuperJSONResult; +} + +/** Attempt a login and return the resulting HTTP status (200 on success). */ +async function loginStatus(email: string, password: string): Promise { + const response = await fetch(`${baseUrl}${trpcPath}/auth.login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(superjson.serialize({ email, password })), + }); + const body = (await response.json()) as TrpcSuccess; + if (body.result !== undefined) { + return 200; + } + const shape = superjson.deserialize<{ data: { httpStatus: number } }>( + body.error as SuperJSONResult, + ); + return shape.data.httpStatus; +} + +test('locks an identity after the failure limit and refuses even the correct password', async () => { + const email = 'admin@acme.test'; // the seeded admin + + // The first LIMIT wrong-password attempts are rejected as invalid (401) and + // counted; the identity is not yet locked. + for (let attempt = 1; attempt <= LIMIT; attempt += 1) { + assert.equal( + await loginStatus(email, 'wrong-password'), + 401, + `attempt ${attempt} should be 401 (invalid credentials)`, + ); + } + + // Now locked: even the CORRECT password is refused with 429 Too Many Requests. + // This is the whole point — the brute-force control gates before the + // credential check. + assert.equal( + await loginStatus(email, 'admin123!'), + 429, + 'a locked identity is refused with 429 even with the right password', + ); +});