移除 fusion-auth-client、融合认证路由和阿里云 SDK 依赖, 同时保留 findOrCreatePhone、appSettings 表、auth-providers 管理端和 /auth/providers 端点等基础设施。
234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
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<string, Platform[]> = {
|
|
phone_sms: ['ios', 'android', 'harmony'],
|
|
huawei: ['android', 'harmony'],
|
|
apple: ['ios'],
|
|
wechat: ['ios', 'android', 'harmony'],
|
|
qq: ['ios', 'android', 'harmony'],
|
|
};
|
|
|
|
const PROVIDER_METADATA: Record<string, { name: string; type: ProviderEntry['type']; iconKey?: string }> = {
|
|
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<Map<string, boolean>> {
|
|
const rows = await db.select().from(appSettings).where(like(appSettings.key, 'auth_provider_%_enabled'));
|
|
const map = new Map<string, boolean>();
|
|
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<string, boolean>): 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<void> {
|
|
// 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,
|
|
});
|
|
});
|
|
}
|