import * as v from 'valibot' const SendCodeBody = v.object({ email: v.pipe(v.string(), v.email()), locale: v.optional(v.string()), }) const RegisterBody = v.object({ email: v.pipe(v.string(), v.email()), password: v.pipe(v.string(), v.minLength(8)), verification_code: v.pipe(v.string(), v.minLength(1)), locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']), }) const LoginBody = v.variant('method', [ v.object({ method: v.literal('password'), email: v.pipe(v.string(), v.email()), password: v.pipe(v.string(), v.minLength(1)) }), v.object({ method: v.literal('google'), credential: v.pipe(v.string(), v.minLength(1)), oauth_flow: v.picklist(['access_token', 'authorization_code']), redirect_uri: v.optional(v.string()), }), ]) const RefreshBody = v.object({ refresh_token: v.pipe(v.string(), v.minLength(1)) }) /** * 账号:邮箱验证码注册 + 密码登录,或 Google 登录(配置了 GOOGLE_OAUTH_CLIENT_ID 时)。 * @param {import('fastify').FastifyInstance} app * @param {Record} services */ export function registerAuthRoutes(app, services) { const { auth } = services app.post('/api/voko/auth/send-code', async (request) => { const body = v.parse(SendCodeBody, request.body) await auth.sendCode({ email: body.email, purpose: 'register', locale: body.locale, ip: request.ip }) return { ok: true } }) app.post('/api/voko/auth/register', async (request) => { const body = v.parse(RegisterBody, request.body) return sessionResponse(await services.register(body)) }) app.post('/api/voko/auth/login', async (request) => { const body = v.parse(LoginBody, request.body) switch (body.method) { case 'password': return sessionResponse(await auth.login({ email: body.email, password: body.password, ip: request.ip })) case 'google': if (!services.oauthEnabled) throw codedError('Google login is not configured', 'BAD_REQUEST') return sessionResponse(await auth.oauthLogin({ provider: 'google', code: body.credential, oauth_flow: body.oauth_flow, redirect_uri: body.redirect_uri })) default: throw new Error(`Unhandled login method: ${/** @type {any} */ (body).method}`) } }) app.post('/api/voko/auth/refresh', async (request) => { const body = v.parse(RefreshBody, request.body) return sessionResponse(await auth.refresh(body)) }) app.post('/api/voko/auth/logout', async (request) => { const body = v.parse(RefreshBody, request.body) return auth.logout(body) }) } /** * @param {{ access_token: string, refresh_token: string, user: { id: string, email: string } }} result */ function sessionResponse(result) { return { access_token: result.access_token, refresh_token: result.refresh_token, user: { id: result.user.id, email: result.user.email }, } } /** * @param {string} message * @param {string} code */ export function codedError(message, code) { const err = /** @type {Error & { code: string }} */ (new Error(message)) err.code = code return err } /** * 从 Bearer access token 解析当前用户;缺失或无效即 401。 * @param {import('fastify').FastifyRequest} request * @param {Record} services * @returns {Promise<{ id: string, email: string, role: string|null }>} */ export async function requireUser(request, services) { const header = request.headers.authorization const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header.trim()) : null if (match === null) throw codedError('Authorization bearer token is required', 'TOKEN_INVALID') const claims = await services.auth.verifyAccessToken(match[1]) const user = await services.userRepo.findById(String(claims.sub)) if (!user) throw codedError(`User not found: ${claims.sub}`, 'TOKEN_INVALID') return { id: user.id, email: user.email, role: user.role === undefined ? null : user.role } }