import { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { db } from '../db/client.js'; import { users, appSettings } from '../db/schema.js'; import { eq, like } from 'drizzle-orm'; import { findOrCreateGuest, findOrCreateHuawei, findOrCreatePhone as _findOrCreatePhone, refreshJwt } from '../services/auth/jwt.js'; import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.js'; import { linkGuestAccount } from '../services/auth/account-link-service.js'; import { NotFoundError } from '../utils/errors.js'; import { config } from '../utils/config.js'; const guestLoginSchema = z.object({ deviceId: z.string().min(1), }); const huaweiLoginSchema = z.object({ authorizationCode: z.string().min(1), }); const refreshTokenSchema = z.object({ refreshToken: z.string().min(1), }); const linkSchema = z.object({ provider: z.enum(['apple']), credential: z.object({ authorizationCode: z.string().optional(), identityToken: z.string().min(1), }), mergePolicy: z.literal('server_account_first'), clientMigrationId: z.string().min(1), }); const providersQuerySchema = z.object({ platform: z.enum(['ios', 'android', 'harmony']), }); type Platform = 'ios' | 'android' | 'harmony'; interface ProviderEntry { id: string; name: string; type: 'primary' | 'secondary' | 'third_party'; iconKey?: string; enabled: boolean; } const PLATFORM_AVAILABILITY: Record = { phone_sms: ['ios', 'android', 'harmony'], huawei: ['android', 'harmony'], apple: ['ios'], wechat: ['ios', 'android', 'harmony'], qq: ['ios', 'android', 'harmony'], }; const PROVIDER_METADATA: Record = { phone_sms: { name: '短信验证码登录', type: 'secondary' }, huawei: { name: '通过华为账号登录', type: 'third_party', iconKey: 'huawei' }, apple: { name: '通过 Apple 登录', type: 'third_party', iconKey: 'apple' }, wechat: { name: '通过微信登录', type: 'third_party', iconKey: 'wechat' }, qq: { name: '通过 QQ 登录', type: 'third_party', iconKey: 'qq' }, }; const CREDENTIAL_PROVIDERS = new Set(['phone_sms', 'huawei', 'apple']); const DB_TOGGLE_PROVIDERS = new Set(['wechat', 'qq']); function isCredentialConfigured(providerId: string, _platform?: Platform): boolean { switch (providerId) { case 'phone_sms': return !!(config.ALIYUN_ACCESS_KEY_ID && config.ALIYUN_ACCESS_KEY_SECRET); case 'huawei': return !!(config.HUAWEI_CLIENT_ID && config.HUAWEI_CLIENT_SECRET); case 'apple': return !!config.APPLE_BUNDLE_ID; default: return false; } } async function getDbProviderToggles(): Promise> { const rows = await db.select().from(appSettings).where(like(appSettings.key, 'auth_provider_%_enabled')); const map = new Map(); for (const row of rows) { const providerId = row.key.replace('auth_provider_', '').replace('_enabled', ''); map.set(providerId, row.value === 'true'); } return map; } function buildProviderList(platform: Platform, dbToggles: Map): ProviderEntry[] { const providers: ProviderEntry[] = []; for (const [id, meta] of Object.entries(PROVIDER_METADATA)) { if (!PLATFORM_AVAILABILITY[id]?.includes(platform)) continue; const enabled = CREDENTIAL_PROVIDERS.has(id) ? isCredentialConfigured(id, platform) : DB_TOGGLE_PROVIDERS.has(id) ? (dbToggles.get(id) ?? false) : false; providers.push({ id, name: meta.name, type: meta.type, ...(meta.iconKey ? { iconKey: meta.iconKey } : {}), enabled, }); } return providers; } export async function authRoutes(app: FastifyInstance): Promise { // Auth endpoints: stricter rate limit (10 requests/minute) app.post('/auth/guest', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { const parsed = guestLoginSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, }); } const result = await findOrCreateGuest(parsed.data.deviceId, app); return reply.send({ success: true, data: result, error: null }); }); app.post('/auth/huawei', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { const parsed = huaweiLoginSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, }); } const userInfo = await verifyHuaweiToken(parsed.data.authorizationCode); const result = await findOrCreateHuawei(userInfo.openId, userInfo.nickname, userInfo.avatarUrl, app); return reply.send({ success: true, data: result, error: null }); }); // Phase 2: Phone login app.post('/auth/phone', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (_request, reply) => { return reply.status(501).send({ success: false, data: null, error: { code: 'NOT_IMPLEMENTED', message: 'Phone login not implemented yet' }, }); }); // ── Login Providers ────────────────────────────────────────────── app.get('/auth/providers', async (request, reply) => { const parsed = providersQuerySchema.safeParse(request.query); if (!parsed.success) { return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, }); } const dbToggles = await getDbProviderToggles(); const providers = buildProviderList(parsed.data.platform, dbToggles); return reply.send({ success: true, data: { providers }, error: null }); }); // ── Token Refresh ──────────────────────────────────────────────── app.post('/auth/refresh', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { const parsed = refreshTokenSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, }); } const result = await refreshJwt(app, parsed.data.refreshToken); return reply.send({ success: true, data: result, error: null }); }); app.post('/auth/link', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { const parsed = linkSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, }); } const { userId } = request.user; const result = await linkGuestAccount({ guestUserId: userId, provider: parsed.data.provider, credential: parsed.data.credential, mergePolicy: parsed.data.mergePolicy, clientMigrationId: parsed.data.clientMigrationId, app, }); return reply.send({ success: true, data: result, error: null }); }); app.get('/auth/me', async (request, reply) => { const { userId } = request.user; const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); if (!user) { throw new NotFoundError('User'); } return reply.send({ success: true, data: { id: user.id, nickname: user.nickname ?? null, avatarUrl: user.avatarUrl ?? null, tier: user.tier ?? 'free', xpTotal: user.xpTotal ?? 0, streakDays: user.streakDays ?? 0, heartsRemaining: user.heartsRemaining ?? 5, dailyXpEarned: user.dailyXpEarned ?? 0, dailyXpGoal: user.dailyXpGoal ?? 50, }, error: null, }); }); }