feat(identity): 完善账户安全和恢复功能
1. 账户冻结/解冻功能: - POST /user/freeze: 用户主动冻结账户 - POST /user/unfreeze: 验证身份后解冻账户(支持助记词或手机号验证) - 添加 AccountUnfrozenEvent 审计事件 2. 密钥轮换功能: - POST /user/key-rotation/request: 验证助记词后请求 MPC 密钥轮换 - 添加 KeyRotationRequestedEvent 事件触发后台轮换 3. 恢复码备份功能: - POST /user/backup-codes/generate: 生成8个一次性恢复码 - POST /user/recover-by-backup-code: 使用恢复码恢复账户 - 恢复码存储在 Redis,有效期1年 - 每个恢复码只能使用一次 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f2a6c09d86
commit
2730bcb354
|
|
@ -18,6 +18,8 @@ import {
|
|||
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
|
||||
SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto,
|
||||
BindWalletDto, SubmitKYCDto, RemoveDeviceDto, RevokeMnemonicDto,
|
||||
FreezeAccountDto, UnfreezeAccountDto, RequestKeyRotationDto,
|
||||
GenerateBackupCodesDto, RecoverByBackupCodeDto,
|
||||
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto,
|
||||
UserProfileResponseDto, DeviceResponseDto,
|
||||
WalletStatusReadyResponseDto, WalletStatusGeneratingResponseDto,
|
||||
|
|
@ -206,6 +208,64 @@ export class UserAccountController {
|
|||
return this.userService.revokeMnemonic(user.userId, dto.reason);
|
||||
}
|
||||
|
||||
@Post('freeze')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '冻结账户', description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作' })
|
||||
@ApiResponse({ status: 200, description: '冻结结果' })
|
||||
async freezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: FreezeAccountDto) {
|
||||
return this.userService.freezeAccount(user.userId, dto.reason);
|
||||
}
|
||||
|
||||
@Post('unfreeze')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '解冻账户', description: '验证身份后解冻账户,支持助记词或手机号验证' })
|
||||
@ApiResponse({ status: 200, description: '解冻结果' })
|
||||
async unfreezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: UnfreezeAccountDto) {
|
||||
return this.userService.unfreezeAccount({
|
||||
userId: user.userId,
|
||||
verifyMethod: dto.verifyMethod,
|
||||
mnemonic: dto.mnemonic,
|
||||
phoneNumber: dto.phoneNumber,
|
||||
smsCode: dto.smsCode,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('key-rotation/request')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '请求密钥轮换', description: '验证当前助记词后,请求轮换 MPC 密钥对' })
|
||||
@ApiResponse({ status: 200, description: '轮换请求结果' })
|
||||
async requestKeyRotation(@CurrentUser() user: CurrentUserData, @Body() dto: RequestKeyRotationDto) {
|
||||
return this.userService.requestKeyRotation({
|
||||
userId: user.userId,
|
||||
currentMnemonic: dto.currentMnemonic,
|
||||
reason: dto.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('backup-codes/generate')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '生成恢复码', description: '验证助记词后生成一组一次性恢复码' })
|
||||
@ApiResponse({ status: 200, description: '恢复码列表' })
|
||||
async generateBackupCodes(@CurrentUser() user: CurrentUserData, @Body() dto: GenerateBackupCodesDto) {
|
||||
return this.userService.generateBackupCodes({
|
||||
userId: user.userId,
|
||||
mnemonic: dto.mnemonic,
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('recover-by-backup-code')
|
||||
@ApiOperation({ summary: '使用恢复码恢复账户' })
|
||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||
async recoverByBackupCode(@Body() dto: RecoverByBackupCodeDto) {
|
||||
return this.userService.recoverByBackupCode({
|
||||
accountSequence: dto.accountSequence,
|
||||
backupCode: dto.backupCode,
|
||||
newDeviceId: dto.newDeviceId,
|
||||
deviceName: dto.deviceName,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('upload-avatar')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '上传用户头像' })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class FreezeAccountDto {
|
||||
@ApiProperty({ example: '账户被盗', description: '冻结原因' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请填写冻结原因' })
|
||||
@MaxLength(200, { message: '冻结原因不能超过200字' })
|
||||
reason: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class GenerateBackupCodesDto {
|
||||
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请提供当前助记词' })
|
||||
mnemonic: string;
|
||||
}
|
||||
|
|
@ -4,3 +4,8 @@ export * from './recover-by-phone.dto';
|
|||
export * from './bind-phone.dto';
|
||||
export * from './submit-kyc.dto';
|
||||
export * from './revoke-mnemonic.dto';
|
||||
export * from './freeze-account.dto';
|
||||
export * from './unfreeze-account.dto';
|
||||
export * from './request-key-rotation.dto';
|
||||
export * from './generate-backup-codes.dto';
|
||||
export * from './recover-by-backup-code.dto';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { IsString, IsNotEmpty, Matches, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RecoverByBackupCodeDto {
|
||||
@ApiProperty({ example: 'D24121200001', description: '账户序列号' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请提供账户序列号' })
|
||||
@Matches(/^D\d{11}$/, { message: '账户序列号格式不正确' })
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty({ example: 'ABCD-1234-EFGH', description: '恢复码' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请提供恢复码' })
|
||||
@Matches(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, { message: '恢复码格式不正确' })
|
||||
backupCode: string;
|
||||
|
||||
@ApiProperty({ example: 'device-uuid-123', description: '新设备ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
newDeviceId: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'iPhone 15 Pro', description: '新设备名称' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
deviceName?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RequestKeyRotationDto {
|
||||
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请提供当前助记词' })
|
||||
currentMnemonic: string;
|
||||
|
||||
@ApiProperty({ example: '安全起见主动轮换', description: '轮换原因' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请填写轮换原因' })
|
||||
@MaxLength(200, { message: '轮换原因不能超过200字' })
|
||||
reason: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UnfreezeAccountDto {
|
||||
@ApiProperty({ example: '确认账户安全', description: '解冻验证方式: mnemonic 或 phone' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
verifyMethod: 'mnemonic' | 'phone';
|
||||
|
||||
@ApiPropertyOptional({ example: 'abandon abandon ...', description: '助记词 (verifyMethod=mnemonic时必填)' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
mnemonic?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+8613800138000', description: '手机号 (verifyMethod=phone时必填)' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '123456', description: '短信验证码 (verifyMethod=phone时必填)' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
smsCode?: string;
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ import { BlockchainWalletHandler } from '../event-handlers/blockchain-wallet.han
|
|||
import { MpcKeygenCompletedHandler } from '../event-handlers/mpc-keygen-completed.handler';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { generateIdentity } from '@/shared/utils';
|
||||
import { MpcKeygenRequestedEvent, AccountRecoveredEvent, AccountRecoveryFailedEvent, MnemonicRevokedEvent } from '@/domain/events';
|
||||
import { MpcKeygenRequestedEvent, AccountRecoveredEvent, AccountRecoveryFailedEvent, MnemonicRevokedEvent, AccountUnfrozenEvent, KeyRotationRequestedEvent } from '@/domain/events';
|
||||
import {
|
||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
||||
|
|
@ -914,4 +914,367 @@ export class UserApplicationService {
|
|||
return { success: false, message: '挂失失败,请稍后重试' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 账户冻结/解冻相关 ============
|
||||
|
||||
/**
|
||||
* 冻结账户 (POST /user/freeze)
|
||||
*
|
||||
* 用户主动冻结自己的账户,防止被盗用
|
||||
* 冻结后账户将无法进行任何操作
|
||||
*/
|
||||
async freezeAccount(userId: string, reason: string): Promise<{ success: boolean; message: string }> {
|
||||
this.logger.log(`[FREEZE] Freezing account for user: ${userId}, reason: ${reason}`);
|
||||
|
||||
const account = await this.userRepository.findById(UserId.create(userId));
|
||||
if (!account) {
|
||||
this.logger.warn(`[FREEZE] User not found: ${userId}`);
|
||||
return { success: false, message: '用户不存在' };
|
||||
}
|
||||
|
||||
try {
|
||||
account.freeze(reason);
|
||||
await this.userRepository.save(account);
|
||||
|
||||
// 发布领域事件 (包含 UserAccountFrozenEvent)
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
this.logger.log(`[FREEZE] Account frozen successfully for user: ${userId}`);
|
||||
return { success: true, message: '账户已冻结' };
|
||||
} catch (error) {
|
||||
this.logger.error(`[FREEZE] Failed to freeze account`, error);
|
||||
if (error instanceof Error && error.message === '账户已冻结') {
|
||||
return { success: false, message: '账户已处于冻结状态' };
|
||||
}
|
||||
return { success: false, message: '冻结失败,请稍后重试' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解冻账户 (POST /user/unfreeze)
|
||||
*
|
||||
* 需要验证身份后才能解冻
|
||||
* 支持助记词或手机号验证
|
||||
*/
|
||||
async unfreezeAccount(params: {
|
||||
userId: string;
|
||||
verifyMethod: 'mnemonic' | 'phone';
|
||||
mnemonic?: string;
|
||||
phoneNumber?: string;
|
||||
smsCode?: string;
|
||||
}): Promise<{ success: boolean; message: string }> {
|
||||
const { userId, verifyMethod, mnemonic, phoneNumber, smsCode } = params;
|
||||
this.logger.log(`[UNFREEZE] Unfreezing account for user: ${userId}, method: ${verifyMethod}`);
|
||||
|
||||
const account = await this.userRepository.findById(UserId.create(userId));
|
||||
if (!account) {
|
||||
this.logger.warn(`[UNFREEZE] User not found: ${userId}`);
|
||||
return { success: false, message: '用户不存在' };
|
||||
}
|
||||
|
||||
// 验证身份
|
||||
if (verifyMethod === 'mnemonic') {
|
||||
if (!mnemonic) {
|
||||
return { success: false, message: '请提供助记词' };
|
||||
}
|
||||
|
||||
// 调用 blockchain-service 验证助记词
|
||||
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
|
||||
accountSequence: account.accountSequence.value,
|
||||
mnemonic,
|
||||
});
|
||||
|
||||
if (!verifyResult.valid) {
|
||||
this.logger.warn(`[UNFREEZE] Mnemonic verification failed for user: ${userId}`);
|
||||
return { success: false, message: verifyResult.message || '助记词验证失败' };
|
||||
}
|
||||
} else if (verifyMethod === 'phone') {
|
||||
if (!phoneNumber || !smsCode) {
|
||||
return { success: false, message: '请提供手机号和验证码' };
|
||||
}
|
||||
|
||||
if (!account.phoneNumber) {
|
||||
return { success: false, message: '账户未绑定手机号,请使用助记词验证' };
|
||||
}
|
||||
|
||||
const phone = PhoneNumber.create(phoneNumber);
|
||||
if (!account.phoneNumber.equals(phone)) {
|
||||
return { success: false, message: '手机号与账户不匹配' };
|
||||
}
|
||||
|
||||
const cachedCode = await this.redisService.get(`sms:unfreeze:${phone.value}`);
|
||||
if (cachedCode !== smsCode) {
|
||||
return { success: false, message: '验证码错误或已过期' };
|
||||
}
|
||||
|
||||
// 清除验证码
|
||||
await this.redisService.delete(`sms:unfreeze:${phone.value}`);
|
||||
} else {
|
||||
return { success: false, message: '不支持的验证方式' };
|
||||
}
|
||||
|
||||
try {
|
||||
account.unfreeze();
|
||||
await this.userRepository.save(account);
|
||||
|
||||
// 发布解冻事件
|
||||
await this.eventPublisher.publish(new AccountUnfrozenEvent({
|
||||
userId,
|
||||
accountSequence: account.accountSequence.value,
|
||||
verifyMethod,
|
||||
unfrozenAt: new Date(),
|
||||
}));
|
||||
|
||||
this.logger.log(`[UNFREEZE] Account unfrozen successfully for user: ${userId}`);
|
||||
return { success: true, message: '账户已解冻' };
|
||||
} catch (error) {
|
||||
this.logger.error(`[UNFREEZE] Failed to unfreeze account`, error);
|
||||
if (error instanceof Error && error.message === '账户未冻结') {
|
||||
return { success: false, message: '账户未处于冻结状态' };
|
||||
}
|
||||
return { success: false, message: '解冻失败,请稍后重试' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 密钥轮换相关 ============
|
||||
|
||||
/**
|
||||
* 请求密钥轮换 (POST /user/key-rotation/request)
|
||||
*
|
||||
* 用户主动请求轮换 MPC 密钥对
|
||||
* 1. 验证当前助记词
|
||||
* 2. 发布密钥轮换请求事件
|
||||
* 3. MPC 系统后台执行轮换
|
||||
*/
|
||||
async requestKeyRotation(params: {
|
||||
userId: string;
|
||||
currentMnemonic: string;
|
||||
reason: string;
|
||||
}): Promise<{ success: boolean; message: string; sessionId?: string }> {
|
||||
const { userId, currentMnemonic, reason } = params;
|
||||
this.logger.log(`[KEY_ROTATION] Requesting key rotation for user: ${userId}, reason: ${reason}`);
|
||||
|
||||
const account = await this.userRepository.findById(UserId.create(userId));
|
||||
if (!account) {
|
||||
this.logger.warn(`[KEY_ROTATION] User not found: ${userId}`);
|
||||
return { success: false, message: '用户不存在' };
|
||||
}
|
||||
|
||||
if (!account.isActive) {
|
||||
return { success: false, message: '账户已冻结或注销,无法进行密钥轮换' };
|
||||
}
|
||||
|
||||
// 验证当前助记词
|
||||
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
|
||||
accountSequence: account.accountSequence.value,
|
||||
mnemonic: currentMnemonic,
|
||||
});
|
||||
|
||||
if (!verifyResult.valid) {
|
||||
this.logger.warn(`[KEY_ROTATION] Mnemonic verification failed for user: ${userId}`);
|
||||
return { success: false, message: verifyResult.message || '助记词验证失败' };
|
||||
}
|
||||
|
||||
// 生成轮换会话ID
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
// 发布密钥轮换请求事件
|
||||
await this.eventPublisher.publish(new KeyRotationRequestedEvent({
|
||||
sessionId,
|
||||
userId,
|
||||
accountSequence: account.accountSequence.value,
|
||||
reason,
|
||||
requestedAt: new Date(),
|
||||
}));
|
||||
|
||||
this.logger.log(`[KEY_ROTATION] Key rotation requested for user: ${userId}, sessionId: ${sessionId}`);
|
||||
return {
|
||||
success: true,
|
||||
message: '密钥轮换请求已提交,请等待处理完成',
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 恢复码相关 ============
|
||||
|
||||
/**
|
||||
* 生成恢复码 (POST /user/backup-codes/generate)
|
||||
*
|
||||
* 生成一组一次性恢复码,用于在丢失助记词时恢复账户
|
||||
* 每次生成会使之前的恢复码失效
|
||||
*/
|
||||
async generateBackupCodes(params: {
|
||||
userId: string;
|
||||
mnemonic: string;
|
||||
}): Promise<{ success: boolean; message: string; codes?: string[] }> {
|
||||
const { userId, mnemonic } = params;
|
||||
this.logger.log(`[BACKUP_CODES] Generating backup codes for user: ${userId}`);
|
||||
|
||||
const account = await this.userRepository.findById(UserId.create(userId));
|
||||
if (!account) {
|
||||
return { success: false, message: '用户不存在' };
|
||||
}
|
||||
|
||||
if (!account.isActive) {
|
||||
return { success: false, message: '账户已冻结或注销' };
|
||||
}
|
||||
|
||||
// 验证助记词
|
||||
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
|
||||
accountSequence: account.accountSequence.value,
|
||||
mnemonic,
|
||||
});
|
||||
|
||||
if (!verifyResult.valid) {
|
||||
return { success: false, message: verifyResult.message || '助记词验证失败' };
|
||||
}
|
||||
|
||||
// 生成 8 个恢复码
|
||||
const codes: string[] = [];
|
||||
const hashedCodes: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const code = this.generateBackupCode();
|
||||
codes.push(code);
|
||||
// 存储哈希值,不存储明文
|
||||
hashedCodes.push(await this.hashBackupCode(code));
|
||||
}
|
||||
|
||||
// 存储到 Redis(带过期时间,1年)
|
||||
const redisKey = `backup_codes:${account.accountSequence.value}`;
|
||||
await this.redisService.set(redisKey, JSON.stringify({
|
||||
codes: hashedCodes,
|
||||
generatedAt: new Date().toISOString(),
|
||||
usedCount: 0,
|
||||
}), 365 * 24 * 60 * 60); // 1年
|
||||
|
||||
this.logger.log(`[BACKUP_CODES] Generated 8 backup codes for user: ${userId}`);
|
||||
return {
|
||||
success: true,
|
||||
message: '恢复码已生成,请妥善保管',
|
||||
codes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用恢复码恢复账户 (POST /user/recover-by-backup-code)
|
||||
*/
|
||||
async recoverByBackupCode(params: {
|
||||
accountSequence: string;
|
||||
backupCode: string;
|
||||
newDeviceId: string;
|
||||
deviceName?: string;
|
||||
}): Promise<RecoverAccountResult> {
|
||||
const { accountSequence, backupCode, newDeviceId, deviceName } = params;
|
||||
this.logger.log(`[BACKUP_CODES] Recovering account ${accountSequence} with backup code`);
|
||||
|
||||
const account = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(accountSequence),
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence,
|
||||
recoveryMethod: 'mnemonic', // 使用 mnemonic 作为类型,因为恢复码是备用方案
|
||||
failureReason: '账户序列号不存在',
|
||||
deviceId: newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('账户序列号不存在');
|
||||
}
|
||||
|
||||
if (!account.isActive) {
|
||||
throw new ApplicationError('账户已冻结或注销');
|
||||
}
|
||||
|
||||
// 验证恢复码
|
||||
const redisKey = `backup_codes:${accountSequence}`;
|
||||
const storedData = await this.redisService.get(redisKey);
|
||||
|
||||
if (!storedData) {
|
||||
throw new ApplicationError('未设置恢复码或恢复码已过期');
|
||||
}
|
||||
|
||||
const { codes: hashedCodes, usedCount } = JSON.parse(storedData);
|
||||
const hashedInput = await this.hashBackupCode(backupCode);
|
||||
|
||||
const codeIndex = hashedCodes.findIndex((h: string) => h === hashedInput);
|
||||
if (codeIndex === -1) {
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence,
|
||||
recoveryMethod: 'mnemonic',
|
||||
failureReason: '恢复码无效',
|
||||
deviceId: newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('恢复码无效');
|
||||
}
|
||||
|
||||
// 标记该恢复码已使用(设为 null)
|
||||
hashedCodes[codeIndex] = null;
|
||||
await this.redisService.set(redisKey, JSON.stringify({
|
||||
codes: hashedCodes,
|
||||
generatedAt: JSON.parse(storedData).generatedAt,
|
||||
usedCount: usedCount + 1,
|
||||
}), 365 * 24 * 60 * 60);
|
||||
|
||||
// 恢复账户
|
||||
account.addDevice(newDeviceId, deviceName);
|
||||
account.recordLogin();
|
||||
await this.userRepository.save(account);
|
||||
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
deviceId: newDeviceId,
|
||||
});
|
||||
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
// 发布恢复成功事件
|
||||
await this.eventPublisher.publish(new AccountRecoveredEvent({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
recoveryMethod: 'mnemonic', // 使用 mnemonic 作为类型
|
||||
deviceId: newDeviceId,
|
||||
deviceName,
|
||||
recoveredAt: new Date(),
|
||||
}));
|
||||
|
||||
this.logger.log(`[BACKUP_CODES] Account ${accountSequence} recovered with backup code`);
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
nickname: account.nickname,
|
||||
avatarUrl: account.avatarUrl,
|
||||
referralCode: account.referralCode.value,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成单个恢复码 (格式: XXXX-XXXX-XXXX)
|
||||
*/
|
||||
private generateBackupCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉容易混淆的字符
|
||||
const segments: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
let segment = '';
|
||||
for (let j = 0; j < 4; j++) {
|
||||
segment += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 哈希恢复码
|
||||
*/
|
||||
private async hashBackupCode(code: string): Promise<string> {
|
||||
const crypto = await import('crypto');
|
||||
return crypto.createHash('sha256').update(code).digest('hex');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,3 +262,45 @@ export class MnemonicRevokedEvent extends DomainEvent {
|
|||
return 'MnemonicRevoked';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户解冻事件 (审计日志)
|
||||
*/
|
||||
export class AccountUnfrozenEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
verifyMethod: 'mnemonic' | 'phone';
|
||||
unfrozenAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'AccountUnfrozen';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥轮换请求事件
|
||||
* 触发 MPC 系统进行密钥轮换
|
||||
*/
|
||||
export class KeyRotationRequestedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
reason: string;
|
||||
requestedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KeyRotationRequested';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue