import { type Request, type Response, Router } from 'express'; import { z } from 'zod'; import type { AuthEntryRequest, AuthPhoneChangeRequest, AuthPhoneLoginRequest, AuthPhoneSendCodeRequest, AuthWechatBindPhoneRequest, } from '../../../packages/shared/src/contracts/auth.js'; import { buildAuthRequestContext } from '../auth/authRequestContext.js'; import { bindWechatPhone, buildAuthLoginOptionsResponse, buildAuthMeResponse, changeUserPhone, createRefreshSession, entryWithPassword, entryWithPhoneCode, liftRiskBlock, listActiveRiskBlocks, listAuthAuditLogs, listUserSessions, logoutAllUserSessions, logoutUser, refreshAuthSession, resolveWechatCallback, revokeRefreshSession, revokeUserSession, sendPhoneLoginCode, startWechatLogin, } from '../auth/authService.js'; import { clearRefreshSessionCookie, readRefreshSessionToken, setRefreshSessionCookie, } from '../auth/refreshSessionCookie.js'; import type { AppContext } from '../context.js'; import { asyncHandler, sendApiResponse } from '../http.js'; import { requireJwtAuth } from '../middleware/auth.js'; import { routeMeta } from '../middleware/routeMeta.js'; const authEntrySchema = z.object({ username: z.string(), password: z.string(), }); const authPhoneSendCodeSchema = z.object({ phone: z.string(), scene: z.enum(['login', 'bind_phone', 'change_phone']).optional(), captchaChallengeId: z.string().optional(), captchaAnswer: z.string().optional(), }); const authPhoneLoginSchema = z.object({ phone: z.string(), code: z.string(), }); const authPhoneChangeSchema = z.object({ phone: z.string(), code: z.string(), }); const authWechatBindPhoneSchema = z.object({ phone: z.string(), code: z.string(), }); function resolveRequestOrigin(request: Request) { const forwardedProto = request.header('x-forwarded-proto')?.split(',')[0]?.trim(); const forwardedHost = request.header('x-forwarded-host')?.split(',')[0]?.trim(); const protocol = forwardedProto || request.protocol || 'http'; const host = forwardedHost || request.header('host') || '127.0.0.1:8081'; return `${protocol}://${host}`; } function normalizeRedirectPath(rawValue: unknown, fallback: string) { if (typeof rawValue !== 'string' || !rawValue.trim()) { return fallback; } const value = rawValue.trim(); if (value.startsWith('/')) { return value; } try { const url = new URL(value); return `${url.pathname}${url.search}${url.hash}`; } catch { return fallback; } } function buildAuthResultRedirectUrl( redirectPath: string, params: Record, ) { const hash = new URLSearchParams(params).toString(); const [pathWithoutHash] = redirectPath.split('#'); return `${pathWithoutHash || '/'}#${hash}`; } function buildRefreshCookieLifetimeSeconds( context: AppContext, expiresAt: string, ) { return Math.max( 0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000), ); } export function createAuthRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); router.get( '/login-options', routeMeta({ operation: 'auth.login_options' }), asyncHandler(async (_request, response) => { sendApiResponse(response, buildAuthLoginOptionsResponse(context)); }), ); router.post( '/entry', routeMeta({ operation: 'auth.entry' }), asyncHandler(async (request, response) => { const payload = authEntrySchema.parse(request.body) as AuthEntryRequest; const requestContext = buildAuthRequestContext(request); const result = await entryWithPassword( context, payload.username, payload.password, requestContext, ); const user = await context.userRepository.findById(result.user.id); if (!user) { throw new Error('failed to resolve auth user after password entry'); } const refreshSession = await createRefreshSession( context, user, requestContext, ); setRefreshSessionCookie( response, context.config, refreshSession.refreshToken, buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), ); sendApiResponse(response, result); }), ); router.post( '/phone/send-code', routeMeta({ operation: 'auth.phone.send_code' }), asyncHandler(async (request, response) => { const payload = authPhoneSendCodeSchema.parse( request.body, ) as AuthPhoneSendCodeRequest; sendApiResponse( response, await sendPhoneLoginCode( context, payload.phone, payload.scene, buildAuthRequestContext(request), { captchaChallengeId: payload.captchaChallengeId, captchaAnswer: payload.captchaAnswer, }, ), ); }), ); router.post( '/phone/change', routeMeta({ operation: 'auth.phone.change' }), requireAuth, asyncHandler(async (request, response) => { const payload = authPhoneChangeSchema.parse( request.body, ) as AuthPhoneChangeRequest; const requestContext = buildAuthRequestContext(request); sendApiResponse( response, await changeUserPhone( context, request.userId!, payload.phone, payload.code, requestContext, ), ); }), ); router.post( '/phone/login', routeMeta({ operation: 'auth.phone.login' }), asyncHandler(async (request, response) => { const payload = authPhoneLoginSchema.parse( request.body, ) as AuthPhoneLoginRequest; const requestContext = buildAuthRequestContext(request); const result = await entryWithPhoneCode( context, payload.phone, payload.code, requestContext, ); const user = await context.userRepository.findById(result.user.id); if (!user) { throw new Error('failed to resolve auth user after phone entry'); } const refreshSession = await createRefreshSession( context, user, requestContext, ); setRefreshSessionCookie( response, context.config, refreshSession.refreshToken, buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), ); sendApiResponse(response, result); }), ); router.get( '/wechat/start', routeMeta({ operation: 'auth.wechat.start' }), asyncHandler(async (request, response) => { const redirectPath = normalizeRedirectPath( request.query.redirectPath, context.config.wechatAuth.defaultRedirectPath, ); const requestContext = buildAuthRequestContext(request); const callbackUrl = new URL( context.config.wechatAuth.callbackPath, resolveRequestOrigin(request), ).toString(); sendApiResponse( response, await startWechatLogin( context, callbackUrl, redirectPath, requestContext, ), ); }), ); router.get( '/wechat/callback', routeMeta({ operation: 'auth.wechat.callback' }), asyncHandler(async (request, response) => { const state = typeof request.query.state === 'string' ? request.query.state.trim() : ''; const stateRecord = context.wechatAuthStates.consume(state); const redirectPath = stateRecord?.redirectPath ?? context.config.wechatAuth.defaultRedirectPath; if (!stateRecord) { response.redirect( 302, buildAuthResultRedirectUrl(redirectPath, { auth_provider: 'wechat', auth_error: '微信登录状态已失效,请重新发起登录。', }), ); return; } try { const requestContext = buildAuthRequestContext(request); const result = await resolveWechatCallback(context, { code: typeof request.query.code === 'string' ? request.query.code : null, mockCode: typeof request.query.mock_code === 'string' ? request.query.mock_code : null, }, requestContext); const user = await context.userRepository.findById(result.user.id); if (!user) { throw new Error('failed to resolve auth user after wechat callback'); } const refreshSession = await createRefreshSession( context, user, requestContext, ); setRefreshSessionCookie( response, context.config, refreshSession.refreshToken, buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), ); response.redirect( 302, buildAuthResultRedirectUrl(redirectPath, { auth_provider: 'wechat', auth_token: result.token, auth_binding_status: result.user.bindingStatus, }), ); } catch (error) { const message = error instanceof Error ? error.message : '微信登录失败,请稍后再试。'; response.redirect( 302, buildAuthResultRedirectUrl(redirectPath, { auth_provider: 'wechat', auth_error: message, }), ); } }), ); router.post( '/wechat/bind-phone', routeMeta({ operation: 'auth.wechat.bind_phone' }), requireAuth, asyncHandler(async (request, response) => { const payload = authWechatBindPhoneSchema.parse( request.body, ) as AuthWechatBindPhoneRequest; const requestContext = buildAuthRequestContext(request); const result = await bindWechatPhone( context, request.userId!, payload.phone, payload.code, requestContext, ); const user = await context.userRepository.findById(result.user.id); if (!user) { throw new Error('failed to resolve auth user after wechat bind'); } const refreshSession = await createRefreshSession( context, user, requestContext, ); setRefreshSessionCookie( response, context.config, refreshSession.refreshToken, buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), ); sendApiResponse(response, result); }), ); router.post( '/refresh', routeMeta({ operation: 'auth.refresh' }), asyncHandler(async (request, response) => { const refreshToken = readRefreshSessionToken(request, context.config); try { const result = await refreshAuthSession(context, refreshToken); setRefreshSessionCookie( response, context.config, result.refreshToken, buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt), ); sendApiResponse(response, { ok: true, token: result.token, }); } catch (error) { clearRefreshSessionCookie(response, context.config); throw error; } }), ); router.get( '/risk-blocks', routeMeta({ operation: 'auth.risk_blocks' }), requireAuth, asyncHandler(async (request, response) => { const user = await context.userRepository.findById(request.userId!); sendApiResponse( response, await listActiveRiskBlocks( context, user!, buildAuthRequestContext(request), ), ); }), ); router.post( '/risk-blocks/:scopeType/lift', routeMeta({ operation: 'auth.risk_blocks.lift' }), requireAuth, asyncHandler(async (request, response) => { const user = await context.userRepository.findById(request.userId!); sendApiResponse( response, await liftRiskBlock( context, user!, buildAuthRequestContext(request), request.params.scopeType === 'phone' ? 'phone' : 'ip', ), ); }), ); router.get( '/sessions', routeMeta({ operation: 'auth.sessions' }), requireAuth, asyncHandler(async (request, response) => { const refreshToken = readRefreshSessionToken(request, context.config); sendApiResponse( response, await listUserSessions(context, request.userId!, refreshToken), ); }), ); router.post( '/sessions/:sessionId/revoke', routeMeta({ operation: 'auth.sessions.revoke' }), requireAuth, asyncHandler(async (request, response) => { const refreshToken = readRefreshSessionToken(request, context.config); sendApiResponse( response, await revokeUserSession( context, request.userId!, request.params.sessionId, refreshToken, buildAuthRequestContext(request), ), ); }), ); router.get( '/audit-logs', routeMeta({ operation: 'auth.audit_logs' }), requireAuth, asyncHandler(async (request, response) => { sendApiResponse( response, await listAuthAuditLogs(context, request.userId!), ); }), ); router.get( '/me', routeMeta({ operation: 'auth.me' }), requireAuth, asyncHandler(async (request, response) => { const user = await context.userRepository.findById(request.userId!); sendApiResponse(response, await buildAuthMeResponse(context, user)); }), ); router.post( '/logout-all', routeMeta({ operation: 'auth.logout_all' }), requireAuth, asyncHandler(async (request, response) => { clearRefreshSessionCookie(response, context.config); sendApiResponse( response, await logoutAllUserSessions( context, request.userId!, buildAuthRequestContext(request), ), ); }), ); router.post( '/logout', routeMeta({ operation: 'auth.logout' }), requireAuth, asyncHandler(async (request, response) => { const refreshToken = readRefreshSessionToken(request, context.config); await revokeRefreshSession(context, refreshToken); clearRefreshSessionCookie(response, context.config); sendApiResponse( response, await logoutUser( context, request.userId!, buildAuthRequestContext(request), ), ); }), ); return router; }