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:
hailin 2025-12-16 17:06:28 -08:00
parent f2a6c09d86
commit 2730bcb354
9 changed files with 555 additions and 1 deletions

View File

@ -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: '上传用户头像' })

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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');
}
}

View File

@ -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';
}
}