diff --git a/docs/catalog.json b/docs/catalog.json index 01510b4..708026c 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -143,6 +143,7 @@ "tables": [ "account", "session", + "sms_otp_session", "two_factor", "user", "verification" @@ -156,6 +157,8 @@ "identity.login", "identity.logout", "identity.me", + "identity.phoneLoginRequest", + "identity.phoneLoginVerify", "identity.register", "identity.requestPasswordReset", "identity.resetPassword", @@ -492,6 +495,16 @@ "packages/core/src/casino/gaming/plugin.ts" ] }, + { + "category": "sms", + "interface": "SmsAdapter", + "token": "SMS_ADAPTER", + "status": "wired", + "boundIn": [ + "packages/core/src/pam/identity/adapters/mock/mock-sms-adapter.ts", + "packages/core/src/pam/identity/plugin.ts" + ] + }, { "category": "wallet-commands", "interface": "WalletDebitArgs", @@ -536,6 +549,8 @@ "identity.2fa.enabled", "identity.email.verified", "identity.password.reset", + "identity.phone_otp.cancelled", + "identity.phone_otp.requested", "identity.profile.updated", "identity.session.revoked", "identity.sessions.revoked_all", @@ -544,6 +559,7 @@ "identity.user.login", "identity.user.login.failed", "identity.user.logout", + "identity.user.phone_login", "identity.user.reactivated", "identity.user.registered", "identity.user.unlocked", @@ -717,6 +733,10 @@ "name": "Disable2faInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" }, + { + "name": "E164PhoneSchema", + "file": "packages/core/src/contracts/schemas/identity.ts" + }, { "name": "EffectivePermissionsSchema", "file": "packages/core/src/iam/contract/index.ts" @@ -901,6 +921,18 @@ "name": "PermissionLevelSchema", "file": "packages/core/src/contracts/schemas/iam.ts" }, + { + "name": "PhoneLoginRequestInputSchema", + "file": "packages/core/src/contracts/schemas/identity.ts" + }, + { + "name": "PhoneLoginRequestOutputSchema", + "file": "packages/core/src/contracts/schemas/identity.ts" + }, + { + "name": "PhoneLoginVerifyInputSchema", + "file": "packages/core/src/contracts/schemas/identity.ts" + }, { "name": "PlatformConfigSchema", "file": "packages/core/src/contracts/schemas/platform-config.ts" @@ -1283,6 +1315,8 @@ "POST /identity/password/change", "POST /identity/password/forgot", "POST /identity/password/reset", + "POST /identity/phone-login/request", + "POST /identity/phone-login/verify", "POST /identity/register", "POST /identity/sessions/revoke", "POST /identity/sessions/revoke-all", diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index e9466f6..1c0f6c1 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -160,6 +160,31 @@ function mapEventToRecord(topic: string, p: Record): RecordInpu }; } + // Player authenticated via SMS OTP. actorId = resource = the player; success outcome. + if (topic === 'identity.user.phone_login') { + return { + ...base, + actorType: 'player', + actorId: str(p['userId']), + resourceType: 'user', + resourceId: str(p['userId']), + result: 'success', + }; + } + + // A pending OTP was cancelled. `max_attempts` is a system-driven security event + // (guessing exhausted); recorded as a failure with the reason for the trail. + if (topic === 'identity.phone_otp.cancelled') { + return { + ...base, + actorType: 'system', + resourceType: 'user', + resourceId: str(p['userId']), + result: 'failure', + after: { reason: p['reason'] ?? null }, + }; + } + // A lockout is a system-driven security control: the subject is the resource, not the // actor, and it is a failure outcome (the base regex would otherwise mark it success). if (topic === 'identity.user.lockout.triggered') { @@ -291,6 +316,8 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'identity.user.lockout.triggered', 'identity.user.unlocked', 'identity.user.logout', + 'identity.user.phone_login', + 'identity.phone_otp.cancelled', 'identity.2fa.enabled', 'identity.2fa.disabled', 'identity.password.reset', diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index b8d3f08..e579843 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -93,6 +93,9 @@ export { RNG_ADAPTER } from './rng.js'; export type { SendEmailPort } from './email.js'; export { SEND_EMAIL } from './email.js'; +export type { SmsAdapter } from './sms.js'; +export { SMS_ADAPTER } from './sms.js'; + export type { AdminGrant, AdminPermissionResolver } from './admin-permission.js'; export { ADMIN_PERMISSION_RESOLVER } from './admin-permission.js'; diff --git a/packages/core/src/contracts/adapters/sms.ts b/packages/core/src/contracts/adapters/sms.ts new file mode 100644 index 0000000..3c863b3 --- /dev/null +++ b/packages/core/src/contracts/adapters/sms.ts @@ -0,0 +1,11 @@ +// SMS delivery seam. The phone-login OTP flow sends codes through this adapter so the +// transport is swappable: the platform default (bound in identity/plugin.ts) is a +// log-to-stdout mock, safe for dev/stage. A consumer overlay rebinds SMS_ADAPTER to a +// real vendor (Twilio, AWS SNS) in production. See AGENTS.md "third-party integration". +import { createToken, type Token } from './token.js'; + +export type SmsAdapter = { + sendOtp(params: { to: string; code: string }): Promise; +}; + +export const SMS_ADAPTER: Token = createToken('SMS_ADAPTER'); diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 0b42192..33a8af4 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -38,8 +38,25 @@ export const domainEventSchemas = { 'identity.user.login.failed': authContextBase.extend({ email: z.email(), reason: z.string().nullable().optional(), + // Remaining credential attempts before lockout - surfaced so the client can show a + // "Forgot password?" CTA as the count runs down. Absent on non-credential errors. + attemptsRemaining: z.number().int().optional(), }), 'identity.user.logout': z.object({ userId: UuidSchema }), + // A player authenticated via SMS OTP (phone login). TOTP 2FA does not apply here; + // the session is minted directly. `method` is a literal so the audit log can + // distinguish it from a password login. + 'identity.user.phone_login': authContextBase.extend({ + userId: UuidSchema, + method: z.literal('phone'), + }), + // A pending OTP was invalidated - either superseded by a fresh request or killed + // after too many wrong guesses (a security event when `max_attempts`). + 'identity.phone_otp.requested': z.object({ userId: UuidSchema }), + 'identity.phone_otp.cancelled': z.object({ + userId: UuidSchema, + reason: z.enum(['max_attempts', 'new_otp_requested']), + }), 'identity.user.lockout.triggered': authContextBase.extend({ userId: UuidSchema, email: z.email(), diff --git a/packages/core/src/contracts/schemas/identity.ts b/packages/core/src/contracts/schemas/identity.ts index 5721177..ee8f5c1 100644 --- a/packages/core/src/contracts/schemas/identity.ts +++ b/packages/core/src/contracts/schemas/identity.ts @@ -16,10 +16,35 @@ export const UserSchema = z.object({ image: z.url().nullable().optional(), theme: ThemeSchema, language: LanguageSchema, + // Phone-login fields. Optional so existing callers/serializers are unaffected; a + // player without a verified phone simply carries a null number and `false`. + phoneNumber: z.string().nullable().optional(), + phoneVerified: z.boolean().optional(), createdAt: TimestampSchema, updatedAt: TimestampSchema, }); +// E.164: '+' then a non-zero country digit and 7-14 more digits (8-15 total). +export const E164PhoneSchema = z.string().regex(/^\+[1-9]\d{7,14}$/); + +export const PhoneLoginRequestInputSchema = z.object({ phone: E164PhoneSchema }); + +export const PhoneLoginRequestOutputSchema = z.object({ + // When the OTP the caller just requested expires (now + 5 min). + expiresAt: TimestampSchema, + // The earliest a fresh OTP may be requested for this phone (now + 60 s). + resendAfter: TimestampSchema, +}); + +export const PhoneLoginVerifyInputSchema = z.object({ + phone: E164PhoneSchema, + code: z + .string() + .length(6) + .regex(/^\d{6}$/), + rememberMe: z.boolean().optional(), +}); + export const OrganizationSchema = z.object({ id: UuidSchema, name: z.string().min(1).max(255), @@ -117,3 +142,7 @@ export type VerifyEmailInput = z.infer; export type UpdateProfileInput = z.infer; export type ChangePasswordInput = z.infer; export type ChangeEmailInput = z.infer; +export type E164Phone = z.infer; +export type PhoneLoginRequestInput = z.infer; +export type PhoneLoginRequestOutput = z.infer; +export type PhoneLoginVerifyInput = z.infer; diff --git a/packages/core/src/pam/identity/AGENTS.md b/packages/core/src/pam/identity/AGENTS.md index e562411..0d80cea 100644 --- a/packages/core/src/pam/identity/AGENTS.md +++ b/packages/core/src/pam/identity/AGENTS.md @@ -2,6 +2,8 @@ better-auth-backed authentication + account lifecycle (register, login, logout, 2FA, password reset, email verification, profile). The engine's `SessionResolver`/`AdminGuard` verify sessions against this module's tables (injected, never imported - ADR-0019/0025). -Login lockout: a per-account credential-failure counter (`user.failed_login_attempts` + `lockout_until`); after `maxAttempts` (default 5) failures the account locks for `durationMs` (default 15min). Configurable via `IDENTITY_OPTIONS`. This is correctness-per-account, distinct from the rate limiter below. +Login lockout: a per-account credential-failure counter (`user.failed_login_attempts` + `lockout_until`); after `maxAttempts` (default 5) failures the account locks. The duration is progressive - a repeat lockout inside a rolling 24h window escalates 1min -> 5min -> 15min (`user.lockout_count` + `user.last_lockout_at`; the window resets once `last_lockout_at` falls outside it). `IDENTITY_OPTIONS.lockout.durationMs` is the tier-3+ fallback. `identity.user.login.failed` carries `attemptsRemaining` so the client can prompt a password reset as the count runs down. This is correctness-per-account, distinct from the rate limiter below. + +Phone login (SMS OTP): a standalone login method for players with a verified phone (`user.phone_number` E.164 + `user.phone_verified`; phone management routes are a separate story). `POST /identity/phone-login/request` sends a 6-digit code via the `SMS_ADAPTER` port (default `MockSmsAdapter` logs to stdout; an overlay rebinds it to Twilio/SNS) - anti-enumeration: it returns the same `{ expiresAt, resendAfter }` whether or not the phone is registered, with a 60s resend cooldown and one live code per user (SHA-256 hashed in `sms_otp_session`, 5min TTL). `POST /identity/phone-login/verify` exchanges a valid code for a session, minted DIRECTLY in the `session` table to bypass better-auth's TOTP plugin chain - TOTP 2FA does NOT apply to phone login by design. Wrong codes increment `failed_attempts` (5-attempt cap, then the session is cancelled). The RG login block is enforced after the OTP verifies, same as password login. Rate limits: request 3/15min, verify 10/5min (both fail closed). Events: `identity.user.phone_login`, `identity.phone_otp.requested`, `identity.phone_otp.cancelled`; the login + max-attempts cancel are audited. Rate limiting: `login`, `register`, `requestPasswordReset`, `resetPassword`, `verify2fa`, and `sendEmailVerification` consume a per-identifier budget via the `RATE_LIMITER` port (keyed by normalized email/token/session, never IP) before doing work - exceeding it throws a 429 (`TOO_MANY_REQUESTS`) with `retryAfterMs`. Defaults are named constants in `identity.service.ts` (login 10/5min, register 5/15min, reset-request 3/15min, reset 5/15min, verify2fa 5/5min, email-verification 3/15min); an overlay rebinds `RATE_LIMITER` to a Redis backend to change policy backend, not the numbers. diff --git a/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts b/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts new file mode 100644 index 0000000..ae6bb68 --- /dev/null +++ b/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts @@ -0,0 +1,223 @@ +import { createHash } from 'node:crypto'; +import { describe, it, expect, vi } from 'vitest'; +import { ORPCError } from '@orpc/server'; +import { PhoneLoginService } from '../service/phone-login.service.js'; +import { makeDrizzle, makeEvents, mock } from '../../../testing/mock.js'; +import type { DrizzleService, EventBus } from '@openora/core/server'; +import type { RateLimiterAdapter, SmsAdapter } from '@openora/core/contracts'; + +const PHONE = '+14155550100'; + +// A verified-phone user row as `verifyOtp`'s `select().from(user)` returns it. +const userRow = { + id: 'u1', + email: 'a@b.dev', + name: 'A', + emailVerified: true, + image: null, + theme: 'system', + language: 'en', + phoneNumber: PHONE, + phoneVerified: true, + rgBlocked: false, + rgBlockedUntil: null, + createdAt: new Date('2020-01-01T00:00:00.000Z'), + updatedAt: new Date('2020-01-01T00:00:00.000Z'), +}; + +const allowLimiter = (): RateLimiterAdapter => ({ + consume: vi.fn().mockResolvedValue({ allowed: true, retryAfterMs: 0 }), +}); + +function build({ + select = [], + returning = [], + sms = { sendOtp: vi.fn().mockResolvedValue(undefined) }, +}: { + select?: unknown[][]; + returning?: unknown[][]; + sms?: SmsAdapter; +} = {}) { + const drizzle = makeDrizzle({ + select: select as never, + returning: returning as never, + }); + const events = makeEvents(); + const svc = new PhoneLoginService({ + drizzle: drizzle as unknown as DrizzleService, + events: mock(events), + sms, + limiter: allowLimiter(), + }); + return { svc, events, sms }; +} + +const otpRow = (over: Record = {}) => ({ + id: 'otp1', + userId: 'u1', + codeHash: '', // set per-test + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + ...over, +}); + +// SHA-256 of a code, mirroring the service's hashCode. +const hash = (code: string) => createHash('sha256').update(code).digest('hex'); + +describe('PhoneLoginService.requestOtp', () => { + it('sends an OTP for a verified phone and emits requested', async () => { + // select order: (1) user lookup, (2) existing-otp lookup (none), + // (3) delete (awaited), (4) insert (awaited). + const { svc, events, sms } = build({ + select: [[{ id: 'u1' }], [], [], []], + }); + + const out = await svc.requestOtp({ phone: PHONE }); + + expect(typeof out.expiresAt).toBe('string'); + expect(typeof out.resendAfter).toBe('string'); + expect(sms.sendOtp).toHaveBeenCalledWith( + expect.objectContaining({ to: PHONE, code: expect.stringMatching(/^\d{6}$/) }), + ); + expect(events.emit).toHaveBeenCalledWith('identity.phone_otp.requested', { userId: 'u1' }); + }); + + it('anti-enumeration: unknown phone returns success shape without sending SMS', async () => { + const { svc, events, sms } = build({ select: [[]] }); + + const out = await svc.requestOtp({ phone: PHONE }); + + expect(out).toEqual({ expiresAt: expect.any(String), resendAfter: expect.any(String) }); + expect(sms.sendOtp).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it('throws OtpCooldownError when a code was sent under 60s ago', async () => { + const { svc, sms } = build({ + // (1) user found, (2) existing OTP created 10s ago. + select: [[{ id: 'u1' }], [{ createdAt: new Date(Date.now() - 10_000) }]], + }); + + await expect(svc.requestOtp({ phone: PHONE })).rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + }); + expect(sms.sendOtp).not.toHaveBeenCalled(); + }); + + it('supersedes an expired-cooldown prior OTP and emits cancelled(new_otp_requested)', async () => { + const { svc, events, sms } = build({ + // (1) user, (2) prior OTP older than cooldown, (3) delete, (4) insert. + select: [[{ id: 'u1' }], [{ createdAt: new Date(Date.now() - 120_000) }], [], []], + }); + + await svc.requestOtp({ phone: PHONE }); + + expect(events.emit).toHaveBeenCalledWith('identity.phone_otp.cancelled', { + userId: 'u1', + reason: 'new_otp_requested', + }); + expect(sms.sendOtp).toHaveBeenCalled(); + }); +}); + +describe('PhoneLoginService.verifyOtp', () => { + it('happy path: correct code mints a session and emits phone_login', async () => { + const code = '123456'; + const { svc, events } = build({ + // (1) otp lookup, (2) delete otp, (3) user lookup, (4) session insert. + select: [[otpRow({ codeHash: hash(code) })], [], [userRow], []], + }); + + const out = await svc.verifyOtp({ phone: PHONE, code }); + + expect(out.user.id).toBe('u1'); + expect(out.session.token).toEqual(expect.any(String)); + expect(out.session.expiresAt).toEqual(expect.any(String)); + expect(events.emit).toHaveBeenCalledWith( + 'identity.user.phone_login', + expect.objectContaining({ userId: 'u1', method: 'phone' }), + ); + }); + + it('rememberMe extends the session TTL to ~30 days', async () => { + const code = '123456'; + const { svc } = build({ + select: [[otpRow({ codeHash: hash(code) })], [], [userRow], []], + }); + + const out = await svc.verifyOtp({ phone: PHONE, code, rememberMe: true }); + const ttlMs = new Date(out.session.expiresAt).getTime() - Date.now(); + expect(ttlMs).toBeGreaterThan(29 * 24 * 60 * 60 * 1000); + }); + + it('wrong code increments failedAttempts and throws OtpInvalidError with attemptsRemaining', async () => { + const { svc, events } = build({ + // (1) otp lookup only (update uses returning, not the select queue). + select: [[otpRow({ codeHash: hash('000000') })]], + // update...returning -> 2 attempts used. + returning: [[{ failedAttempts: 2 }]], + }); + + const promise = svc.verifyOtp({ phone: PHONE, code: '111111' }); + await expect(promise).rejects.toBeInstanceOf(ORPCError); + await expect(promise).rejects.toMatchObject({ + code: 'UNPROCESSABLE_CONTENT', + data: { attemptsRemaining: 3 }, + }); + expect(events.emit).not.toHaveBeenCalledWith('identity.phone_otp.cancelled', expect.anything()); + }); + + it('5th wrong code cancels the session, deletes it, and emits cancelled(max_attempts)', async () => { + const { svc, events } = build({ + // (1) otp lookup, (2) delete (awaited). + select: [[otpRow({ codeHash: hash('000000') })], []], + returning: [[{ failedAttempts: 5 }]], + }); + + await expect(svc.verifyOtp({ phone: PHONE, code: '111111' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(events.emit).toHaveBeenCalledWith('identity.phone_otp.cancelled', { + userId: 'u1', + reason: 'max_attempts', + }); + }); + + it('expired OTP throws OtpInvalidError', async () => { + const code = '123456'; + const { svc } = build({ + select: [[otpRow({ codeHash: hash(code), expiresAt: new Date(Date.now() - 1000) })]], + }); + + await expect(svc.verifyOtp({ phone: PHONE, code })).rejects.toMatchObject({ + code: 'UNPROCESSABLE_CONTENT', + }); + }); + + it('missing OTP session throws OtpInvalidError', async () => { + const { svc } = build({ select: [[]] }); + await expect(svc.verifyOtp({ phone: PHONE, code: '123456' })).rejects.toMatchObject({ + code: 'UNPROCESSABLE_CONTENT', + }); + }); + + it('RG-blocked user is forbidden after the OTP passes and no session is minted', async () => { + const code = '123456'; + const { svc, events } = build({ + // (1) otp lookup, (2) delete otp, (3) blocked user lookup. + select: [ + [otpRow({ codeHash: hash(code) })], + [], + [{ ...userRow, rgBlocked: true, rgBlockedUntil: null }], + ], + }); + + await expect(svc.verifyOtp({ phone: PHONE, code })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(events.emit).toHaveBeenCalledWith( + 'rg.exclusion.login_blocked', + expect.objectContaining({ userId: 'u1' }), + ); + expect(events.emit).not.toHaveBeenCalledWith('identity.user.phone_login', expect.anything()); + }); +}); diff --git a/packages/core/src/pam/identity/adapters/mock/mock-sms-adapter.ts b/packages/core/src/pam/identity/adapters/mock/mock-sms-adapter.ts new file mode 100644 index 0000000..ae7643f --- /dev/null +++ b/packages/core/src/pam/identity/adapters/mock/mock-sms-adapter.ts @@ -0,0 +1,10 @@ +// Platform default: logs the OTP to stdout instead of sending a real SMS. Safe for +// dev/stage. Replace via overlay: ctx.provide(SMS_ADAPTER, () => new TwilioSmsAdapter()). +import type { SmsAdapter } from '@openora/core/contracts'; + +export class MockSmsAdapter implements SmsAdapter { + async sendOtp({ to, code }: { to: string; code: string }): Promise { + // mock: log-to-stdout delivery until a real SMS vendor adapter is bound via overlay. + console.log(`[SMS OTP] ${to}: ${code}`); + } +} diff --git a/packages/core/src/pam/identity/contract/index.ts b/packages/core/src/pam/identity/contract/index.ts index 3068703..3295da2 100644 --- a/packages/core/src/pam/identity/contract/index.ts +++ b/packages/core/src/pam/identity/contract/index.ts @@ -16,6 +16,9 @@ import { ChangeEmailInputSchema, IdentitySuccessSchema, TimestampSchema, + PhoneLoginRequestInputSchema, + PhoneLoginRequestOutputSchema, + PhoneLoginVerifyInputSchema, } from '@openora/core/contracts'; import { PageQuerySchema, paginated } from '@openora/core/contracts/kit'; import * as z from 'zod'; @@ -52,6 +55,16 @@ export const identityContract = { }), ), + phoneLoginRequest: oc + .route({ method: 'POST', path: '/identity/phone-login/request' }) + .input(PhoneLoginRequestInputSchema) + .output(PhoneLoginRequestOutputSchema), + + phoneLoginVerify: oc + .route({ method: 'POST', path: '/identity/phone-login/verify' }) + .input(PhoneLoginVerifyInputSchema) + .output(z.object({ user: UserSchema, session: SessionSchema })), + logout: oc.route({ method: 'POST', path: '/identity/logout' }).output(IdentitySuccessSchema), me: oc.route({ method: 'GET', path: '/identity/me' }).output(UserSchema.nullable()), diff --git a/packages/core/src/pam/identity/drizzle/migrations/0001_rainy_peter_quill.sql b/packages/core/src/pam/identity/drizzle/migrations/0001_rainy_peter_quill.sql new file mode 100644 index 0000000..fe07ca0 --- /dev/null +++ b/packages/core/src/pam/identity/drizzle/migrations/0001_rainy_peter_quill.sql @@ -0,0 +1,18 @@ +CREATE TABLE "sms_otp_session" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "phone" text NOT NULL, + "code_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "failed_attempts" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "phone_number" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "phone_verified" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "phone_verified_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "lockout_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "last_lockout_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "sms_otp_session" ADD CONSTRAINT "sms_otp_session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "sms_otp_session_user_id_idx" ON "sms_otp_session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "sms_otp_session_phone_idx" ON "sms_otp_session" USING btree ("phone"); diff --git a/packages/core/src/pam/identity/drizzle/migrations/meta/0001_snapshot.json b/packages/core/src/pam/identity/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..cd7e6b9 --- /dev/null +++ b/packages/core/src/pam/identity/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,684 @@ +{ + "id": "7a03cc2a-aa35-4f53-b068-e828048a76da", + "prevId": "65994818-a8b0-4408-8af1-3fb81ed897a9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sms_otp_session": { + "name": "sms_otp_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sms_otp_session_user_id_idx": { + "name": "sms_otp_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sms_otp_session_phone_idx": { + "name": "sms_otp_session_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sms_otp_session_user_id_user_id_fk": { + "name": "sms_otp_session_user_id_user_id_fk", + "tableFrom": "sms_otp_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + } + }, + "indexes": { + "two_factor_user_id_idx": { + "name": "two_factor_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "two_factor_secret_idx": { + "name": "two_factor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "user_theme", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'player'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_verified": { + "name": "phone_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "phone_verified_at": { + "name": "phone_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failed_login_attempts": { + "name": "failed_login_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lockout_until": { + "name": "lockout_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lockout_count": { + "name": "lockout_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_lockout_at": { + "name": "last_lockout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rg_blocked": { + "name": "rg_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rg_blocked_until": { + "name": "rg_blocked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_email_trgm_idx": { + "name": "user_email_trgm_idx", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_theme": { + "name": "user_theme", + "schema": "public", + "values": ["light", "dark", "system"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json b/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json index 7471d8b..6565b6b 100644 --- a/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1783555225473, "tag": "0000_curved_tana_nile", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1783611861777, + "tag": "0001_rainy_peter_quill", + "breakpoints": true } ] } diff --git a/packages/core/src/pam/identity/plugin.ts b/packages/core/src/pam/identity/plugin.ts index feea5e9..163a210 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -9,9 +9,12 @@ import { RATE_LIMITER, PLATFORM_CONFIG, SESSION_COMMANDS, + SMS_ADAPTER, } from '@openora/core/contracts'; import { definePlugin, ADMIN_GUARD, EVENT_BUS, DRIZZLE } from '@openora/core/server'; import { MockKycAdapter } from './adapters/mock/mock-kyc-adapter.js'; +import { MockSmsAdapter } from './adapters/mock/mock-sms-adapter.js'; +import { PhoneLoginService } from './service/phone-login.service.js'; import { DrizzleAdminUserDirectory } from './admin-user-directory.js'; import { IdentityReaderService } from './adapters/identity-reader.service.js'; import { createIdentityRouter } from './router/index.js'; @@ -23,6 +26,9 @@ export default definePlugin({ id: 'identity', register(ctx) { ctx.provide(KYC_ADAPTER, () => new MockKycAdapter()); + // Platform default SMS transport: logs the OTP to stdout. A consumer overlay + // rebinds SMS_ADAPTER to a real vendor (Twilio, AWS SNS) after this plugin. + ctx.provide(SMS_ADAPTER, () => new MockSmsAdapter()); // The back-office depends on this port, not on the identity schema directly. ctx.provide( ADMIN_USER_DIRECTORY, @@ -61,6 +67,12 @@ export default definePlugin({ platformConfig: c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG) : undefined, }), new SessionService({ drizzle: c.get(DRIZZLE), events: c.get(EVENT_BUS) }), + new PhoneLoginService({ + drizzle: c.get(DRIZZLE), + events: c.get(EVENT_BUS), + sms: c.get(SMS_ADAPTER), + limiter: c.get(RATE_LIMITER), + }), c.get(ADMIN_GUARD), c.get(EVENT_BUS), ), diff --git a/packages/core/src/pam/identity/router/index.ts b/packages/core/src/pam/identity/router/index.ts index b462d6d..af66bf1 100644 --- a/packages/core/src/pam/identity/router/index.ts +++ b/packages/core/src/pam/identity/router/index.ts @@ -1,6 +1,7 @@ import { implement } from '@orpc/server'; import { type OssContext, + type NodeHeaders, AdminGuard, mapErrors, getUserId, @@ -10,11 +11,25 @@ import { import { identityContract } from '../contract/index.js'; import { IdentityService } from '../service/identity.service.js'; import { SessionService } from '../service/session.service.js'; +import { PhoneLoginService } from '../service/phone-login.service.js'; import { UnsupportedLanguageError } from '../../shared/language.js'; +function nodeIp(headers: NodeHeaders): string | null { + const fwd = headers['x-forwarded-for']; + const first = Array.isArray(fwd) ? fwd[0] : fwd; + const real = headers['x-real-ip']; + return first?.split(',')[0]?.trim() || (Array.isArray(real) ? real[0] : real) || null; +} + +function nodeUa(headers: NodeHeaders): string | null { + const ua = headers['user-agent']; + return (Array.isArray(ua) ? ua[0] : ua) ?? null; +} + export function createIdentityRouter( identity: IdentityService, sessionSvc: SessionService, + phoneLogin: PhoneLoginService, adminGuard: AdminGuard, eventBus: EventBus, ) { @@ -29,6 +44,16 @@ export function createIdentityRouter( identity.login(input, context.request.headers, context.resHeaders ?? new Headers()), ), + phoneLoginRequest: os.phoneLoginRequest.handler(({ input, context }) => { + const h = context.request.headers; + return phoneLogin.requestOtp({ ...input, ip: nodeIp(h), userAgent: nodeUa(h) }); + }), + + phoneLoginVerify: os.phoneLoginVerify.handler(({ input, context }) => { + const h = context.request.headers; + return phoneLogin.verifyOtp({ ...input, ip: nodeIp(h), userAgent: nodeUa(h) }); + }), + logout: os.logout.handler(({ context }) => identity.logout(context.request.headers, context.resHeaders ?? new Headers()), ), diff --git a/packages/core/src/pam/identity/schema/index.ts b/packages/core/src/pam/identity/schema/index.ts index fd58168..7c714ad 100644 --- a/packages/core/src/pam/identity/schema/index.ts +++ b/packages/core/src/pam/identity/schema/index.ts @@ -34,8 +34,19 @@ export const user = pgTable( // Use logical JS property names only; drizzle.config.ts maps camelCase -> snake_case // for SQL identifiers so migrations and runtime are consistent. twoFactorEnabled: boolean().default(false), + // Phone-login (SMS OTP) fields. `phoneNumber` is E.164; `phoneVerified` gates the + // phone-login lookup so an unverified/blank phone can never authenticate. Phone + // management routes are a separate story - these are written elsewhere today. + phoneNumber: text(), + phoneVerified: boolean().notNull().default(false), + phoneVerifiedAt: timestamp({ withTimezone: true }), failedLoginAttempts: integer().notNull().default(0), lockoutUntil: timestamp({ withTimezone: true }), + // Progressive lockout tiers: `lockoutCount` counts lockouts inside a rolling 24h + // window (reset once `lastLockoutAt` falls outside it), escalating the duration + // 1min -> 5min -> 15min. Shared by every login method. + lockoutCount: integer().notNull().default(0), + lastLockoutAt: timestamp({ withTimezone: true }), // Responsible-Gambling login block (cooling-off / self-exclusion). Written only via // the LOGIN_ENFORCEMENT port. `rgBlockedUntil` null while blocked = indefinite // (self-exclusion); a Date is the cooling-off expiry the login gate auto-clears. @@ -127,8 +138,31 @@ export const twoFactor = pgTable( ], ); +// One active OTP per user: `requestOtp` deletes any prior row before inserting, so a +// new code invalidates the previous one. `codeHash` is SHA-256 of the 6-digit code - +// the plaintext OTP is never stored. `failedAttempts` caps guessing at 5 per session. +export const smsOtpSession = pgTable( + 'sms_otp_session', + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid() + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + phone: text().notNull(), + codeHash: text().notNull(), + expiresAt: timestamp({ withTimezone: true }).notNull(), + failedAttempts: integer().notNull().default(0), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + index('sms_otp_session_user_id_idx').on(t.userId), + index('sms_otp_session_phone_idx').on(t.phone), + ], +); + export type User = typeof user.$inferSelect; export type Session = typeof session.$inferSelect; +export type SmsOtpSession = typeof smsOtpSession.$inferSelect; export type Account = typeof account.$inferSelect; export type Verification = typeof verification.$inferSelect; export type TwoFactor = typeof twoFactor.$inferSelect; diff --git a/packages/core/src/pam/identity/service/identity.service.ts b/packages/core/src/pam/identity/service/identity.service.ts index 88db951..5900109 100644 --- a/packages/core/src/pam/identity/service/identity.service.ts +++ b/packages/core/src/pam/identity/service/identity.service.ts @@ -143,6 +143,30 @@ async function ensureOk(res: globalThis.Response) { const DEFAULT_MAX_LOGIN_ATTEMPTS = 5; const DEFAULT_LOCKOUT_DURATION_MS = 15 * 60 * 1000; +// Progressive lockout: a repeat lockout inside a rolling 24h window escalates the +// duration (1 min -> 5 min -> 15 min, capped at tier 3). The window resets once the +// last lockout falls outside it, so an occasional fat-finger never compounds. +const LOCKOUT_WINDOW_MS = 24 * 60 * 60 * 1000; +const LOCKOUT_TIER_DURATIONS_MS = [60_000, 5 * 60_000, 15 * 60_000] as const; + +function computeLockoutTier({ + lockoutCount, + lastLockoutAt, + nowMs, + fallbackDurationMs, +}: { + lockoutCount: number; + lastLockoutAt: Date | null; + nowMs: number; + fallbackDurationMs: number; +}) { + const withinWindow = + lastLockoutAt !== null && nowMs - lastLockoutAt.getTime() < LOCKOUT_WINDOW_MS; + const tier = withinWindow ? lockoutCount + 1 : 1; + const durationMs = LOCKOUT_TIER_DURATIONS_MS[Math.min(tier - 1, 2)] ?? fallbackDurationMs; + return { tier, durationMs }; +} + const MINUTE_MS = 60 * 1000; // Coarse abuse throttles keyed by the caller identifier the context provides (email/ // token/session), NOT IP. The lockout above is a per-account credential-failure @@ -269,6 +293,8 @@ export class IdentityService { id: user.id, failedLoginAttempts: user.failedLoginAttempts, lockoutUntil: user.lockoutUntil, + lockoutCount: user.lockoutCount, + lastLockoutAt: user.lastLockoutAt, role: user.role, rgBlocked: user.rgBlocked, rgBlockedUntil: user.rgBlockedUntil, @@ -279,7 +305,14 @@ export class IdentityService { let existingUser: | Pick< typeof user.$inferSelect, - 'id' | 'failedLoginAttempts' | 'lockoutUntil' | 'role' | 'rgBlocked' | 'rgBlockedUntil' + | 'id' + | 'failedLoginAttempts' + | 'lockoutUntil' + | 'lockoutCount' + | 'lastLockoutAt' + | 'role' + | 'rgBlocked' + | 'rgBlockedUntil' > | undefined = existingUserRow; @@ -352,9 +385,10 @@ export class IdentityService { // error must never lock the account out. const isCredentialFailure = error instanceof ORPCError && error.code === 'UNAUTHORIZED'; + let attemptsRemaining: number | undefined; if (lockoutEnabled && existingUser && isCredentialFailure) { const maxAttempts = this.options?.lockout?.maxAttempts ?? DEFAULT_MAX_LOGIN_ATTEMPTS; - const durationMs = this.options?.lockout?.durationMs ?? DEFAULT_LOCKOUT_DURATION_MS; + const fallbackDurationMs = this.options?.lockout?.durationMs ?? DEFAULT_LOCKOUT_DURATION_MS; // Atomic increment: concurrent failures can't each read a stale count and slip past // the threshold (the read-modify-write this replaces was bypassable under load). const [row] = await this.drizzle.db @@ -363,17 +397,27 @@ export class IdentityService { .where(eq(user.id, existingUser.id)) .returning({ failedLoginAttempts: user.failedLoginAttempts }); const newAttempts = row?.failedLoginAttempts ?? existingUser.failedLoginAttempts + 1; - const { isLocking, lockoutUntil } = computeLockoutState({ + attemptsRemaining = Math.max(maxAttempts - newAttempts, 0); + const { isLocking } = computeLockoutState({ attempts: newAttempts, maxAttempts, - durationMs, + durationMs: fallbackDurationMs, nowMs: Date.now(), }); - if (isLocking && lockoutUntil) { + if (isLocking) { + const nowMs = Date.now(); + // Escalate the lockout duration for repeat offenders inside the 24h window. + const { tier, durationMs } = computeLockoutTier({ + lockoutCount: existingUser.lockoutCount ?? 0, + lastLockoutAt: existingUser.lastLockoutAt ?? null, + nowMs, + fallbackDurationMs, + }); + const lockoutUntil = new Date(nowMs + durationMs); await this.drizzle.db .update(user) - .set({ lockoutUntil }) + .set({ lockoutUntil, lockoutCount: tier, lastLockoutAt: new Date(nowMs) }) .where(eq(user.id, existingUser.id)); this.events.emit('identity.user.lockout.triggered', { @@ -389,7 +433,13 @@ export class IdentityService { } const reason = isCredentialFailure ? 'invalid_credentials' : 'error'; - this.events.emit('identity.user.login.failed', { email, reason, ip, userAgent }); + this.events.emit('identity.user.login.failed', { + email, + reason, + ip, + userAgent, + ...(attemptsRemaining !== undefined ? { attemptsRemaining } : {}), + }); throw error; } } diff --git a/packages/core/src/pam/identity/service/phone-login.service.ts b/packages/core/src/pam/identity/service/phone-login.service.ts new file mode 100644 index 0000000..d64bd32 --- /dev/null +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -0,0 +1,251 @@ +import { createHash, randomInt, randomUUID } from 'node:crypto'; +import { ORPCError } from '@orpc/server'; +import { type EventBus, DrizzleService, assertRateLimit } from '@openora/core/server'; +import { eq, and, sql } from 'drizzle-orm'; +import type { + RateLimiterAdapter, + SmsAdapter, + PhoneLoginRequestInput, + PhoneLoginRequestOutput, + PhoneLoginVerifyInput, + User, +} from '@openora/core/contracts'; +import { user, session, smsOtpSession } from '../schema/index.js'; + +const MINUTE_MS = 60 * 1000; +const OTP_TTL_MS = 5 * MINUTE_MS; +const RESEND_COOLDOWN_MS = 60 * 1000; +const MAX_VERIFY_ATTEMPTS = 5; +const SESSION_TTL_MS = 24 * 60 * 60 * 1000; +const REMEMBER_ME_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +// Fixed-window abuse throttles keyed by phone. Both fail closed (`deny`): an +// unthrottled OTP-request or OTP-guess window is a credential-guessing surface, so a +// transient limiter outage must not open it. +const OTP_REQUEST_RATE_LIMIT = { + limit: 3, + windowMs: 15 * MINUTE_MS, + onUnavailable: 'deny', +} as const; +const OTP_VERIFY_RATE_LIMIT = { + limit: 10, + windowMs: 5 * MINUTE_MS, + onUnavailable: 'deny', +} as const; + +/** Resend requested before the 60s cooldown elapsed. */ +export function OtpCooldownError(retryAfterMs: number) { + return new ORPCError('TOO_MANY_REQUESTS', { + message: 'A code was already sent. Please wait before requesting another.', + data: { retryAfterMs }, + }); +} + +/** Wrong or expired code, session still alive. */ +export function OtpInvalidError(attemptsRemaining: number) { + return new ORPCError('UNPROCESSABLE_CONTENT', { + message: 'The code is invalid or has expired.', + data: { attemptsRemaining }, + }); +} + +/** Max wrong attempts reached; the OTP session was invalidated - restart login. */ +export function OtpCancelledError() { + return new ORPCError('FORBIDDEN', { + message: 'Too many incorrect attempts. Please request a new code.', + }); +} + +function hashCode(code: string): string { + return createHash('sha256').update(code).digest('hex'); +} + +function generateCode(): string { + return randomInt(0, 1_000_000).toString().padStart(6, '0'); +} + +function isRgBlocked(u: { rgBlocked: boolean; rgBlockedUntil: Date | null }): boolean { + return u.rgBlocked && (u.rgBlockedUntil === null || u.rgBlockedUntil > new Date()); +} + +// Fields the OTP flow returns to the caller; the phone-login response mirrors the +// public UserSchema, so serialize the row's timestamps to ISO strings. +function serializeUser(u: typeof user.$inferSelect): User { + const base = { + id: u.id, + email: u.email, + name: u.name, + emailVerified: u.emailVerified, + theme: u.theme, + language: u.language, + phoneNumber: u.phoneNumber, + phoneVerified: u.phoneVerified, + createdAt: u.createdAt.toISOString(), + updatedAt: u.updatedAt.toISOString(), + }; + return u.image !== null && u.image !== undefined ? { ...base, image: u.image } : base; +} + +export type PhoneLoginServiceDeps = { + drizzle: DrizzleService; + events: EventBus; + sms: SmsAdapter; + limiter?: RateLimiterAdapter; +}; + +export class PhoneLoginService { + private readonly drizzle: DrizzleService; + private readonly events: EventBus; + private readonly sms: SmsAdapter; + private readonly limiter?: RateLimiterAdapter; + + constructor({ drizzle, events, sms, limiter }: PhoneLoginServiceDeps) { + this.drizzle = drizzle; + this.events = events; + this.sms = sms; + this.limiter = limiter; + } + + async requestOtp( + input: PhoneLoginRequestInput & { ip?: string | null; userAgent?: string | null }, + ): Promise { + const { phone } = input; + await assertRateLimit(this.limiter, `phone-otp-req:${phone}`, OTP_REQUEST_RATE_LIMIT); + + const now = Date.now(); + const expiresAt = new Date(now + OTP_TTL_MS); + const resendAfter = new Date(now + RESEND_COOLDOWN_MS); + + const [account] = await this.drizzle.db + .select({ id: user.id }) + .from(user) + .where(and(eq(user.phoneNumber, phone), eq(user.phoneVerified, true))) + .limit(1); + + // Anti-enumeration: an unregistered/unverified phone gets the same success-shaped + // response, minus the SMS. A caller cannot tell a real number from a fake one. + if (!account) + return { expiresAt: expiresAt.toISOString(), resendAfter: resendAfter.toISOString() }; + + const [existing] = await this.drizzle.db + .select({ createdAt: smsOtpSession.createdAt }) + .from(smsOtpSession) + .where(eq(smsOtpSession.userId, account.id)) + .limit(1); + + if (existing && now - existing.createdAt.getTime() < RESEND_COOLDOWN_MS) { + throw OtpCooldownError(RESEND_COOLDOWN_MS - (now - existing.createdAt.getTime())); + } + + // Invalidate any prior code before issuing a new one - one live OTP per user. + await this.drizzle.db.delete(smsOtpSession).where(eq(smsOtpSession.userId, account.id)); + if (existing) { + this.events.emit('identity.phone_otp.cancelled', { + userId: account.id, + reason: 'new_otp_requested', + }); + } + + const code = generateCode(); + await this.drizzle.db.insert(smsOtpSession).values({ + userId: account.id, + phone, + codeHash: hashCode(code), + expiresAt, + }); + + await this.sms.sendOtp({ to: phone, code }); + this.events.emit('identity.phone_otp.requested', { userId: account.id }); + + return { expiresAt: expiresAt.toISOString(), resendAfter: resendAfter.toISOString() }; + } + + async verifyOtp( + input: PhoneLoginVerifyInput & { ip?: string | null; userAgent?: string | null }, + ) { + const { phone, code, rememberMe, ip = null, userAgent = null } = input; + await assertRateLimit(this.limiter, `phone-otp-verify:${phone}`, OTP_VERIFY_RATE_LIMIT); + + const [otp] = await this.drizzle.db + .select({ + id: smsOtpSession.id, + userId: smsOtpSession.userId, + codeHash: smsOtpSession.codeHash, + expiresAt: smsOtpSession.expiresAt, + }) + .from(smsOtpSession) + .where(eq(smsOtpSession.phone, phone)) + .limit(1); + + if (!otp || otp.expiresAt.getTime() < Date.now()) { + throw OtpInvalidError(MAX_VERIFY_ATTEMPTS); + } + + if (hashCode(code) !== otp.codeHash) { + // Atomic increment so concurrent guesses can't each read a stale count and slip + // past the cap. + const [row] = await this.drizzle.db + .update(smsOtpSession) + .set({ failedAttempts: sql`${smsOtpSession.failedAttempts} + 1` }) + .where(eq(smsOtpSession.id, otp.id)) + .returning({ failedAttempts: smsOtpSession.failedAttempts }); + const newAttempts = row?.failedAttempts ?? MAX_VERIFY_ATTEMPTS; + + if (newAttempts >= MAX_VERIFY_ATTEMPTS) { + await this.drizzle.db.delete(smsOtpSession).where(eq(smsOtpSession.id, otp.id)); + this.events.emit('identity.phone_otp.cancelled', { + userId: otp.userId, + reason: 'max_attempts', + }); + throw OtpCancelledError(); + } + throw OtpInvalidError(MAX_VERIFY_ATTEMPTS - newAttempts); + } + + // Correct code: single-use, so remove the session before minting a login session. + await this.drizzle.db.delete(smsOtpSession).where(eq(smsOtpSession.id, otp.id)); + + const [account] = await this.drizzle.db + .select() + .from(user) + .where(eq(user.id, otp.userId)) + .limit(1); + if (!account) throw OtpInvalidError(MAX_VERIFY_ATTEMPTS); + + // RG login block, applied only AFTER the OTP verifies so a probe can't distinguish a + // restricted account from a wrong code. + if (isRgBlocked(account)) { + this.events.emit('rg.exclusion.login_blocked', { userId: account.id, ip, userAgent }); + throw new ORPCError('FORBIDDEN', { + message: 'Account access is currently restricted (responsible gambling).', + }); + } + + // Mint the session directly - bypasses better-auth's TOTP plugin chain by design, + // so phone login never triggers a second factor. + const token = randomUUID(); + const expiresAt = new Date(Date.now() + (rememberMe ? REMEMBER_ME_TTL_MS : SESSION_TTL_MS)); + await this.drizzle.db.insert(session).values({ + id: randomUUID(), + token, + userId: account.id, + expiresAt, + ipAddress: ip, + userAgent, + createdAt: new Date(), + updatedAt: new Date(), + }); + + this.events.emit('identity.user.phone_login', { + userId: account.id, + method: 'phone', + ip, + userAgent, + }); + + return { + user: serializeUser(account), + session: { token, expiresAt: expiresAt.toISOString() }, + }; + } +}