import crypto from 'node:crypto'; import type pg from 'pg'; import { config } from '../../core/config.js'; import { HttpError, getHeader, type RequestContext } from '../../core/http.js'; import { queryOne } from '../../core/db.js'; export interface PlatformUserSummary { id: string; username: string | null; phone: string | null; name: string | null; avatarUrl: string | null; primaryRole: string; createdAt: string; } export interface LoginSessionSummary { token: string; expiresAt: string; } export function clientIpFrom(ctx: RequestContext) { const forwarded = getHeader(ctx.req, 'x-forwarded-for'); return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim(); } export function userAgentFrom(ctx: RequestContext) { return getHeader(ctx.req, 'user-agent'); } export function normalizeChinaPhone(phone: string) { return phone.replace(/\s+/g, '').replace(/^\+?86/, ''); } export function assertChinaPhone(phone: string) { const normalized = normalizeChinaPhone(phone); if (!/^1[3-9]\d{9}$/.test(normalized)) { throw new HttpError(400, 'Invalid China mainland phone number', 'INVALID_PHONE'); } return normalized; } export function normalizePurpose(value: string) { if (['login', 'bind_phone', 'reset_password'].includes(value)) return value; throw new HttpError(400, 'Unsupported SMS purpose', 'UNSUPPORTED_SMS_PURPOSE'); } export function generateSmsCode() { return crypto.randomInt(100000, 1000000).toString(); } export function hashSmsCode(tenantId: string, phone: string, purpose: string, code: string) { return crypto .createHmac('sha256', config.authCodePepper) .update([tenantId, phone, purpose, code].join(':')) .digest('hex'); } export function createSessionToken() { return `tk_${crypto.randomBytes(32).toString('base64url')}`; } export function hashSessionToken(token: string) { return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex'); } export async function findUserBySessionToken(token: string) { const tokenHash = hashSessionToken(token); return queryOne< PlatformUserSummary & { tenantId: string; sessionId: string; sessionExpiresAt: string; } >( ` select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.created_at as "createdAt", s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt" from app_private.auth_sessions s join public.platform_users u on u.id = s.user_id where s.token_hash = $1 and s.revoked_at is null and s.expires_at > now() limit 1 `, [tokenHash], ); } export function bearerTokenFrom(ctx: RequestContext) { const authorization = getHeader(ctx.req, 'authorization'); const match = authorization.match(/^Bearer\s+(.+)$/i); return match?.[1]?.trim() || ''; } export async function createLoginSession( client: pg.PoolClient, input: { tenantId: string; userId: string; provider: string; ipAddress?: string; userAgent?: string; metadata?: Record; }, ): Promise { const token = createSessionToken(); const tokenHash = hashSessionToken(token); const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString(); await client.query( ` insert into app_private.auth_sessions ( tenant_id, user_id, token_hash, provider, expires_at, ip_address, user_agent, metadata ) values ($1, $2, $3, $4, $5::timestamptz, $6, $7, $8::jsonb) `, [ input.tenantId, input.userId, tokenHash, input.provider, expiresAt, input.ipAddress || null, input.userAgent || null, JSON.stringify(input.metadata || {}), ], ); return { token, expiresAt }; } export async function upsertPhoneUser( client: pg.PoolClient, input: { tenantId: string; phone: string; }, ) { const existing = await client.query( ` select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.created_at as "createdAt" from public.user_identities i join public.platform_users u on u.id = i.user_id where i.provider = 'phone' and i.provider_subject = $1 limit 1 `, [input.phone], ); if (existing.rows[0]) { const user = existing.rows[0]; await ensureStudentTenantRecords(client, input.tenantId, user.id); return { user, isNewUser: false }; } const legacyPhoneUser = await client.query( ` select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.created_at as "createdAt" from public.platform_users u where u.phone = $1 order by u.created_at asc limit 1 `, [input.phone], ); if (legacyPhoneUser.rows[0]) { const user = legacyPhoneUser.rows[0]; await client.query( ` insert into public.user_identities (user_id, provider, provider_subject, phone) values ($1, 'phone', $2, $2) on conflict (provider, provider_subject) do update set user_id = excluded.user_id, phone = excluded.phone, updated_at = now() `, [user.id, input.phone], ); await ensureStudentTenantRecords(client, input.tenantId, user.id); return { user, isNewUser: false }; } const userResult = await client.query( ` insert into public.platform_users (username, phone, primary_role, raw_profile) values ($1, $2, 'student', $3::jsonb) returning id, username, phone, name, avatar_url as "avatarUrl", primary_role as "primaryRole", created_at as "createdAt" `, [`u_${input.phone.slice(-4)}_${Date.now().toString(36)}`, input.phone, JSON.stringify({ source: 'sms_login' })], ); const user = userResult.rows[0]; await client.query( ` insert into public.user_identities (user_id, provider, provider_subject, phone) values ($1, 'phone', $2, $2) on conflict (provider, provider_subject) do update set user_id = excluded.user_id, phone = excluded.phone, updated_at = now() `, [user.id, input.phone], ); await ensureStudentTenantRecords(client, input.tenantId, user.id); return { user, isNewUser: true }; } export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) { await client.query( ` insert into public.tenant_memberships (tenant_id, user_id, role, status) values ($1, $2, 'student', 'active') on conflict (tenant_id, user_id, role) do update set status = 'active', updated_at = now() `, [tenantId, userId], ); await client.query( ` insert into public.student_profiles (tenant_id, user_id, stats, progress) values ($1, $2, $3::jsonb, '{}'::jsonb) on conflict (tenant_id, user_id) do nothing `, [ tenantId, userId, JSON.stringify({ totalAnswered: 0, correctCount: 0, wrongCount: 0, studyDays: 1, }), ], ); } export async function writeLoginEvent( client: pg.PoolClient, input: { tenantId: string; userId?: string | null; provider: string; identifier?: string; result: 'sent' | 'success' | 'failed' | 'blocked'; failureCode?: string | null; ipAddress?: string; userAgent?: string; metadata?: Record; }, ) { await client.query( ` insert into public.auth_login_events ( tenant_id, user_id, provider, identifier, result, failure_code, ip_address, user_agent, metadata ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) `, [ input.tenantId, input.userId || null, input.provider, input.identifier || null, input.result, input.failureCode || null, input.ipAddress || null, input.userAgent || null, JSON.stringify(input.metadata || {}), ], ); }