1415 lines
38 KiB
TypeScript
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,
|
|
};
|
|
}
|