diff --git a/docs/catalog.json b/docs/catalog.json index 39c81bd4..f7c5faef 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", @@ -495,6 +498,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", @@ -545,6 +558,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", @@ -553,6 +568,7 @@ "identity.user.login", "identity.user.login.failed", "identity.user.logout", + "identity.user.phone_login", "identity.user.reactivated", "identity.user.registered", "identity.user.unlocked", @@ -738,6 +754,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" @@ -934,6 +954,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" @@ -1054,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" @@ -1325,6 +1361,8 @@ "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/register", "POST /identity/sessions/revoke", "POST /identity/sessions/revoke-all", diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index f1fccdc6..c74d217e 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -160,6 +160,43 @@ 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', + }; + } + + // 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') { + 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') { @@ -335,6 +372,9 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'identity.user.lockout.triggered', 'identity.user.unlocked', 'identity.user.logout', + 'identity.user.phone_login', + 'identity.phone_otp.requested', + '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 384154df..f4cc7296 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -99,6 +99,8 @@ 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 { EmailTemplateKey, EmailTemplateData, 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 8ee7c3de..3df686d1 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -40,8 +40,18 @@ export const domainEventSchemas = { 'identity.user.login.failed': authContextBase.extend({ email: z.email(), reason: z.string().nullable().optional(), + attemptsRemaining: z.number().int().optional(), }), 'identity.user.logout': z.object({ userId: UuidSchema }), + 'identity.user.phone_login': authContextBase.extend({ + userId: UuidSchema, + method: z.literal('phone'), + }), + '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 be1ad857..fceb8f7f 100644 --- a/packages/core/src/contracts/schemas/identity.ts +++ b/packages/core/src/contracts/schemas/identity.ts @@ -18,10 +18,27 @@ export const UserSchema = z.object({ image: z.url().nullable().optional(), theme: ThemeSchema, language: LanguageSchema, + phoneNumber: z.string().nullable().optional(), + phoneVerified: z.boolean().optional(), createdAt: TimestampSchema, updatedAt: TimestampSchema, }); +export const E164PhoneSchema = z.string().regex(/^\+[1-9][0-9]{7,14}$/); + +export const PhoneLoginRequestInputSchema = z.object({ phone: E164PhoneSchema }); + +export const PhoneLoginRequestOutputSchema = z.object({ + expiresAt: TimestampSchema, + resendAfter: TimestampSchema, +}); + +export const PhoneLoginVerifyInputSchema = z.object({ + phone: E164PhoneSchema, + code: z.string().regex(/^[0-9]{6}$/), + rememberMe: z.boolean().optional(), +}); + export const OrganizationSchema = z.object({ id: UuidSchema, name: z.string().min(1).max(255), @@ -130,3 +147,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 fc431ce5..c3f3a3fa 100644 --- a/packages/core/src/pam/identity/AGENTS.md +++ b/packages/core/src/pam/identity/AGENTS.md @@ -2,7 +2,9 @@ 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`, `verifyPasswordResetOtp`, `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-verify 5/5min, 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..99286863 --- /dev/null +++ b/packages/core/src/pam/identity/__tests__/phone-login.service.test.ts @@ -0,0 +1,248 @@ +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 }), + reset: vi.fn(), +}); + +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), + failedAttempts: 0, + ...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', phoneVerified: true }], [], [], []], + }); + + 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('anti-enumeration: unverified phone returns success shape without sending SMS', async () => { + const { svc, sms } = build({ select: [[{ id: 'u1', phoneVerified: false }]] }); + + const out = await svc.requestOtp({ phone: PHONE }); + expect(out).toEqual({ expiresAt: expect.any(String), resendAfter: expect.any(String) }); + 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', phoneVerified: true }], [{ 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', phoneVerified: true }], + [{ 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) 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 }); + + 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 returns attemptsRemaining derived from failedAttempts on the dead session', async () => { + const code = '123456'; + const { svc } = build({ + // 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 with 0 attemptsRemaining', async () => { + const { svc } = build({ select: [[]] }); + await expect(svc.verifyOtp({ phone: PHONE, code: '123456' })).rejects.toMatchObject({ + code: 'UNPROCESSABLE_CONTENT', + data: { attemptsRemaining: 0 }, + }); + }); + + 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) blocked user lookup - RG check before tx so OTP is not consumed. + 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..4ced35d4 --- /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}`); // oxlint-disable-line no-console + } +} diff --git a/packages/core/src/pam/identity/contract/index.ts b/packages/core/src/pam/identity/contract/index.ts index 8ffb1c3d..93752c28 100644 --- a/packages/core/src/pam/identity/contract/index.ts +++ b/packages/core/src/pam/identity/contract/index.ts @@ -17,11 +17,14 @@ import { ChangeEmailInputSchema, IdentitySuccessSchema, TimestampSchema, + PhoneLoginRequestInputSchema, + PhoneLoginRequestOutputSchema, + PhoneLoginVerifyInputSchema, } from '@openora/core/contracts'; 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, }); @@ -53,6 +56,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/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/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/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/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/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/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/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 7471d8ba..ece963b8 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,34 @@ "when": 1783555225473, "tag": "0000_curved_tana_nile", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1783611861777, + "tag": "0001_rainy_peter_quill", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783944672839, + "tag": "0002_outstanding_patriot", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "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/plugin.ts b/packages/core/src/pam/identity/plugin.ts index 946a7a24..d2c1a833 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -10,9 +10,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 { DefaultEmailTemplateRenderer } from './adapters/default-email-template-renderer.js'; import { DrizzleAdminUserDirectory } from './admin-user-directory.js'; import { IdentityReaderService } from './adapters/identity-reader.service.js'; @@ -25,6 +28,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, @@ -65,6 +71,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 e6644b64..42eaa0b4 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, @@ -8,13 +9,27 @@ import { type EventBus, } from '@openora/core/server'; import { identityContract } from '../contract/index.js'; +import { PhoneLoginService } from '../service/phone-login.service.js'; import { IdentityService, UserNotFoundError } from '../service/identity.service.js'; import { SessionService, SessionNotFoundError } from '../service/session.service.js'; import { UnsupportedLanguageError } from '../../shared/language.js'; +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 nodeUserAgent(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,24 @@ 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: nodeForwardedFor(h), + userAgent: nodeUserAgent(h), + }); + }), + + phoneLoginVerify: os.phoneLoginVerify.handler(({ input, context }) => { + const h = context.request.headers; + return phoneLogin.verifyOtp({ + ...input, + ip: nodeForwardedFor(h), + userAgent: nodeUserAgent(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..61fd6e2f 100644 --- a/packages/core/src/pam/identity/schema/index.ts +++ b/packages/core/src/pam/identity/schema/index.ts @@ -34,8 +34,16 @@ 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), + phoneNumber: text().unique(), + 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 +135,29 @@ export const twoFactor = pgTable( ], ); +// 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', + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid() + .notNull() + .unique() + .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_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 b925cb2f..501e4546 100644 --- a/packages/core/src/pam/identity/service/identity.service.ts +++ b/packages/core/src/pam/identity/service/identity.service.ts @@ -35,6 +35,7 @@ import type { } from '@openora/core/contracts'; import { RATE_LIMIT_KEYS, makeRateLimitKey } 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(); @@ -173,6 +174,30 @@ async function ensureOk(res: globalThis.Response, opts?: { genericMessage?: stri 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; export const SESSION_DURATION_IN_SECONDS = 30 * 24 * 60 * 60; // 30 days // Coarse abuse throttles keyed by the caller identifier the context provides (email/ @@ -216,12 +241,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; @@ -365,6 +384,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, @@ -375,7 +396,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; @@ -461,9 +489,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 @@ -472,17 +501,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', { @@ -498,7 +537,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..ad9298e1 --- /dev/null +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -0,0 +1,301 @@ +import { createHash, randomInt, randomUUID } from 'node:crypto'; +import { ORPCError } from '@orpc/server'; +import { type EventBus, DrizzleService, assertRateLimit } from '@openora/core/server'; +import { and, eq, gt, sql } from 'drizzle-orm'; +import type { + RateLimiterAdapter, + SmsAdapter, + PhoneLoginRequestInput, + PhoneLoginRequestOutput, + PhoneLoginVerifyInput, + 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; +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; + +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 { + 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< + 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, + 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, phoneVerified: user.phoneVerified }) + .from(user) + .where(eq(user.phoneNumber, phone)) + .limit(1); + + // 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() }; + } + + const [existing] = await this.drizzle.db + .select({ createdAt: smsOtpSession.createdAt }) + .from(smsOtpSession) + .where(and(eq(smsOtpSession.userId, account.id), gt(smsOtpSession.expiresAt, new Date()))) + .limit(1); + + if (existing && now - existing.createdAt.getTime() < RESEND_COOLDOWN_MS) { + throw OtpCooldownError(RESEND_COOLDOWN_MS - (now - existing.createdAt.getTime())); + } + + 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() }, + }); + + 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', + }); + } + 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, + failedAttempts: smsOtpSession.failedAttempts, + }) + .from(smsOtpSession) + .where(eq(smsOtpSession.phone, phone)) + .limit(1); + + if (!otp) { + throw OtpInvalidError(0); + } + if (otp.expiresAt.getTime() < Date.now()) { + throw OtpInvalidError(MAX_VERIFY_ATTEMPTS - otp.failedAttempts); + } + + 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` }) + .where(eq(smsOtpSession.id, otp.id)) + .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. + 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); + } + + // 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, + 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); + 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. + 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. + // 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 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', { + userId: account.id, + method: 'phone', + ip, + userAgent, + }); + + return { + user: serializeUser(account), + session: { token, expiresAt: sessionExpiresAt.toISOString() }, + }; + } +} 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()); +} diff --git a/packages/testing/src/seed-demo-data.ts b/packages/testing/src/seed-demo-data.ts index 38e41de9..9c0ed259 100644 --- a/packages/testing/src/seed-demo-data.ts +++ b/packages/testing/src/seed-demo-data.ts @@ -147,6 +147,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'], @@ -218,6 +259,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, @@ -226,6 +272,9 @@ export async function seedDemoData(options: SeedOptions): Promise { role: 'player', isActive, createdAt, + phoneNumber, + phoneVerified, + phoneVerifiedAt, }); if (!playerUser) { continue; @@ -318,6 +367,9 @@ type EnsureUserInput = { role: string; isActive: boolean; createdAt?: Date; + phoneNumber?: string | null; + phoneVerified?: boolean; + phoneVerifiedAt?: Date | null; }; async function ensureUser( @@ -343,6 +395,15 @@ async function ensureUser( 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 }; }