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;
|
const { accountSequence, mnemonic } = params;
|
||||||
this.logger.log(`Verifying mnemonic for account ${accountSequence}`);
|
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({
|
const recoveryRecord = await this.prisma.recoveryMnemonic.findFirst({
|
||||||
where: {
|
where: {
|
||||||
accountSequence,
|
accountSequence,
|
||||||
|
|
@ -48,7 +65,7 @@ export class MnemonicVerificationService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 使用 RecoveryMnemonicAdapter 验证哈希 (bcrypt 是异步的)
|
// 3. 使用 RecoveryMnemonicAdapter 验证哈希 (bcrypt 是异步的)
|
||||||
const result = await this.recoveryMnemonic.verifyMnemonic(mnemonic, recoveryRecord.mnemonicHash);
|
const result = await this.recoveryMnemonic.verifyMnemonic(mnemonic, recoveryRecord.mnemonicHash);
|
||||||
|
|
||||||
if (result.valid) {
|
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 { MpcKeygenCompletedHandler } from '../event-handlers/mpc-keygen-completed.handler';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
import { generateIdentity } from '@/shared/utils';
|
import { generateIdentity } from '@/shared/utils';
|
||||||
import { MpcKeygenRequestedEvent } from '@/domain/events';
|
import { MpcKeygenRequestedEvent, AccountRecoveredEvent, AccountRecoveryFailedEvent, MnemonicRevokedEvent } from '@/domain/events';
|
||||||
import {
|
import {
|
||||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
||||||
|
|
@ -153,14 +153,40 @@ export class UserApplicationService {
|
||||||
async recoverByMnemonic(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
|
async recoverByMnemonic(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
|
||||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
||||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
if (!account) {
|
||||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
// 发布恢复失败审计事件
|
||||||
|
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 failKey = `mnemonic:fail:${command.accountSequence}`;
|
||||||
const failCountStr = await this.redisService.get(failKey);
|
const failCountStr = await this.redisService.get(failKey);
|
||||||
const failCount = failCountStr ? parseInt(failCountStr, 10) : 0;
|
const failCount = failCountStr ? parseInt(failCountStr, 10) : 0;
|
||||||
if (failCount >= 5) {
|
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小时后重试');
|
throw new ApplicationError('验证次数过多,请1小时后重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +201,15 @@ export class UserApplicationService {
|
||||||
// 记录失败次数
|
// 记录失败次数
|
||||||
await this.redisService.incr(failKey);
|
await this.redisService.incr(failKey);
|
||||||
await this.redisService.expire(failKey, 3600); // 1小时过期
|
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);
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
account.clearDomainEvents();
|
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 {
|
return {
|
||||||
userId: account.userId.toString(),
|
userId: account.userId.toString(),
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
|
|
@ -208,15 +252,60 @@ export class UserApplicationService {
|
||||||
async recoverByPhone(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
async recoverByPhone(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
||||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
||||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
if (!account) {
|
||||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
await this.eventPublisher.publish(new AccountRecoveryFailedEvent({
|
||||||
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
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);
|
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}`);
|
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.addDevice(command.newDeviceId, command.deviceName);
|
||||||
account.recordLogin();
|
account.recordLogin();
|
||||||
|
|
@ -232,6 +321,16 @@ export class UserApplicationService {
|
||||||
await this.eventPublisher.publishAll(account.domainEvents);
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
account.clearDomainEvents();
|
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 {
|
return {
|
||||||
userId: account.userId.toString(),
|
userId: account.userId.toString(),
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
|
|
@ -798,6 +897,17 @@ export class UserApplicationService {
|
||||||
try {
|
try {
|
||||||
const result = await this.blockchainClient.revokeMnemonic(account.accountSequence.value, reason);
|
const result = await this.blockchainClient.revokeMnemonic(account.accountSequence.value, reason);
|
||||||
this.logger.log(`[REVOKE] Mnemonic revoke result: ${JSON.stringify(result)}`);
|
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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[REVOKE] Failed to revoke mnemonic`, error);
|
this.logger.error(`[REVOKE] Failed to revoke mnemonic`, error);
|
||||||
|
|
|
||||||
|
|
@ -193,3 +193,72 @@ export class MpcKeygenRequestedEvent extends DomainEvent {
|
||||||
return 'MpcKeygenRequested';
|
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