Files
Genarrative/server-node/src/auth/authService.ts

1415 lines
38 KiB
TypeScript

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<AuthUser> {
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<AuthMeResponse> {
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<string, unknown> | 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<AuthAuditLogsResponse> {
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<AuthRiskBlocksResponse> {
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<AuthLiftRiskBlockResponse> {
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<AuthSessionsResponse> {
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<AuthRevokeSessionResponse> {
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<AuthLogoutAllResponse> {
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<AuthEntryResponse> {
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<AuthPhoneSendCodeResponse> {
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<AuthPhoneLoginResponse> {
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<AuthWechatStartResponse> {
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<AuthWechatBindPhoneResponse> {
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<AuthPhoneChangeResponse> {
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<LogoutResponse> {
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,
};
}