feat(identity/blockchain): 增强助记词安全性和审计日志
1. blockchain-service 助记词验证增强: - 验证前先检查是否存在已挂失(REVOKED)的助记词记录 - 如果检测到挂失记录,立即拒绝恢复请求 2. identity-service 审计日志事件: - 新增 AccountRecoveredEvent: 账户恢复成功事件 - 新增 AccountRecoveryFailedEvent: 账户恢复失败事件 - 新增 MnemonicRevokedEvent: 助记词挂失事件 3. 恢复操作审计: - recoverByMnemonic: 记录所有失败原因和成功事件 - recoverByPhone: 记录所有失败原因和成功事件 - revokeMnemonic: 记录挂失成功事件 🤖 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
effd34cd0a
commit
f2a6c09d86
|
|
@ -32,7 +32,24 @@ export class MnemonicVerificationService {
|
|||
const { accountSequence, mnemonic } = params;
|
||||
this.logger.log(`Verifying mnemonic for account ${accountSequence}`);
|
||||
|
||||
// 1. 查询账户的 ACTIVE 助记词记录
|
||||
// 1. 先检查是否有已挂失的助记词 (安全检查)
|
||||
const revokedRecord = await this.prisma.recoveryMnemonic.findFirst({
|
||||
where: {
|
||||
accountSequence,
|
||||
status: 'REVOKED',
|
||||
},
|
||||
orderBy: { revokedAt: 'desc' },
|
||||
});
|
||||
|
||||
if (revokedRecord) {
|
||||
this.logger.warn(`Account ${accountSequence} has revoked mnemonic, rejecting recovery attempt`);
|
||||
return {
|
||||
valid: false,
|
||||
message: '该助记词已被挂失,无法用于账户恢复。如需帮助请联系客服。',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 查询账户的 ACTIVE 助记词记录
|
||||
const recoveryRecord = await this.prisma.recoveryMnemonic.findFirst({
|
||||
where: {
|
||||
accountSequence,
|
||||
|
|
@ -48,7 +65,7 @@ export class MnemonicVerificationService {
|
|||
};
|
||||
}
|
||||
|
||||
// 2. 使用 RecoveryMnemonicAdapter 验证哈希 (bcrypt 是异步的)
|
||||
// 3. 使用 RecoveryMnemonicAdapter 验证哈希 (bcrypt 是异步的)
|
||||
const result = await this.recoveryMnemonic.verifyMnemonic(mnemonic, recoveryRecord.mnemonicHash);
|
||||
|
||||
if (result.valid) {
|
||||
|
|
|
|||
|
|
@ -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 } from '@/domain/events';
|
||||
import { MpcKeygenRequestedEvent, AccountRecoveredEvent, AccountRecoveryFailedEvent, MnemonicRevokedEvent } from '@/domain/events';
|
||||
import {
|
||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
||||
|
|
@ -153,14 +153,40 @@ export class UserApplicationService {
|
|||
async recoverByMnemonic(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
|
||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||
if (!account) {
|
||||
// 发布恢复失败审计事件
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence: command.accountSequence,
|
||||
recoveryMethod: 'mnemonic',
|
||||
failureReason: '账户序列号不存在',
|
||||
deviceId: command.newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('账户序列号不存在');
|
||||
}
|
||||
if (!account.isActive) {
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence: command.accountSequence,
|
||||
recoveryMethod: 'mnemonic',
|
||||
failureReason: '账户已冻结或注销',
|
||||
deviceId: command.newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('账户已冻结或注销');
|
||||
}
|
||||
|
||||
// 检查验证失败次数限制 (防止暴力破解)
|
||||
const failKey = `mnemonic:fail:${command.accountSequence}`;
|
||||
const failCountStr = await this.redisService.get(failKey);
|
||||
const failCount = failCountStr ? parseInt(failCountStr, 10) : 0;
|
||||
if (failCount >= 5) {
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence: command.accountSequence,
|
||||
recoveryMethod: 'mnemonic',
|
||||
failureReason: '验证次数过多',
|
||||
deviceId: command.newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('验证次数过多,请1小时后重试');
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +201,15 @@ export class UserApplicationService {
|
|||
// 记录失败次数
|
||||
await this.redisService.incr(failKey);
|
||||
await this.redisService.expire(failKey, 3600); // 1小时过期
|
||||
throw new ApplicationError('助记词错误');
|
||||
// 发布恢复失败审计事件
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence: command.accountSequence,
|
||||
recoveryMethod: 'mnemonic',
|
||||
failureReason: verifyResult.message || '助记词错误',
|
||||
deviceId: command.newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError(verifyResult.message || '助记词错误');
|
||||
}
|
||||
|
||||
// 验证成功,清除失败计数
|
||||
|
|
@ -194,6 +228,16 @@ export class UserApplicationService {
|
|||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
// 发布账户恢复成功审计事件
|
||||
await this.eventPublisher.publish(new AccountRecoveredEvent({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
recoveryMethod: 'mnemonic',
|
||||
deviceId: command.newDeviceId,
|
||||
deviceName: command.deviceName,
|
||||
recoveredAt: new Date(),
|
||||
}));
|
||||
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
|
|
@ -208,15 +252,60 @@ export class UserApplicationService {
|
|||
async recoverByPhone(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
||||
if (!account) {
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence: command.accountSequence,
|
||||
recoveryMethod: 'phone',
|
||||
failureReason: '账户序列号不存在',
|
||||
deviceId: command.newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('账户序列号不存在');
|
||||
}
|
||||
if (!account.isActive) {
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence: command.accountSequence,
|
||||
recoveryMethod: 'phone',
|
||||
failureReason: '账户已冻结或注销',
|
||||
deviceId: command.newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('账户已冻结或注销');
|
||||
}
|
||||
if (!account.phoneNumber) {
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence: command.accountSequence,
|
||||
recoveryMethod: 'phone',
|
||||
failureReason: '账户未绑定手机号',
|
||||
deviceId: command.newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
||||
}
|
||||
|
||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||
if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配');
|
||||
if (!account.phoneNumber.equals(phoneNumber)) {
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence: command.accountSequence,
|
||||
recoveryMethod: 'phone',
|
||||
failureReason: '手机号与账户不匹配',
|
||||
deviceId: command.newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('手机号与账户不匹配');
|
||||
}
|
||||
|
||||
const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`);
|
||||
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
||||
if (cachedCode !== command.smsCode) {
|
||||
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||
accountSequence: command.accountSequence,
|
||||
recoveryMethod: 'phone',
|
||||
failureReason: '验证码错误或已过期',
|
||||
deviceId: command.newDeviceId,
|
||||
attemptedAt: new Date(),
|
||||
}));
|
||||
throw new ApplicationError('验证码错误或已过期');
|
||||
}
|
||||
|
||||
account.addDevice(command.newDeviceId, command.deviceName);
|
||||
account.recordLogin();
|
||||
|
|
@ -232,6 +321,16 @@ export class UserApplicationService {
|
|||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
// 发布账户恢复成功审计事件
|
||||
await this.eventPublisher.publish(new AccountRecoveredEvent({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
recoveryMethod: 'phone',
|
||||
deviceId: command.newDeviceId,
|
||||
deviceName: command.deviceName,
|
||||
recoveredAt: new Date(),
|
||||
}));
|
||||
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
|
|
@ -798,6 +897,17 @@ export class UserApplicationService {
|
|||
try {
|
||||
const result = await this.blockchainClient.revokeMnemonic(account.accountSequence.value, reason);
|
||||
this.logger.log(`[REVOKE] Mnemonic revoke result: ${JSON.stringify(result)}`);
|
||||
|
||||
// 3. 发布助记词挂失审计事件
|
||||
if (result.success) {
|
||||
await this.eventPublisher.publish(new MnemonicRevokedEvent({
|
||||
userId,
|
||||
accountSequence: account.accountSequence.value,
|
||||
reason,
|
||||
revokedAt: new Date(),
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`[REVOKE] Failed to revoke mnemonic`, error);
|
||||
|
|
|
|||
|
|
@ -193,3 +193,72 @@ export class MpcKeygenRequestedEvent extends DomainEvent {
|
|||
return 'MpcKeygenRequested';
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 账户恢复相关事件 ============
|
||||
|
||||
/**
|
||||
* 账户恢复成功事件 (审计日志)
|
||||
*/
|
||||
export class AccountRecoveredEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
recoveryMethod: 'mnemonic' | 'phone';
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
recoveredAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'AccountRecovered';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户恢复失败事件 (审计日志)
|
||||
*/
|
||||
export class AccountRecoveryFailedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
accountSequence: string;
|
||||
recoveryMethod: 'mnemonic' | 'phone';
|
||||
failureReason: string;
|
||||
deviceId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
attemptedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'AccountRecoveryFailed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 助记词挂失事件 (审计日志)
|
||||
*/
|
||||
export class MnemonicRevokedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
reason: string;
|
||||
revokedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'MnemonicRevoked';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue