import crypto from 'node:crypto'; import type { AuthAuditLogEntry, AuthAuditLogEventType, AuthAuditLogsResponse, AuthBindingStatus, AuthEntryResponse, AuthLoginOptionsResponse, AuthLiftRiskBlockResponse, AuthLoginMethod, AuthLogoutAllResponse, AuthMeResponse, AuthPhoneChangeResponse, AuthPhoneLoginResponse, AuthPhoneSendCodeResponse, AuthRefreshResponse, AuthRevokeSessionResponse, AuthRiskBlocksResponse, AuthRiskBlockSummary, AuthSessionsResponse, AuthSessionSummary, AuthUser, AuthWechatBindPhoneResponse, AuthWechatStartResponse, LogoutResponse, } from '../../../packages/shared/src/contracts/auth.js'; import type { AppContext } from '../context.js'; import { badRequest, captchaRequired, conflict, tooManyRequests, unauthorized, } from '../errors.js'; import type { UserRecord } from '../repositories/userRepository.js'; import { hashPassword, verifyPassword } from './password.js'; import { normalizeMainlandChinaPhoneNumber, validateSmsVerifyCode, } from './phoneNumber.js'; import { createRefreshSessionToken, hashRefreshSessionToken, type RefreshSessionRequestContext, } from './refreshSessionCookie.js'; import { signAccessToken } from './token.js'; const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,24}$/u; function normalizeUsername(username: string) { return username.trim(); } function validateCredentials(username: string, password: string) { if (!USERNAME_PATTERN.test(username)) { throw badRequest('用户名只允许 3 到 24 位字母、数字、下划线'); } if (password.length < 6 || password.length > 128) { throw badRequest('密码长度需要在 6 到 128 位之间'); } } function isUniqueViolationError(error: unknown) { return ( typeof error === 'object' && error !== null && 'code' in error && (error as { code?: unknown }).code === '23505' ); } function buildMaskedPhoneDisplay(phoneNumber: string) { const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneNumber); return normalizedPhone.maskedNationalNumber; } function buildSystemUsername(prefix: string) { return `${prefix}_${crypto.randomBytes(10).toString('hex')}`; } function buildRandomPasswordSeed() { return crypto.randomBytes(24).toString('hex'); } function mapAccountStatusToBindingStatus( status: UserRecord['accountStatus'], ): AuthBindingStatus { return status === 'pending_bind_phone' ? 'pending_bind_phone' : 'active'; } function resolveDisplayName(user: { displayName?: string | null; username?: string | null; phoneNumber?: string | null; }) { return ( user.displayName?.trim() || (user.phoneNumber ? buildMaskedPhoneDisplay(user.phoneNumber) : '') || user.username?.trim() || '玩家' ); } function resolveDisplayNameAfterPhoneChange(user: UserRecord, nextPhoneNumber: string) { const nextMaskedPhone = buildMaskedPhoneDisplay(nextPhoneNumber); const currentMaskedPhone = user.phoneNumber ? buildMaskedPhoneDisplay(user.phoneNumber) : ''; if ( user.loginProvider === 'phone' || !user.displayName?.trim() || user.displayName.trim() === currentMaskedPhone ) { return nextMaskedPhone; } return user.displayName; } function resolveAvailableLoginMethods(context: AppContext): AuthLoginMethod[] { const methods: AuthLoginMethod[] = []; if (context.config.smsAuth.enabled) { methods.push('phone'); } if (context.config.wechatAuth.enabled) { methods.push('wechat'); } return methods; } async function toAuthUser( context: AppContext, user: UserRecord, ): Promise { const identities = await context.authIdentityRepository.listByUserId(user.id); const wechatBound = identities.some((identity) => identity.provider === 'wechat'); const displayName = resolveDisplayName(user); return { id: user.id, username: displayName, displayName, phoneNumberMasked: user.phoneNumber ? buildMaskedPhoneDisplay(user.phoneNumber) : null, loginMethod: user.loginProvider, bindingStatus: mapAccountStatusToBindingStatus(user.accountStatus), wechatBound, }; } export async function buildAuthMeResponse( context: AppContext, user: UserRecord | null, ): Promise { return { user: user ? await toAuthUser(context, user) : null, availableLoginMethods: resolveAvailableLoginMethods(context), }; } export function buildAuthLoginOptionsResponse( context: AppContext, ): AuthLoginOptionsResponse { return { availableLoginMethods: resolveAvailableLoginMethods(context), }; } async function signUserAuthPayload( context: AppContext, user: UserRecord, ) { const token = await signAccessToken( { userId: user.id, tokenVersion: user.tokenVersion, }, context.config, ); return { token, user: await toAuthUser(context, user), }; } function buildRefreshSessionExpiry(config: AppContext['config']) { const expiresAt = new Date(); expiresAt.setDate( expiresAt.getDate() + Math.max(1, config.authSession.refreshSessionTtlDays), ); return expiresAt.toISOString(); } function buildRelativeTimeIso(params: { hours?: number; days?: number; }) { const date = new Date(); if (params.hours) { date.setHours(date.getHours() - params.hours); } if (params.days) { date.setDate(date.getDate() - params.days); } return date.toISOString(); } function buildCaptchaScopeKey(params: { scene: 'login' | 'bind_phone' | 'change_phone'; phoneNumber: string; ip: string | null; }) { return `${params.scene}:${params.phoneNumber}:${params.ip ?? 'no-ip'}`; } function buildFutureTimeIso(params: { minutes: number; }) { const date = new Date(); date.setMinutes(date.getMinutes() + Math.max(1, params.minutes)); return date.toISOString(); } function maskIpAddress(ip: string | null) { if (!ip) { return null; } if (ip.includes(':')) { const parts = ip.split(':').filter(Boolean); if (parts.length <= 2) { return ip; } return `${parts.slice(0, 2).join(':')}::*`; } const parts = ip.split('.'); if (parts.length !== 4) { return ip; } return `${parts[0]}.${parts[1]}.*.*`; } function buildSessionClientLabel(session: { clientType: string; userAgent: string | null; }) { const userAgent = session.userAgent?.toLowerCase() || ''; if (userAgent.includes('mobile') || userAgent.includes('android') || userAgent.includes('iphone')) { return '移动端浏览器'; } if (session.clientType === 'browser') { return '网页端浏览器'; } return session.clientType || '未知设备'; } function buildAuditLogTitle(eventType: AuthAuditLogEventType) { switch (eventType) { case 'password_login': return '账号密码登录'; case 'phone_login': return '手机号登录'; case 'wechat_login': return '微信登录'; case 'wechat_bind_phone': return '绑定手机号'; case 'change_phone': return '更换手机号'; case 'captcha_required': return '需要图形验证码'; case 'logout': return '退出当前设备'; case 'logout_all': return '退出全部设备'; case 'revoke_session': return '移除登录设备'; case 'risk_block_phone': return '手机号临时保护'; case 'risk_block_ip': return '网络临时保护'; case 'risk_unblock_phone': return '解除手机号保护'; case 'risk_unblock_ip': return '解除网络保护'; default: return '账号操作'; } } async function writeAuthAuditLog( context: AppContext, input: { userId: string; eventType: AuthAuditLogEventType; detail: string; ip: string | null; userAgent: string | null; metaJson?: Record | null; }, ) { await context.authAuditLogRepository.create(input); } async function recordSmsAuthEvent( context: AppContext, input: { phoneNumber: string; scene: 'login' | 'bind_phone' | 'change_phone'; action: 'send_code' | 'verify_code'; success: boolean; requestContext: RefreshSessionRequestContext | null; }, ) { await context.smsAuthEventRepository.create({ phoneNumber: input.phoneNumber, scene: input.scene, action: input.action, success: input.success, ip: input.requestContext?.ip ?? null, userAgent: input.requestContext?.userAgent ?? null, }); } function buildCaptchaRequiredError( context: AppContext, params: { scene: 'login' | 'bind_phone' | 'change_phone'; phoneNumber: string; requestContext: RefreshSessionRequestContext | null; message?: string; }, ) { const challenge = context.captchaChallenges.create( buildCaptchaScopeKey({ scene: params.scene, phoneNumber: params.phoneNumber, ip: params.requestContext?.ip ?? null, }), context.config.smsAuth.captchaTtlSeconds, ); return captchaRequired( params.message ?? '当前操作需要完成人机校验', { captchaChallenge: challenge, }, ); } async function enforceSmsSendRateLimit( context: AppContext, phoneNumber: string, requestContext: RefreshSessionRequestContext | null, ) { const phoneSendCount = await context.smsAuthEventRepository.countSinceByPhone({ phoneNumber, action: 'send_code', since: buildRelativeTimeIso({ days: 1 }), }); if (phoneSendCount >= context.config.smsAuth.maxSendPerPhonePerDay) { throw tooManyRequests('该手机号今日验证码发送次数已达上限,请明天再试'); } const ipSendCount = await context.smsAuthEventRepository.countSinceByIp({ ip: requestContext?.ip ?? null, action: 'send_code', since: buildRelativeTimeIso({ hours: 1 }), }); if (ipSendCount >= context.config.smsAuth.maxSendPerIpPerHour) { throw tooManyRequests('当前网络请求验证码过于频繁,请稍后再试'); } } async function enforceSmsVerifyFailureLimit( context: AppContext, phoneNumber: string, requestContext: RefreshSessionRequestContext | null, ) { const phoneFailureCount = await context.smsAuthEventRepository.countSinceByPhone({ phoneNumber, action: 'verify_code', success: false, since: buildRelativeTimeIso({ hours: 1 }), }); if ( phoneFailureCount >= context.config.smsAuth.maxVerifyFailuresPerPhonePerHour ) { throw tooManyRequests('该手机号验证码尝试次数过多,请稍后再试'); } const ipFailureCount = await context.smsAuthEventRepository.countSinceByIp({ ip: requestContext?.ip ?? null, action: 'verify_code', success: false, since: buildRelativeTimeIso({ hours: 1 }), }); if ( ipFailureCount >= context.config.smsAuth.maxVerifyFailuresPerIpPerHour ) { throw tooManyRequests('当前网络验证码尝试次数过多,请稍后再试'); } } function buildRiskBlockMessage(scopeType: 'phone' | 'ip', expiresAt: string) { const expiresDate = new Date(expiresAt); const remainingMinutes = Math.max( 1, Math.ceil((expiresDate.getTime() - Date.now()) / 60000), ); if (scopeType === 'phone') { return `该手机号因异常尝试已被临时保护,请约 ${remainingMinutes} 分钟后再试`; } return `当前网络因异常尝试已被临时保护,请约 ${remainingMinutes} 分钟后再试`; } function toAuthRiskBlockSummary(block: { scopeType: 'phone' | 'ip'; expiresAt: string; }): AuthRiskBlockSummary { const remainingSeconds = Math.max( 0, Math.floor((new Date(block.expiresAt).getTime() - Date.now()) / 1000), ); return { scopeType: block.scopeType, title: block.scopeType === 'phone' ? '手机号保护中' : '当前网络保护中', detail: buildRiskBlockMessage(block.scopeType, block.expiresAt), expiresAt: block.expiresAt, remainingSeconds, }; } async function enforceNoActiveRiskBlocks( context: AppContext, params: { phoneNumber: string; requestContext: RefreshSessionRequestContext | null; }, ) { const phoneBlock = await context.authRiskBlockRepository.findActive( 'phone', params.phoneNumber, ); if (phoneBlock) { throw tooManyRequests( buildRiskBlockMessage('phone', phoneBlock.expiresAt), { blockExpiresAt: phoneBlock.expiresAt, scopeType: 'phone', }, ); } const ip = params.requestContext?.ip ?? null; if (!ip) { return; } const ipBlock = await context.authRiskBlockRepository.findActive('ip', ip); if (ipBlock) { throw tooManyRequests( buildRiskBlockMessage('ip', ipBlock.expiresAt), { blockExpiresAt: ipBlock.expiresAt, scopeType: 'ip', }, ); } } async function applyRiskBlocksIfNeeded( context: AppContext, params: { phoneNumber: string; requestContext: RefreshSessionRequestContext | null; }, ) { const phoneFailureCount = await context.smsAuthEventRepository.countSinceByPhone({ phoneNumber: params.phoneNumber, action: 'verify_code', success: false, since: buildRelativeTimeIso({ hours: 1 }), }); if ( phoneFailureCount >= context.config.smsAuth.blockPhoneFailureThreshold ) { const expiresAt = buildFutureTimeIso({ minutes: context.config.smsAuth.blockPhoneDurationMinutes, }); const block = await context.authRiskBlockRepository.createOrRefresh({ scopeType: 'phone', scopeKey: params.phoneNumber, reason: 'sms_verify_failures', expiresAt, }); const existingUser = await context.userRepository.findByPhoneNumber( params.phoneNumber, ); if (existingUser) { await writeAuthAuditLog(context, { userId: existingUser.id, eventType: 'risk_block_phone', detail: `手机号 ${buildMaskedPhoneDisplay(params.phoneNumber)} 已被临时保护`, ip: params.requestContext?.ip ?? null, userAgent: params.requestContext?.userAgent ?? null, metaJson: { scopeKey: params.phoneNumber, expiresAt: block?.expiresAt ?? expiresAt, }, }); } } const ip = params.requestContext?.ip ?? null; if (!ip) { return; } const ipFailureCount = await context.smsAuthEventRepository.countSinceByIp({ ip, action: 'verify_code', success: false, since: buildRelativeTimeIso({ hours: 1 }), }); if (ipFailureCount >= context.config.smsAuth.blockIpFailureThreshold) { const expiresAt = buildFutureTimeIso({ minutes: context.config.smsAuth.blockIpDurationMinutes, }); await context.authRiskBlockRepository.createOrRefresh({ scopeType: 'ip', scopeKey: ip, reason: 'sms_verify_failures', expiresAt, }); } } async function enforceCaptchaRequirement( context: AppContext, params: { scene: 'login' | 'bind_phone' | 'change_phone'; phoneNumber: string; requestContext: RefreshSessionRequestContext | null; captchaChallengeId?: string | null; captchaAnswer?: string | null; }, ) { const phoneFailureCount = await context.smsAuthEventRepository.countSinceByPhone({ phoneNumber: params.phoneNumber, action: 'verify_code', success: false, since: buildRelativeTimeIso({ hours: 1 }), }); const ipFailureCount = await context.smsAuthEventRepository.countSinceByIp({ ip: params.requestContext?.ip ?? null, action: 'verify_code', success: false, since: buildRelativeTimeIso({ hours: 1 }), }); const requiresCaptcha = phoneFailureCount >= context.config.smsAuth.captchaTriggerVerifyFailuresPerPhone || ipFailureCount >= context.config.smsAuth.captchaTriggerVerifyFailuresPerIp; if (!requiresCaptcha) { return; } const challengeId = params.captchaChallengeId?.trim() || ''; const captchaAnswer = params.captchaAnswer?.trim() || ''; if (!challengeId || !captchaAnswer) { const existingUser = await context.userRepository.findByPhoneNumber( params.phoneNumber, ); if (existingUser) { await writeAuthAuditLog(context, { userId: existingUser.id, eventType: 'captcha_required', detail: `手机号 ${buildMaskedPhoneDisplay(params.phoneNumber)} 需要图形验证码`, ip: params.requestContext?.ip ?? null, userAgent: params.requestContext?.userAgent ?? null, }); } throw buildCaptchaRequiredError(context, params); } const isValid = context.captchaChallenges.verify({ challengeId, scopeKey: buildCaptchaScopeKey({ scene: params.scene, phoneNumber: params.phoneNumber, ip: params.requestContext?.ip ?? null, }), answer: captchaAnswer, }); if (!isValid) { const existingUser = await context.userRepository.findByPhoneNumber( params.phoneNumber, ); if (existingUser) { await writeAuthAuditLog(context, { userId: existingUser.id, eventType: 'captcha_required', detail: `手机号 ${buildMaskedPhoneDisplay(params.phoneNumber)} 图形验证码错误`, ip: params.requestContext?.ip ?? null, userAgent: params.requestContext?.userAgent ?? null, }); } throw buildCaptchaRequiredError(context, { ...params, message: '图形验证码错误或已过期,请重新输入', }); } } function toAuthAuditLogEntry(log: { id: string; eventType: AuthAuditLogEventType; detail: string; ip: string | null; userAgent: string | null; createdAt: string; }): AuthAuditLogEntry { return { id: log.id, eventType: log.eventType, title: buildAuditLogTitle(log.eventType), detail: log.detail, ipMasked: maskIpAddress(log.ip), userAgent: log.userAgent, createdAt: log.createdAt, }; } export async function createRefreshSession( context: AppContext, user: UserRecord, sessionContext: RefreshSessionRequestContext, ) { const refreshToken = createRefreshSessionToken(); const refreshTokenHash = hashRefreshSessionToken(refreshToken); const expiresAt = buildRefreshSessionExpiry(context.config); await context.userSessionRepository.create({ userId: user.id, refreshTokenHash, clientType: sessionContext.clientType, userAgent: sessionContext.userAgent, ip: sessionContext.ip, expiresAt, }); return { refreshToken, expiresAt, }; } export async function listAuthAuditLogs( context: AppContext, userId: string, ): Promise { const logs = await context.authAuditLogRepository.listRecentByUserId(userId, 20); return { logs: logs.map((log) => toAuthAuditLogEntry(log)), }; } export async function listActiveRiskBlocks( context: AppContext, user: UserRecord, requestContext: RefreshSessionRequestContext | null, ): Promise { const blocks: AuthRiskBlockSummary[] = []; if (user.phoneNumber) { const phoneBlock = await context.authRiskBlockRepository.findActive( 'phone', user.phoneNumber, ); if (phoneBlock) { blocks.push(toAuthRiskBlockSummary(phoneBlock)); } } const ip = requestContext?.ip ?? null; if (ip) { const ipBlock = await context.authRiskBlockRepository.findActive('ip', ip); if (ipBlock) { blocks.push(toAuthRiskBlockSummary(ipBlock)); } } return { blocks, }; } export async function liftRiskBlock( context: AppContext, user: UserRecord, requestContext: RefreshSessionRequestContext | null, scopeType: 'phone' | 'ip', ): Promise { if (scopeType === 'phone') { if (!user.phoneNumber) { throw badRequest('当前账号没有可解除的手机号保护'); } const liftedBlocks = await context.authRiskBlockRepository.liftActive( 'phone', user.phoneNumber, ); if (liftedBlocks.length === 0) { throw badRequest('当前没有生效中的手机号保护'); } await writeAuthAuditLog(context, { userId: user.id, eventType: 'risk_unblock_phone', detail: `已手动解除手机号 ${buildMaskedPhoneDisplay(user.phoneNumber)} 的保护`, ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, }); return { ok: true, }; } const ip = requestContext?.ip ?? null; if (!ip) { throw badRequest('当前网络没有可解除的保护'); } const liftedBlocks = await context.authRiskBlockRepository.liftActive('ip', ip); if (liftedBlocks.length === 0) { throw badRequest('当前没有生效中的网络保护'); } await writeAuthAuditLog(context, { userId: user.id, eventType: 'risk_unblock_ip', detail: '已手动解除当前网络保护', ip, userAgent: requestContext?.userAgent ?? null, }); return { ok: true, }; } export async function refreshAuthSession( context: AppContext, rawRefreshToken: string, ): Promise< AuthRefreshResponse & { refreshToken: string; refreshExpiresAt: string; } > { const refreshToken = rawRefreshToken.trim(); if (!refreshToken) { throw unauthorized('缺少刷新会话'); } const refreshTokenHash = hashRefreshSessionToken(refreshToken); const session = await context.userSessionRepository.findActiveByRefreshTokenHash( refreshTokenHash, ); if (!session || session.revokedAt) { throw unauthorized('刷新会话已失效,请重新登录'); } if (new Date(session.expiresAt).getTime() <= Date.now()) { throw unauthorized('刷新会话已过期,请重新登录'); } const user = await context.userRepository.findById(session.userId); if (!user) { throw unauthorized('用户不存在'); } if (user.accountStatus === 'disabled') { throw unauthorized('账号已被禁用'); } const nextRefreshToken = createRefreshSessionToken(); const nextRefreshTokenHash = hashRefreshSessionToken(nextRefreshToken); const nextExpiresAt = buildRefreshSessionExpiry(context.config); const lastSeenAt = new Date().toISOString(); await context.userSessionRepository.rotate(session.id, { refreshTokenHash: nextRefreshTokenHash, expiresAt: nextExpiresAt, lastSeenAt, }); const accessPayload = await signUserAuthPayload(context, user); return { token: accessPayload.token, refreshToken: nextRefreshToken, refreshExpiresAt: nextExpiresAt, }; } export async function listUserSessions( context: AppContext, userId: string, rawRefreshToken: string, ): Promise { const sessions = await context.userSessionRepository.listActiveByUserId(userId); const currentRefreshTokenHash = rawRefreshToken.trim() ? hashRefreshSessionToken(rawRefreshToken.trim()) : ''; return { sessions: sessions.map( (session) => ({ sessionId: session.id, clientType: session.clientType, clientLabel: buildSessionClientLabel(session), userAgent: session.userAgent, ipMasked: maskIpAddress(session.ip), isCurrent: Boolean(currentRefreshTokenHash) && session.refreshTokenHash === currentRefreshTokenHash, createdAt: session.createdAt, lastSeenAt: session.lastSeenAt, expiresAt: session.expiresAt, }) satisfies AuthSessionSummary, ), }; } export async function revokeRefreshSession( context: AppContext, rawRefreshToken: string, ) { const refreshToken = rawRefreshToken.trim(); if (!refreshToken) { return; } const refreshTokenHash = hashRefreshSessionToken(refreshToken); const session = await context.userSessionRepository.findActiveByRefreshTokenHash( refreshTokenHash, ); if (!session || session.revokedAt) { return; } await context.userSessionRepository.revoke(session.id); } export async function revokeUserSession( context: AppContext, userId: string, sessionId: string, rawRefreshToken: string, requestContext: RefreshSessionRequestContext | null = null, ): Promise { const targetSession = await context.userSessionRepository.findById(sessionId); if (!targetSession || targetSession.userId !== userId || targetSession.revokedAt) { throw badRequest('目标登录设备不存在或已失效'); } const currentRefreshTokenHash = rawRefreshToken.trim() ? hashRefreshSessionToken(rawRefreshToken.trim()) : ''; if ( currentRefreshTokenHash && targetSession.refreshTokenHash === currentRefreshTokenHash ) { throw badRequest('当前设备请直接使用退出登录'); } const revokedSession = await context.userSessionRepository.revokeByUserIdAndSessionId( userId, sessionId, ); if (!revokedSession) { throw badRequest('目标登录设备不存在或已失效'); } await writeAuthAuditLog(context, { userId, eventType: 'revoke_session', detail: `已移除设备:${buildSessionClientLabel(revokedSession)}`, ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, metaJson: { sessionId: revokedSession.id, targetIp: revokedSession.ip, targetUserAgent: revokedSession.userAgent, }, }); return { ok: true, }; } export async function logoutAllUserSessions( context: AppContext, userId: string, requestContext: RefreshSessionRequestContext | null = null, ): Promise { const user = await context.userRepository.incrementTokenVersion(userId); if (!user) { throw unauthorized('用户不存在'); } await context.userSessionRepository.revokeAllByUserId(userId); await writeAuthAuditLog(context, { userId, eventType: 'logout_all', detail: '已退出全部设备登录', ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, }); return { ok: true, }; } export async function entryWithPassword( context: AppContext, usernameInput: string, password: string, requestContext: RefreshSessionRequestContext | null = null, ): Promise { const username = normalizeUsername(usernameInput); validateCredentials(username, password); let user = await context.userRepository.findByUsername(username); let shouldVerifyExistingPassword = Boolean(user); if (!user) { const passwordHash = await hashPassword(password); try { user = await context.userRepository.create(username, passwordHash); shouldVerifyExistingPassword = false; } catch (error) { if (!isUniqueViolationError(error)) { throw error; } user = await context.userRepository.findByUsername(username); shouldVerifyExistingPassword = true; if (!user) { throw error; } } } if (!user) { throw new Error('failed to resolve user after auth entry'); } if (shouldVerifyExistingPassword) { const isValid = await verifyPassword(user.passwordHash, password); if (!isValid) { throw unauthorized('用户名或密码错误'); } } await writeAuthAuditLog(context, { userId: user.id, eventType: 'password_login', detail: '使用账号密码完成登录', ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, }); return signUserAuthPayload(context, user); } export async function sendPhoneLoginCode( context: AppContext, phoneInput: string, scene: 'login' | 'bind_phone' | 'change_phone' = 'login', requestContext: RefreshSessionRequestContext | null = null, captchaParams: { captchaChallengeId?: string | null; captchaAnswer?: string | null; } = {}, ): Promise { const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); await enforceNoActiveRiskBlocks(context, { phoneNumber: normalizedPhone.e164, requestContext, }); await enforceCaptchaRequirement(context, { scene, phoneNumber: normalizedPhone.e164, requestContext, captchaChallengeId: captchaParams.captchaChallengeId, captchaAnswer: captchaParams.captchaAnswer, }); await enforceSmsSendRateLimit( context, normalizedPhone.e164, requestContext, ); const result = await context.smsVerificationService.sendLoginCode( normalizedPhone, ); await recordSmsAuthEvent(context, { phoneNumber: normalizedPhone.e164, scene, action: 'send_code', success: true, requestContext, }); return { ok: true, cooldownSeconds: result.cooldownSeconds, expiresInSeconds: result.expiresInSeconds, providerRequestId: result.providerRequestId, }; } export async function entryWithPhoneCode( context: AppContext, phoneInput: string, verifyCodeInput: string, requestContext: RefreshSessionRequestContext | null = null, ): Promise { const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); const verifyCode = validateSmsVerifyCode(verifyCodeInput); await enforceNoActiveRiskBlocks(context, { phoneNumber: normalizedPhone.e164, requestContext, }); await enforceSmsVerifyFailureLimit( context, normalizedPhone.e164, requestContext, ); try { await context.smsVerificationService.verifyLoginCode( normalizedPhone, verifyCode, ); await recordSmsAuthEvent(context, { phoneNumber: normalizedPhone.e164, scene: 'login', action: 'verify_code', success: true, requestContext, }); } catch (error) { await recordSmsAuthEvent(context, { phoneNumber: normalizedPhone.e164, scene: 'login', action: 'verify_code', success: false, requestContext, }); await applyRiskBlocksIfNeeded(context, { phoneNumber: normalizedPhone.e164, requestContext, }); throw error; } let user = await context.userRepository.findByPhoneNumber( normalizedPhone.e164, ); if (!user) { const passwordHash = await hashPassword(buildRandomPasswordSeed()); user = await context.userRepository.createPhoneUser({ username: buildSystemUsername('phone'), passwordHash, displayName: normalizedPhone.maskedNationalNumber, phoneNumber: normalizedPhone.e164, phoneVerifiedAt: new Date().toISOString(), }); } if (!user) { throw new Error('failed to resolve user after phone auth entry'); } await writeAuthAuditLog(context, { userId: user.id, eventType: 'phone_login', detail: `使用手机号 ${normalizedPhone.maskedNationalNumber} 完成登录`, ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, }); return signUserAuthPayload(context, user); } export async function startWechatLogin( context: AppContext, callbackUrl: string, redirectPath: string, requestContext: RefreshSessionRequestContext | null = null, ): Promise { const stateRecord = context.wechatAuthStates.create(redirectPath); return { authorizationUrl: context.wechatAuthService.buildAuthorizationUrl({ callbackUrl, state: stateRecord.state, userAgent: requestContext?.userAgent ?? null, }), }; } export async function resolveWechatCallback( context: AppContext, params: { code?: string | null; mockCode?: string | null; }, requestContext: RefreshSessionRequestContext | null = null, ) { const profile = await context.wechatAuthService.resolveCallbackProfile(params); let identity = await context.authIdentityRepository.findWechatIdentityByProfile( { providerUid: profile.providerUid, providerUnionId: profile.providerUnionId, }, ); let user = identity ? await context.userRepository.findById(identity.userId) : null; if (!user) { const passwordHash = await hashPassword(buildRandomPasswordSeed()); user = await context.userRepository.createWechatPendingUser({ username: buildSystemUsername('wechat'), passwordHash, displayName: profile.displayName?.trim() || '微信旅人', }); if (!user) { throw new Error('failed to create pending wechat user'); } identity = await context.authIdentityRepository.createWechatIdentity({ userId: user.id, providerUid: profile.providerUid, providerUnionId: profile.providerUnionId, displayName: profile.displayName, avatarUrl: profile.avatarUrl, metaJson: profile.metaJson, }); } if (!identity || !user) { throw new Error('failed to resolve wechat auth identity'); } await writeAuthAuditLog(context, { userId: user.id, eventType: 'wechat_login', detail: '使用微信身份完成登录', ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, }); return signUserAuthPayload(context, user); } export async function bindWechatPhone( context: AppContext, userId: string, phoneInput: string, verifyCodeInput: string, requestContext: RefreshSessionRequestContext | null = null, ): Promise { const currentUser = await context.userRepository.findById(userId); if (!currentUser) { throw unauthorized('用户不存在'); } if (currentUser.accountStatus !== 'pending_bind_phone') { throw badRequest('当前账号无需绑定手机号'); } const identities = await context.authIdentityRepository.listByUserId(userId); const hasWechatIdentity = identities.some((identity) => identity.provider === 'wechat'); if (!hasWechatIdentity) { throw badRequest('当前账号缺少微信身份,无法绑定手机号'); } const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); const verifyCode = validateSmsVerifyCode(verifyCodeInput); await enforceNoActiveRiskBlocks(context, { phoneNumber: normalizedPhone.e164, requestContext, }); await enforceSmsVerifyFailureLimit( context, normalizedPhone.e164, requestContext, ); try { await context.smsVerificationService.verifyLoginCode( normalizedPhone, verifyCode, ); await recordSmsAuthEvent(context, { phoneNumber: normalizedPhone.e164, scene: 'bind_phone', action: 'verify_code', success: true, requestContext, }); } catch (error) { await recordSmsAuthEvent(context, { phoneNumber: normalizedPhone.e164, scene: 'bind_phone', action: 'verify_code', success: false, requestContext, }); await applyRiskBlocksIfNeeded(context, { phoneNumber: normalizedPhone.e164, requestContext, }); throw error; } const existingPhoneUser = await context.userRepository.findByPhoneNumber( normalizedPhone.e164, ); if (existingPhoneUser && existingPhoneUser.id !== currentUser.id) { await context.db.query('BEGIN'); try { await context.authIdentityRepository.moveWechatIdentitiesToUser( currentUser.id, existingPhoneUser.id, ); await context.userRepository.deleteUser(currentUser.id); await context.db.query('COMMIT'); } catch (error) { await context.db.query('ROLLBACK'); throw error; } const mergedUser = await context.userRepository.findById(existingPhoneUser.id); if (!mergedUser) { throw new Error('failed to resolve merged phone user'); } await writeAuthAuditLog(context, { userId: mergedUser.id, eventType: 'wechat_bind_phone', detail: `已将微信身份绑定到手机号 ${normalizedPhone.maskedNationalNumber}`, ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, }); return signUserAuthPayload(context, mergedUser); } const activatedUser = await context.userRepository.activatePendingWechatUser( currentUser.id, { displayName: currentUser.displayName?.trim() || normalizedPhone.maskedNationalNumber, phoneNumber: normalizedPhone.e164, phoneVerifiedAt: new Date().toISOString(), }, ); if (!activatedUser) { throw new Error('failed to activate pending wechat user'); } await writeAuthAuditLog(context, { userId: activatedUser.id, eventType: 'wechat_bind_phone', detail: `已绑定手机号 ${normalizedPhone.maskedNationalNumber}`, ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, }); return signUserAuthPayload(context, activatedUser); } export async function changeUserPhone( context: AppContext, userId: string, phoneInput: string, verifyCodeInput: string, requestContext: RefreshSessionRequestContext | null = null, ): Promise { const currentUser = await context.userRepository.findById(userId); if (!currentUser) { throw unauthorized('用户不存在'); } if (currentUser.accountStatus !== 'active') { throw badRequest('当前账号状态不允许更换手机号'); } const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); const verifyCode = validateSmsVerifyCode(verifyCodeInput); if (currentUser.phoneNumber === normalizedPhone.e164) { throw badRequest('新手机号不能与当前手机号相同'); } const existingPhoneUser = await context.userRepository.findByPhoneNumber( normalizedPhone.e164, ); if (existingPhoneUser && existingPhoneUser.id !== currentUser.id) { throw conflict('该手机号已绑定其他账号'); } await enforceNoActiveRiskBlocks(context, { phoneNumber: normalizedPhone.e164, requestContext, }); await enforceSmsVerifyFailureLimit( context, normalizedPhone.e164, requestContext, ); try { await context.smsVerificationService.verifyLoginCode( normalizedPhone, verifyCode, ); await recordSmsAuthEvent(context, { phoneNumber: normalizedPhone.e164, scene: 'change_phone', action: 'verify_code', success: true, requestContext, }); } catch (error) { await recordSmsAuthEvent(context, { phoneNumber: normalizedPhone.e164, scene: 'change_phone', action: 'verify_code', success: false, requestContext, }); await applyRiskBlocksIfNeeded(context, { phoneNumber: normalizedPhone.e164, requestContext, }); throw error; } const updatedUser = await context.userRepository.updatePhoneInfo(userId, { phoneNumber: normalizedPhone.e164, phoneVerifiedAt: new Date().toISOString(), displayName: resolveDisplayNameAfterPhoneChange( currentUser, normalizedPhone.e164, ), }); if (!updatedUser) { throw new Error('failed to update user phone'); } await writeAuthAuditLog(context, { userId: updatedUser.id, eventType: 'change_phone', detail: `已更换手机号为 ${normalizedPhone.maskedNationalNumber}`, ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, }); return { user: await toAuthUser(context, updatedUser), }; } export async function logoutUser( context: AppContext, userId: string, requestContext: RefreshSessionRequestContext | null = null, ): Promise { const user = await context.userRepository.incrementTokenVersion(userId); if (!user) { throw unauthorized('用户不存在'); } await writeAuthAuditLog(context, { userId, eventType: 'logout', detail: '已退出当前设备登录', ip: requestContext?.ip ?? null, userAgent: requestContext?.userAgent ?? null, }); return { ok: true as const, }; }