From a59abce53bcc9e8a9d4f249d635a4c6628ad7db8 Mon Sep 17 00:00:00 2001 From: Oleksandr Agniev Date: Thu, 9 Jul 2026 19:44:24 +0200 Subject: [PATCH 01/13] feat(identity): implementation of sms login api --- docs/catalog.json | 32 + packages/core/src/audit/plugin.ts | 27 + packages/core/src/contracts/adapters/index.ts | 3 + packages/core/src/contracts/adapters/sms.ts | 11 + packages/core/src/contracts/schemas/events.ts | 17 + .../core/src/contracts/schemas/identity.ts | 29 + packages/core/src/pam/identity/AGENTS.md | 4 +- .../__tests__/phone-login.service.test.ts | 223 ++++++ .../adapters/mock/mock-sms-adapter.ts | 10 + .../core/src/pam/identity/contract/index.ts | 13 + .../migrations/0001_rainy_peter_quill.sql | 18 + .../migrations/meta/0001_snapshot.json | 684 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/pam/identity/plugin.ts | 12 + .../core/src/pam/identity/router/index.ts | 25 + .../core/src/pam/identity/schema/index.ts | 34 + .../pam/identity/service/identity.service.ts | 64 +- .../identity/service/phone-login.service.ts | 251 +++++++ 18 files changed, 1456 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/contracts/adapters/sms.ts create mode 100644 packages/core/src/pam/identity/__tests__/phone-login.service.test.ts create mode 100644 packages/core/src/pam/identity/adapters/mock/mock-sms-adapter.ts create mode 100644 packages/core/src/pam/identity/drizzle/migrations/0001_rainy_peter_quill.sql create mode 100644 packages/core/src/pam/identity/drizzle/migrations/meta/0001_snapshot.json create mode 100644 packages/core/src/pam/identity/service/phone-login.service.ts diff --git a/docs/catalog.json b/docs/catalog.json index 01510b47..f83178a4 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" diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index e9466f61..1c0f6c14 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 b8d3f083..e5798432 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 00000000..3c863b35 --- /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 0b42192f..33a8af48 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 57211775..ee8f5c11 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 e5624110..0d80ceac 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 00000000..ae6bb68e --- /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 00000000..ae7643fe --- /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 30687030..3295da22 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 00000000..fe07ca06 --- /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 00000000..cd7e6b9b --- /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 7471d8ba..6565b6ba 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 feea5e91..163a2101 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 b462d6da..af66bf1d 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 fd581683..7c714ad7 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 88db9516..5900109a 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 00000000..d64bd32b --- /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() }, + }; + } +} From 58e3675b46513a8b24f91f2dd83a8eccf2be89c9 Mon Sep 17 00:00:00 2001 From: Oleksandr Agniev Date: Thu, 9 Jul 2026 19:44:58 +0200 Subject: [PATCH 02/13] feat(identity): implementation of sms login api --- docs/catalog.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/catalog.json b/docs/catalog.json index f83178a4..708026c2 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -1315,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", From 699337789eb1fd0e9c83e3b7110fc8cc86d275ce Mon Sep 17 00:00:00 2001 From: Olek Date: Mon, 13 Jul 2026 08:46:01 +0200 Subject: [PATCH 03/13] fix(identity): fixing retry params --- .../__tests__/phone-login.service.test.ts | 36 ++++++++++-- .../core/src/pam/identity/router/index.ts | 16 ++++-- .../identity/service/phone-login.service.ts | 25 ++++++--- packages/testing/src/seed-demo-data.ts | 55 +++++++++++++++++++ 4 files changed, 114 insertions(+), 18 deletions(-) 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 index ae6bb68e..7bd80132 100644 --- a/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts +++ b/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts @@ -57,6 +57,7 @@ const otpRow = (over: Record = {}) => ({ userId: 'u1', codeHash: '', // set per-test expiresAt: new Date(Date.now() + 5 * 60 * 1000), + failedAttempts: 0, ...over, }); @@ -68,7 +69,7 @@ describe('PhoneLoginService.requestOtp', () => { // select order: (1) user lookup, (2) existing-otp lookup (none), // (3) delete (awaited), (4) insert (awaited). const { svc, events, sms } = build({ - select: [[{ id: 'u1' }], [], [], []], + select: [[{ id: 'u1', phoneVerified: true }], [], [], []], }); const out = await svc.requestOtp({ phone: PHONE }); @@ -91,10 +92,17 @@ describe('PhoneLoginService.requestOtp', () => { expect(events.emit).not.toHaveBeenCalled(); }); + it('throws FORBIDDEN when the phone is registered but not verified', async () => { + const { svc, sms } = build({ select: [[{ id: 'u1', phoneVerified: false }]] }); + + await expect(svc.requestOtp({ phone: PHONE })).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(sms.sendOtp).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) }]], + select: [[{ id: 'u1', phoneVerified: true }], [{ createdAt: new Date(Date.now() - 10_000) }]], }); await expect(svc.requestOtp({ phone: PHONE })).rejects.toMatchObject({ @@ -106,7 +114,12 @@ describe('PhoneLoginService.requestOtp', () => { 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) }], [], []], + select: [ + [{ id: 'u1', phoneVerified: true }], + [{ createdAt: new Date(Date.now() - 120_000) }], + [], + [], + ], }); await svc.requestOtp({ phone: PHONE }); @@ -182,21 +195,32 @@ describe('PhoneLoginService.verifyOtp', () => { }); }); - it('expired OTP throws OtpInvalidError', async () => { + it('expired OTP returns attemptsRemaining derived from failedAttempts on the dead session', async () => { const code = '123456'; const { svc } = build({ - select: [[otpRow({ codeHash: hash(code), expiresAt: new Date(Date.now() - 1000) })]], + // 2 failed attempts before the code expired -> 3 remaining + select: [ + [ + otpRow({ + codeHash: hash(code), + expiresAt: new Date(Date.now() - 1000), + failedAttempts: 2, + }), + ], + ], }); await expect(svc.verifyOtp({ phone: PHONE, code })).rejects.toMatchObject({ code: 'UNPROCESSABLE_CONTENT', + data: { attemptsRemaining: 3 }, }); }); - it('missing OTP session throws OtpInvalidError', async () => { + it('missing OTP session throws OtpInvalidError with 0 attemptsRemaining', async () => { const { svc } = build({ select: [[]] }); await expect(svc.verifyOtp({ phone: PHONE, code: '123456' })).rejects.toMatchObject({ code: 'UNPROCESSABLE_CONTENT', + data: { attemptsRemaining: 0 }, }); }); diff --git a/packages/core/src/pam/identity/router/index.ts b/packages/core/src/pam/identity/router/index.ts index af66bf1d..6b94bf42 100644 --- a/packages/core/src/pam/identity/router/index.ts +++ b/packages/core/src/pam/identity/router/index.ts @@ -14,14 +14,14 @@ 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 { +function nodeForwardedFor(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 { +function nodeUserAgent(headers: NodeHeaders): string | null { const ua = headers['user-agent']; return (Array.isArray(ua) ? ua[0] : ua) ?? null; } @@ -46,12 +46,20 @@ export function createIdentityRouter( phoneLoginRequest: os.phoneLoginRequest.handler(({ input, context }) => { const h = context.request.headers; - return phoneLogin.requestOtp({ ...input, ip: nodeIp(h), userAgent: nodeUa(h) }); + return phoneLogin.requestOtp({ + ...input, + ip: nodeForwardedFor(h), + userAgent: nodeUserAgent(h), + }); }), phoneLoginVerify: os.phoneLoginVerify.handler(({ input, context }) => { const h = context.request.headers; - return phoneLogin.verifyOtp({ ...input, ip: nodeIp(h), userAgent: nodeUa(h) }); + return phoneLogin.verifyOtp({ + ...input, + ip: nodeForwardedFor(h), + userAgent: nodeUserAgent(h), + }); }), logout: os.logout.handler(({ context }) => diff --git a/packages/core/src/pam/identity/service/phone-login.service.ts b/packages/core/src/pam/identity/service/phone-login.service.ts index d64bd32b..1b8164f2 100644 --- a/packages/core/src/pam/identity/service/phone-login.service.ts +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -1,7 +1,7 @@ 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 { eq, sql } from 'drizzle-orm'; import type { RateLimiterAdapter, SmsAdapter, @@ -117,16 +117,21 @@ export class PhoneLoginService { const resendAfter = new Date(now + RESEND_COOLDOWN_MS); const [account] = await this.drizzle.db - .select({ id: user.id }) + .select({ id: user.id, phoneVerified: user.phoneVerified }) .from(user) - .where(and(eq(user.phoneNumber, phone), eq(user.phoneVerified, true))) + .where(eq(user.phoneNumber, phone)) .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. + // Anti-enumeration: an unknown phone gets the same success-shaped response, minus + // the SMS. A caller cannot distinguish a real number from a fake one. if (!account) return { expiresAt: expiresAt.toISOString(), resendAfter: resendAfter.toISOString() }; + if (!account.phoneVerified) + throw new ORPCError('FORBIDDEN', { + message: 'Phone number is not verified on this account.', + }); + const [existing] = await this.drizzle.db .select({ createdAt: smsOtpSession.createdAt }) .from(smsOtpSession) @@ -172,13 +177,17 @@ export class PhoneLoginService { userId: smsOtpSession.userId, codeHash: smsOtpSession.codeHash, expiresAt: smsOtpSession.expiresAt, + failedAttempts: smsOtpSession.failedAttempts, }) .from(smsOtpSession) .where(eq(smsOtpSession.phone, phone)) .limit(1); - if (!otp || otp.expiresAt.getTime() < Date.now()) { - throw OtpInvalidError(MAX_VERIFY_ATTEMPTS); + if (!otp) { + throw OtpInvalidError(0); + } + if (otp.expiresAt.getTime() < Date.now()) { + throw OtpInvalidError(MAX_VERIFY_ATTEMPTS - otp.failedAttempts); } if (hashCode(code) !== otp.codeHash) { @@ -210,7 +219,7 @@ export class PhoneLoginService { .from(user) .where(eq(user.id, otp.userId)) .limit(1); - if (!account) throw OtpInvalidError(MAX_VERIFY_ATTEMPTS); + if (!account) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Account not found.' }); // RG login block, applied only AFTER the OTP verifies so a probe can't distinguish a // restricted account from a wrong code. diff --git a/packages/testing/src/seed-demo-data.ts b/packages/testing/src/seed-demo-data.ts index 421a32f8..e203f409 100644 --- a/packages/testing/src/seed-demo-data.ts +++ b/packages/testing/src/seed-demo-data.ts @@ -141,6 +141,47 @@ const KYC_WEIGHTS = [ ['rejected', 12], ] as const; +// E.164 phone numbers, one per seeded player slot (index-stable so the same player +// always gets the same number across re-seeds). Country codes align roughly with LOCALES. +const PHONE_NUMBERS = [ + '+4915201234567', + '+4407911123456', + '+14165550101', + '+46701234567', + '+34612345678', + '+33612345678', + '+4791234567', + '+393331234567', + '+4915207654321', + '+4407911654321', + '+14165550202', + '+46709876543', + '+34698765432', + '+33698765432', + '+4798765432', + '+393339876543', + '+4915209988776', + '+4407922334455', + '+14165550303', + '+46731122334', + '+34611223344', + '+33611223344', + '+4792233445', + '+393312233445', + '+4915203344556', + '+4407933445566', + '+14165550404', + '+46744556677', + '+34633445566', + '+33644556677', + '+4793344556', + '+393323344556', + '+4915204455667', + '+4407944556677', + '+14165550505', + '+46755667788', +] as const; + const GAMES = [ ['Gates of Olympus', 'Pragmatic Play', 'slots'], ['Sweet Bonanza', 'Pragmatic Play', 'slots'], @@ -210,6 +251,11 @@ export async function seedDemoData(options: SeedOptions): Promise { const daysAgo = Math.floor(rng() * rng() * windowDays); const createdAt = new Date(now - daysAgo * dayMs); const isActive = status !== 'suspended' && status !== 'closed'; + const phoneNumber = PHONE_NUMBERS[i % PHONE_NUMBERS.length] ?? null; + const phoneVerified = phoneNumber !== null && rng() > 0.25; + const phoneVerifiedAt = phoneVerified + ? new Date(createdAt.getTime() + Math.floor(rng() * 7) * dayMs) + : null; const playerUser = await ensureUser(db, auth, { email, @@ -218,6 +264,9 @@ export async function seedDemoData(options: SeedOptions): Promise { role: 'player', isActive, createdAt, + phoneNumber, + phoneVerified, + phoneVerifiedAt, }); if (!playerUser) continue; userCount++; @@ -305,6 +354,9 @@ type EnsureUserInput = { role: string; isActive: boolean; createdAt?: Date; + phoneNumber?: string | null; + phoneVerified?: boolean; + phoneVerifiedAt?: Date | null; }; async function ensureUser( @@ -326,6 +378,9 @@ async function ensureUser( isActive: input.isActive, }; if (input.createdAt) patch.createdAt = input.createdAt; + if (input.phoneNumber !== undefined) patch.phoneNumber = input.phoneNumber; + if (input.phoneVerified !== undefined) patch.phoneVerified = input.phoneVerified; + if (input.phoneVerifiedAt !== undefined) patch.phoneVerifiedAt = input.phoneVerifiedAt; await db.update(user).set(patch).where(eq(user.id, existing.id)); return { id: existing.id }; } From 40af67c9228fadf1b2e0cf84f89ee160e4e7d05c Mon Sep 17 00:00:00 2001 From: Olek Date: Mon, 13 Jul 2026 08:49:17 +0200 Subject: [PATCH 04/13] fix(identity): merge dev --- .../src/pam/identity/service/phone-login.service.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/pam/identity/service/phone-login.service.ts b/packages/core/src/pam/identity/service/phone-login.service.ts index 1b8164f2..1cc76c95 100644 --- a/packages/core/src/pam/identity/service/phone-login.service.ts +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -124,13 +124,15 @@ export class PhoneLoginService { // Anti-enumeration: an unknown phone gets the same success-shaped response, minus // the SMS. A caller cannot distinguish a real number from a fake one. - if (!account) + if (!account) { return { expiresAt: expiresAt.toISOString(), resendAfter: resendAfter.toISOString() }; + } - if (!account.phoneVerified) + if (!account.phoneVerified) { throw new ORPCError('FORBIDDEN', { message: 'Phone number is not verified on this account.', }); + } const [existing] = await this.drizzle.db .select({ createdAt: smsOtpSession.createdAt }) @@ -219,7 +221,9 @@ export class PhoneLoginService { .from(user) .where(eq(user.id, otp.userId)) .limit(1); - if (!account) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Account not found.' }); + if (!account) { + throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Account not found.' }); + } // RG login block, applied only AFTER the OTP verifies so a probe can't distinguish a // restricted account from a wrong code. From 59ebf32aee65bc84701c58c297e684712d21ea22 Mon Sep 17 00:00:00 2001 From: Olek Date: Mon, 13 Jul 2026 14:39:34 +0200 Subject: [PATCH 05/13] fix(identity): final review and mock generator for better testing --- .../core/src/contracts/schemas/identity.ts | 5 +- .../migrations/0002_outstanding_patriot.sql | 1 + .../migrations/meta/0002_snapshot.json | 690 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + .../core/src/pam/identity/schema/index.ts | 1 + .../pam/identity/service/identity.service.ts | 9 +- .../identity/service/phone-login.service.ts | 85 ++- .../pam/identity/service/rg-guard.service.ts | 3 + 8 files changed, 763 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/pam/identity/drizzle/migrations/0002_outstanding_patriot.sql create mode 100644 packages/core/src/pam/identity/drizzle/migrations/meta/0002_snapshot.json create mode 100644 packages/core/src/pam/identity/service/rg-guard.service.ts diff --git a/packages/core/src/contracts/schemas/identity.ts b/packages/core/src/contracts/schemas/identity.ts index ee8f5c11..bd676e14 100644 --- a/packages/core/src/contracts/schemas/identity.ts +++ b/packages/core/src/contracts/schemas/identity.ts @@ -38,10 +38,7 @@ export const PhoneLoginRequestOutputSchema = z.object({ export const PhoneLoginVerifyInputSchema = z.object({ phone: E164PhoneSchema, - code: z - .string() - .length(6) - .regex(/^\d{6}$/), + code: z.string().regex(/^\d{6}$/), rememberMe: z.boolean().optional(), }); diff --git a/packages/core/src/pam/identity/drizzle/migrations/0002_outstanding_patriot.sql b/packages/core/src/pam/identity/drizzle/migrations/0002_outstanding_patriot.sql new file mode 100644 index 00000000..26ede466 --- /dev/null +++ b/packages/core/src/pam/identity/drizzle/migrations/0002_outstanding_patriot.sql @@ -0,0 +1 @@ +ALTER TABLE "sms_otp_session" ADD CONSTRAINT "sms_otp_session_userId_unique" UNIQUE("user_id"); diff --git a/packages/core/src/pam/identity/drizzle/migrations/meta/0002_snapshot.json b/packages/core/src/pam/identity/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..a5754bad --- /dev/null +++ b/packages/core/src/pam/identity/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,690 @@ +{ + "id": "062c0656-9ff5-455b-aa03-4736ccf7a921", + "prevId": "7a03cc2a-aa35-4f53-b068-e828048a76da", + "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": { + "sms_otp_session_userId_unique": { + "name": "sms_otp_session_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "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 6565b6ba..a072fbfb 100644 --- a/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1783611861777, "tag": "0001_rainy_peter_quill", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783944672839, + "tag": "0002_outstanding_patriot", + "breakpoints": true } ] } diff --git a/packages/core/src/pam/identity/schema/index.ts b/packages/core/src/pam/identity/schema/index.ts index 7c714ad7..129e8b4c 100644 --- a/packages/core/src/pam/identity/schema/index.ts +++ b/packages/core/src/pam/identity/schema/index.ts @@ -147,6 +147,7 @@ export const smsOtpSession = pgTable( id: uuid().primaryKey().defaultRandom(), userId: uuid() .notNull() + .unique() .references(() => user.id, { onDelete: 'cascade' }), phone: text().notNull(), codeHash: text().notNull(), diff --git a/packages/core/src/pam/identity/service/identity.service.ts b/packages/core/src/pam/identity/service/identity.service.ts index c27ae54f..eca6e192 100644 --- a/packages/core/src/pam/identity/service/identity.service.ts +++ b/packages/core/src/pam/identity/service/identity.service.ts @@ -31,6 +31,7 @@ import type { PlatformConfig, } from '@openora/core/contracts'; import { assertSupportedLanguage } from '../../shared/language.js'; +import { isRgBlocked } from './rg-guard.service.js'; function nodeHeadersToHeaders(nodeHeaders: NodeHeaders) { const headers = new Headers(); @@ -221,12 +222,6 @@ function computeLockoutState({ return { isLocking, lockoutUntil: isLocking ? new Date(nowMs + durationMs) : null }; } -// A finite rgBlockedUntil in the past = a lapsed cooling-off; a null one = indefinite -// (self-exclusion / permanent). Blocked only while set and not elapsed. -function isRgBlocked(u: { rgBlocked: boolean; rgBlockedUntil: Date | null }): boolean { - return u.rgBlocked && (u.rgBlockedUntil === null || u.rgBlockedUntil > new Date()); -} - export type IdentityServiceDeps = { drizzle: DrizzleService; events: EventBus; @@ -484,7 +479,7 @@ export class IdentityService { private clearLockout(userId: User['id']) { return this.drizzle.db .update(user) - .set({ failedLoginAttempts: 0, lockoutUntil: null }) + .set({ failedLoginAttempts: 0, lockoutUntil: null, lockoutCount: 0, lastLockoutAt: null }) .where(eq(user.id, userId)); } diff --git a/packages/core/src/pam/identity/service/phone-login.service.ts b/packages/core/src/pam/identity/service/phone-login.service.ts index 1cc76c95..fd3f1ecf 100644 --- a/packages/core/src/pam/identity/service/phone-login.service.ts +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -11,6 +11,7 @@ import type { User, } from '@openora/core/contracts'; import { user, session, smsOtpSession } from '../schema/index.js'; +import { isRgBlocked } from './rg-guard.service.js'; const MINUTE_MS = 60 * 1000; const OTP_TTL_MS = 5 * MINUTE_MS; @@ -61,16 +62,27 @@ function hashCode(code: string): string { } function generateCode(): string { - return randomInt(0, 1_000_000).toString().padStart(6, '0'); + randomInt(0, 1_000_000).toString().padStart(6, '0'); + return '123456'; // Currently, we use it for better testing exp; + // 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 { +type UserRow = Pick< + typeof user.$inferSelect, + | 'id' + | 'email' + | 'name' + | 'emailVerified' + | 'image' + | 'theme' + | 'language' + | 'phoneNumber' + | 'phoneVerified' + | 'createdAt' + | 'updatedAt' +>; + +function serializeUser(u: UserRow): User { const base = { id: u.id, email: u.email, @@ -144,8 +156,17 @@ export class PhoneLoginService { 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)); + const code = generateCode(); + const codeHash = hashCode(code); + + await this.drizzle.db + .insert(smsOtpSession) + .values({ userId: account.id, phone, codeHash, expiresAt }) + .onConflictDoUpdate({ + target: smsOtpSession.userId, + set: { phone, codeHash, expiresAt, failedAttempts: 0, createdAt: new Date() }, + }); + if (existing) { this.events.emit('identity.phone_otp.cancelled', { userId: account.id, @@ -153,14 +174,6 @@ export class PhoneLoginService { }); } - 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 }); @@ -200,14 +213,18 @@ export class PhoneLoginService { .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', - }); + const newAttempts = row?.failedAttempts; + + // row is undefined when a concurrent request already deleted the session after hitting + // the cap — treat it as already cancelled; skip the emit to avoid duplicates. + if (newAttempts === undefined || newAttempts >= MAX_VERIFY_ATTEMPTS) { + if (newAttempts !== undefined) { + 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); @@ -217,7 +234,21 @@ export class PhoneLoginService { await this.drizzle.db.delete(smsOtpSession).where(eq(smsOtpSession.id, otp.id)); const [account] = await this.drizzle.db - .select() + .select({ + id: user.id, + email: user.email, + name: user.name, + emailVerified: user.emailVerified, + image: user.image, + theme: user.theme, + language: user.language, + phoneNumber: user.phoneNumber, + phoneVerified: user.phoneVerified, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + rgBlocked: user.rgBlocked, + rgBlockedUntil: user.rgBlockedUntil, + }) .from(user) .where(eq(user.id, otp.userId)) .limit(1); diff --git a/packages/core/src/pam/identity/service/rg-guard.service.ts b/packages/core/src/pam/identity/service/rg-guard.service.ts new file mode 100644 index 00000000..ad0a65a1 --- /dev/null +++ b/packages/core/src/pam/identity/service/rg-guard.service.ts @@ -0,0 +1,3 @@ +export function isRgBlocked(u: { rgBlocked: boolean; rgBlockedUntil: Date | null }): boolean { + return u.rgBlocked && (u.rgBlockedUntil === null || u.rgBlockedUntil > new Date()); +} From 811b7ec1da2cfcdcf31ed7a8ad3a52e48c0033ea Mon Sep 17 00:00:00 2001 From: Olek Date: Mon, 13 Jul 2026 14:40:31 +0200 Subject: [PATCH 06/13] fix(identity): final review and mock generator for better testing --- .husky/pre-commit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index fe869ab8..b5976ad6 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,7 +3,7 @@ # unstaged changes. staged=$(git diff --cached --name-only --diff-filter=ACMR) -pnpm lint:fix +pnpm lint pnpm format if [ -n "$staged" ]; then From 69643b0be77ce0dbca727c639328ad6a882e64e3 Mon Sep 17 00:00:00 2001 From: Olek Date: Mon, 13 Jul 2026 15:24:54 +0200 Subject: [PATCH 07/13] fix(identity): post self code review --- packages/core/src/contracts/schemas/events.ts | 7 ------- packages/core/src/contracts/schemas/identity.ts | 4 ---- .../src/pam/identity/adapters/mock/mock-sms-adapter.ts | 2 +- .../core/src/pam/identity/service/phone-login.service.ts | 3 --- 4 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index ffffb6f9..3df686d1 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -40,20 +40,13 @@ 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, diff --git a/packages/core/src/contracts/schemas/identity.ts b/packages/core/src/contracts/schemas/identity.ts index bd676e14..c7cc36bf 100644 --- a/packages/core/src/contracts/schemas/identity.ts +++ b/packages/core/src/contracts/schemas/identity.ts @@ -16,8 +16,6 @@ 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, @@ -30,9 +28,7 @@ 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, }); 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 index ae7643fe..4ced35d4 100644 --- a/packages/core/src/pam/identity/adapters/mock/mock-sms-adapter.ts +++ b/packages/core/src/pam/identity/adapters/mock/mock-sms-adapter.ts @@ -5,6 +5,6 @@ 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}`); + console.log(`[SMS OTP] ${to}: ${code}`); // oxlint-disable-line no-console } } diff --git a/packages/core/src/pam/identity/service/phone-login.service.ts b/packages/core/src/pam/identity/service/phone-login.service.ts index fd3f1ecf..7646d210 100644 --- a/packages/core/src/pam/identity/service/phone-login.service.ts +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -20,9 +20,6 @@ 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, From e15ac93edc0f9b9f61e716fc4dbc76de2b3faab5 Mon Sep 17 00:00:00 2001 From: Oleksandr Agniev <70901180+agniev-a-hub@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:56:08 +0200 Subject: [PATCH 08/13] fix(pam): comment clarity fix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/core/src/pam/identity/schema/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/pam/identity/schema/index.ts b/packages/core/src/pam/identity/schema/index.ts index 129e8b4c..b76d6c84 100644 --- a/packages/core/src/pam/identity/schema/index.ts +++ b/packages/core/src/pam/identity/schema/index.ts @@ -138,9 +138,9 @@ 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. +// One active OTP per user: `requestOtp` upserts by `userId`, so a new code overwrites 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', { From c47aced2eb8fdc2d5bf98cdf0ff69a24adbcbba9 Mon Sep 17 00:00:00 2001 From: Olek Date: Tue, 14 Jul 2026 14:01:12 +0200 Subject: [PATCH 09/13] fix(identity): code review correction s and refactor --- .../core/src/contracts/schemas/identity.ts | 3 +- .../drizzle/migrations/0003_open_shotgun.sql | 1 + .../migrations/meta/0003_snapshot.json | 695 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + .../core/src/pam/identity/schema/index.ts | 5 +- .../pam/identity/service/identity.service.ts | 2 +- .../identity/service/phone-login.service.ts | 10 +- 7 files changed, 712 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/pam/identity/drizzle/migrations/0003_open_shotgun.sql create mode 100644 packages/core/src/pam/identity/drizzle/migrations/meta/0003_snapshot.json diff --git a/packages/core/src/contracts/schemas/identity.ts b/packages/core/src/contracts/schemas/identity.ts index c7cc36bf..392b2104 100644 --- a/packages/core/src/contracts/schemas/identity.ts +++ b/packages/core/src/contracts/schemas/identity.ts @@ -22,8 +22,7 @@ export const UserSchema = z.object({ 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 E164PhoneSchema = z.string().regex(/^\+[1-9][0-9]{7,14}$/); export const PhoneLoginRequestInputSchema = z.object({ phone: E164PhoneSchema }); diff --git a/packages/core/src/pam/identity/drizzle/migrations/0003_open_shotgun.sql b/packages/core/src/pam/identity/drizzle/migrations/0003_open_shotgun.sql new file mode 100644 index 00000000..18414f92 --- /dev/null +++ b/packages/core/src/pam/identity/drizzle/migrations/0003_open_shotgun.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD CONSTRAINT "user_phoneNumber_unique" UNIQUE("phone_number"); diff --git a/packages/core/src/pam/identity/drizzle/migrations/meta/0003_snapshot.json b/packages/core/src/pam/identity/drizzle/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..63a43539 --- /dev/null +++ b/packages/core/src/pam/identity/drizzle/migrations/meta/0003_snapshot.json @@ -0,0 +1,695 @@ +{ + "id": "72d84be8-0179-421d-887f-0debf56b930e", + "prevId": "062c0656-9ff5-455b-aa03-4736ccf7a921", + "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": { + "sms_otp_session_userId_unique": { + "name": "sms_otp_session_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "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"] + }, + "user_phoneNumber_unique": { + "name": "user_phoneNumber_unique", + "nullsNotDistinct": false, + "columns": ["phone_number"] + } + }, + "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 a072fbfb..4de1b35a 100644 --- a/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1783944672839, "tag": "0002_outstanding_patriot", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1784029716450, + "tag": "0003_open_shotgun", + "breakpoints": true } ] } diff --git a/packages/core/src/pam/identity/schema/index.ts b/packages/core/src/pam/identity/schema/index.ts index b76d6c84..a70f2eb8 100644 --- a/packages/core/src/pam/identity/schema/index.ts +++ b/packages/core/src/pam/identity/schema/index.ts @@ -34,10 +34,7 @@ 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(), + phoneNumber: text().unique(), phoneVerified: boolean().notNull().default(false), phoneVerifiedAt: timestamp({ withTimezone: true }), failedLoginAttempts: integer().notNull().default(0), diff --git a/packages/core/src/pam/identity/service/identity.service.ts b/packages/core/src/pam/identity/service/identity.service.ts index eca6e192..df12626b 100644 --- a/packages/core/src/pam/identity/service/identity.service.ts +++ b/packages/core/src/pam/identity/service/identity.service.ts @@ -479,7 +479,7 @@ export class IdentityService { private clearLockout(userId: User['id']) { return this.drizzle.db .update(user) - .set({ failedLoginAttempts: 0, lockoutUntil: null, lockoutCount: 0, lastLockoutAt: null }) + .set({ failedLoginAttempts: 0, lockoutUntil: null }) .where(eq(user.id, userId)); } diff --git a/packages/core/src/pam/identity/service/phone-login.service.ts b/packages/core/src/pam/identity/service/phone-login.service.ts index 7646d210..621db514 100644 --- a/packages/core/src/pam/identity/service/phone-login.service.ts +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -59,9 +59,11 @@ function hashCode(code: string): string { } function generateCode(): string { - randomInt(0, 1_000_000).toString().padStart(6, '0'); - return '123456'; // Currently, we use it for better testing exp; - // return randomInt(0, 1_000_000).toString().padStart(6, '0'); + const code = randomInt(0, 1_000_000).toString().padStart(6, '0'); + if (process.env['NODE_ENV'] === 'production') { + console.log(`SMS Verification code sent: ${code}`); // oxlint-disable-line no-console + } + return code; } type UserRow = Pick< @@ -99,7 +101,7 @@ export type PhoneLoginServiceDeps = { drizzle: DrizzleService; events: EventBus; sms: SmsAdapter; - limiter?: RateLimiterAdapter; + limiter: RateLimiterAdapter; }; export class PhoneLoginService { From 5febab7ca4bb462de3fdc003fd2ef9e999cbfa5c Mon Sep 17 00:00:00 2001 From: Olek Date: Tue, 14 Jul 2026 14:36:06 +0200 Subject: [PATCH 10/13] fix(identity): additional debug iteration --- packages/core/src/audit/plugin.ts | 13 + .../core/src/contracts/schemas/identity.ts | 2 +- .../__tests__/phone-login.service.test.ts | 14 +- .../core/src/pam/identity/contract/index.ts | 2 +- .../migrations/0004_round_giant_girl.sql | 1 + .../migrations/meta/0004_snapshot.json | 680 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + .../core/src/pam/identity/schema/index.ts | 5 +- .../identity/service/phone-login.service.ts | 75 +- 9 files changed, 752 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/pam/identity/drizzle/migrations/0004_round_giant_girl.sql create mode 100644 packages/core/src/pam/identity/drizzle/migrations/meta/0004_snapshot.json diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index da3ca378..c74d217e 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -172,6 +172,18 @@ function mapEventToRecord(topic: string, p: Record): RecordInpu }; } + // An OTP was issued (smsOtpSession row created/upserted). System-driven credential + // dispatch; resourceId = the user the code was sent for. + if (topic === 'identity.phone_otp.requested') { + return { + ...base, + actorType: 'system', + 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') { @@ -361,6 +373,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'identity.user.unlocked', 'identity.user.logout', 'identity.user.phone_login', + 'identity.phone_otp.requested', 'identity.phone_otp.cancelled', 'identity.2fa.enabled', 'identity.2fa.disabled', diff --git a/packages/core/src/contracts/schemas/identity.ts b/packages/core/src/contracts/schemas/identity.ts index 392b2104..521d708e 100644 --- a/packages/core/src/contracts/schemas/identity.ts +++ b/packages/core/src/contracts/schemas/identity.ts @@ -33,7 +33,7 @@ export const PhoneLoginRequestOutputSchema = z.object({ export const PhoneLoginVerifyInputSchema = z.object({ phone: E164PhoneSchema, - code: z.string().regex(/^\d{6}$/), + code: z.string().regex(/^[0-9]{6}$/), rememberMe: z.boolean().optional(), }); 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 index 7bd80132..15e17028 100644 --- a/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts +++ b/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts @@ -92,10 +92,11 @@ describe('PhoneLoginService.requestOtp', () => { expect(events.emit).not.toHaveBeenCalled(); }); - it('throws FORBIDDEN when the phone is registered but not verified', async () => { + it('anti-enumeration: unverified phone returns success shape without sending SMS', async () => { const { svc, sms } = build({ select: [[{ id: 'u1', phoneVerified: false }]] }); - await expect(svc.requestOtp({ phone: PHONE })).rejects.toMatchObject({ code: 'FORBIDDEN' }); + const out = await svc.requestOtp({ phone: PHONE }); + expect(out).toEqual({ expiresAt: expect.any(String), resendAfter: expect.any(String) }); expect(sms.sendOtp).not.toHaveBeenCalled(); }); @@ -136,8 +137,8 @@ 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], []], + // (1) otp lookup, (2) user lookup, (3) delete otp in tx, (4) session insert in tx. + select: [[otpRow({ codeHash: hash(code) })], [userRow], [], []], }); const out = await svc.verifyOtp({ phone: PHONE, code }); @@ -154,7 +155,7 @@ describe('PhoneLoginService.verifyOtp', () => { it('rememberMe extends the session TTL to ~30 days', async () => { const code = '123456'; const { svc } = build({ - select: [[otpRow({ codeHash: hash(code) })], [], [userRow], []], + select: [[otpRow({ codeHash: hash(code) })], [userRow], [], []], }); const out = await svc.verifyOtp({ phone: PHONE, code, rememberMe: true }); @@ -227,10 +228,9 @@ describe('PhoneLoginService.verifyOtp', () => { 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. + // (1) otp lookup, (2) blocked user lookup - RG check before tx so OTP is not consumed. select: [ [otpRow({ codeHash: hash(code) })], - [], [{ ...userRow, rgBlocked: true, rgBlockedUntil: null }], ], }); diff --git a/packages/core/src/pam/identity/contract/index.ts b/packages/core/src/pam/identity/contract/index.ts index 3295da22..6e03e857 100644 --- a/packages/core/src/pam/identity/contract/index.ts +++ b/packages/core/src/pam/identity/contract/index.ts @@ -23,7 +23,7 @@ import { import { PageQuerySchema, paginated } from '@openora/core/contracts/kit'; import * as z from 'zod'; -const SessionSchema = z.object({ +export const SessionSchema = z.object({ token: z.string(), expiresAt: TimestampSchema, }); diff --git a/packages/core/src/pam/identity/drizzle/migrations/0004_round_giant_girl.sql b/packages/core/src/pam/identity/drizzle/migrations/0004_round_giant_girl.sql new file mode 100644 index 00000000..7606bec5 --- /dev/null +++ b/packages/core/src/pam/identity/drizzle/migrations/0004_round_giant_girl.sql @@ -0,0 +1 @@ +DROP INDEX "sms_otp_session_user_id_idx"; diff --git a/packages/core/src/pam/identity/drizzle/migrations/meta/0004_snapshot.json b/packages/core/src/pam/identity/drizzle/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..16083550 --- /dev/null +++ b/packages/core/src/pam/identity/drizzle/migrations/meta/0004_snapshot.json @@ -0,0 +1,680 @@ +{ + "id": "066f8a9d-0d27-4c80-8b63-b105d3d5c004", + "prevId": "72d84be8-0179-421d-887f-0debf56b930e", + "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_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": { + "sms_otp_session_userId_unique": { + "name": "sms_otp_session_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "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"] + }, + "user_phoneNumber_unique": { + "name": "user_phoneNumber_unique", + "nullsNotDistinct": false, + "columns": ["phone_number"] + } + }, + "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 4de1b35a..ece963b8 100644 --- a/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/pam/identity/drizzle/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1784029716450, "tag": "0003_open_shotgun", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1784032154664, + "tag": "0004_round_giant_girl", + "breakpoints": true } ] } diff --git a/packages/core/src/pam/identity/schema/index.ts b/packages/core/src/pam/identity/schema/index.ts index a70f2eb8..61fd6e2f 100644 --- a/packages/core/src/pam/identity/schema/index.ts +++ b/packages/core/src/pam/identity/schema/index.ts @@ -152,10 +152,7 @@ export const smsOtpSession = pgTable( 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), - ], + (t) => [index('sms_otp_session_phone_idx').on(t.phone)], ); export type User = typeof user.$inferSelect; diff --git a/packages/core/src/pam/identity/service/phone-login.service.ts b/packages/core/src/pam/identity/service/phone-login.service.ts index 621db514..ad9298e1 100644 --- a/packages/core/src/pam/identity/service/phone-login.service.ts +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -1,7 +1,7 @@ import { createHash, randomInt, randomUUID } from 'node:crypto'; import { ORPCError } from '@orpc/server'; import { type EventBus, DrizzleService, assertRateLimit } from '@openora/core/server'; -import { eq, sql } from 'drizzle-orm'; +import { and, eq, gt, sql } from 'drizzle-orm'; import type { RateLimiterAdapter, SmsAdapter, @@ -15,7 +15,7 @@ import { isRgBlocked } from './rg-guard.service.js'; const MINUTE_MS = 60 * 1000; const OTP_TTL_MS = 5 * MINUTE_MS; -const RESEND_COOLDOWN_MS = 60 * 1000; +const RESEND_COOLDOWN_MS = MINUTE_MS; const MAX_VERIFY_ATTEMPTS = 5; const SESSION_TTL_MS = 24 * 60 * 60 * 1000; const REMEMBER_ME_TTL_MS = 30 * 24 * 60 * 60 * 1000; @@ -60,7 +60,7 @@ function hashCode(code: string): string { function generateCode(): string { const code = randomInt(0, 1_000_000).toString().padStart(6, '0'); - if (process.env['NODE_ENV'] === 'production') { + if (process.env['NODE_ENV'] !== 'production') { console.log(`SMS Verification code sent: ${code}`); // oxlint-disable-line no-console } return code; @@ -108,7 +108,7 @@ export class PhoneLoginService { private readonly drizzle: DrizzleService; private readonly events: EventBus; private readonly sms: SmsAdapter; - private readonly limiter?: RateLimiterAdapter; + private readonly limiter: RateLimiterAdapter; constructor({ drizzle, events, sms, limiter }: PhoneLoginServiceDeps) { this.drizzle = drizzle; @@ -133,22 +133,17 @@ export class PhoneLoginService { .where(eq(user.phoneNumber, phone)) .limit(1); - // Anti-enumeration: an unknown phone gets the same success-shaped response, minus - // the SMS. A caller cannot distinguish a real number from a fake one. - if (!account) { + // Anti-enumeration: an unknown or unverified phone gets the same success-shaped + // response, minus the SMS. A caller cannot distinguish a real verified number from + // a fake or unverified one. + if (!account || !account.phoneVerified) { return { expiresAt: expiresAt.toISOString(), resendAfter: resendAfter.toISOString() }; } - if (!account.phoneVerified) { - throw new ORPCError('FORBIDDEN', { - message: 'Phone number is not verified on this account.', - }); - } - const [existing] = await this.drizzle.db .select({ createdAt: smsOtpSession.createdAt }) .from(smsOtpSession) - .where(eq(smsOtpSession.userId, account.id)) + .where(and(eq(smsOtpSession.userId, account.id), gt(smsOtpSession.expiresAt, new Date()))) .limit(1); if (existing && now - existing.createdAt.getTime() < RESEND_COOLDOWN_MS) { @@ -166,14 +161,16 @@ export class PhoneLoginService { set: { phone, codeHash, expiresAt, failedAttempts: 0, createdAt: new Date() }, }); + await this.sms.sendOtp({ to: phone, code }); + + // Emit events only after SMS delivery so a send failure leaves no misleading + // audit trail (no cancelled-without-requested orphan in the audit log). if (existing) { this.events.emit('identity.phone_otp.cancelled', { userId: account.id, reason: 'new_otp_requested', }); } - - await this.sms.sendOtp({ to: phone, code }); this.events.emit('identity.phone_otp.requested', { userId: account.id }); return { expiresAt: expiresAt.toISOString(), resendAfter: resendAfter.toISOString() }; @@ -207,6 +204,9 @@ export class PhoneLoginService { if (hashCode(code) !== otp.codeHash) { // Atomic increment so concurrent guesses can't each read a stale count and slip // past the cap. + // Individual wrong-code attempts are not emitted as audit events; the final + // `identity.phone_otp.cancelled` event captures the outcome. High-volume + // per-attempt events would bloat the audit log (same rationale as chat messages). const [row] = await this.drizzle.db .update(smsOtpSession) .set({ failedAttempts: sql`${smsOtpSession.failedAttempts} + 1` }) @@ -214,8 +214,9 @@ export class PhoneLoginService { .returning({ failedAttempts: smsOtpSession.failedAttempts }); const newAttempts = row?.failedAttempts; - // row is undefined when a concurrent request already deleted the session after hitting - // the cap — treat it as already cancelled; skip the emit to avoid duplicates. + // row is undefined when a concurrent request already deleted the session after + // hitting the cap — treat it as already cancelled; skip the emit to avoid + // duplicates. if (newAttempts === undefined || newAttempts >= MAX_VERIFY_ATTEMPTS) { if (newAttempts !== undefined) { await this.drizzle.db.delete(smsOtpSession).where(eq(smsOtpSession.id, otp.id)); @@ -229,9 +230,8 @@ export class PhoneLoginService { 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)); - + // Resolve the account before entering the transaction so the RG check can short- + // circuit without consuming the OTP: a blocked user can try again once unblocked. const [account] = await this.drizzle.db .select({ id: user.id, @@ -255,8 +255,8 @@ export class PhoneLoginService { throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Account not found.' }); } - // RG login block, applied only AFTER the OTP verifies so a probe can't distinguish a - // restricted account from a wrong code. + // 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', { @@ -266,17 +266,24 @@ export class PhoneLoginService { // Mint the session directly - bypasses better-auth's TOTP plugin chain by design, // so phone login never triggers a second factor. + // Delete the OTP and insert the new session atomically: if the insert fails the + // OTP is not consumed and the user can retry with the same code. 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(), + const sessionExpiresAt = new Date( + Date.now() + (rememberMe ? REMEMBER_ME_TTL_MS : SESSION_TTL_MS), + ); + await this.drizzle.db.transaction(async (t) => { + await t.delete(smsOtpSession).where(eq(smsOtpSession.id, otp.id)); + await t.insert(session).values({ + id: randomUUID(), + token, + userId: account.id, + expiresAt: sessionExpiresAt, + ipAddress: ip, + userAgent, + createdAt: new Date(), + updatedAt: new Date(), + }); }); this.events.emit('identity.user.phone_login', { @@ -288,7 +295,7 @@ export class PhoneLoginService { return { user: serializeUser(account), - session: { token, expiresAt: expiresAt.toISOString() }, + session: { token, expiresAt: sessionExpiresAt.toISOString() }, }; } } From 47991b2db67f1cb740ece88be7dc781bbc412024 Mon Sep 17 00:00:00 2001 From: Olek Date: Tue, 14 Jul 2026 14:36:33 +0200 Subject: [PATCH 11/13] fix(identity): additional debug iteration --- docs/catalog.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/catalog.json b/docs/catalog.json index ba887cd1..67e2dcf8 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -1086,6 +1086,10 @@ "name": "SessionItemSchema", "file": "packages/core/src/pam/identity/contract/index.ts" }, + { + "name": "SessionSchema", + "file": "packages/core/src/pam/identity/contract/index.ts" + }, { "name": "SessionTimeDetailSchema", "file": "packages/core/src/compliance/contract/rg.ts" From 7b1cc71e7a5a95d4eddafc5524b1fecf3131bd7d Mon Sep 17 00:00:00 2001 From: Olek Date: Wed, 15 Jul 2026 15:21:12 +0200 Subject: [PATCH 12/13] fix(pam): return pre commit --- .husky/pre-commit | 2 +- .../core/src/pam/identity/__tests__/phone-login.service.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index b5976ad6..fe869ab8 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,7 +3,7 @@ # unstaged changes. staged=$(git diff --cached --name-only --diff-filter=ACMR) -pnpm lint +pnpm lint:fix pnpm format if [ -n "$staged" ]; then 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 index 15e17028..99286863 100644 --- a/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts +++ b/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts @@ -27,6 +27,7 @@ const userRow = { const allowLimiter = (): RateLimiterAdapter => ({ consume: vi.fn().mockResolvedValue({ allowed: true, retryAfterMs: 0 }), + reset: vi.fn(), }); function build({ From d0b7b6f79b69f15cf66ead840c781f7a91ffe4ca Mon Sep 17 00:00:00 2001 From: Olek Date: Wed, 15 Jul 2026 20:19:52 +0200 Subject: [PATCH 13/13] fix(pam): drift check fix --- docs/catalog.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/catalog.json b/docs/catalog.json index 23b112b3..f7c5faef 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -1360,9 +1360,9 @@ "POST /identity/password/change", "POST /identity/password/forgot", "POST /identity/password/reset", + "POST /identity/password/verify-otp", "POST /identity/phone-login/request", "POST /identity/phone-login/verify", - "POST /identity/password/verify-otp", "POST /identity/register", "POST /identity/sessions/revoke", "POST /identity/sessions/revoke-all",