From f2a6c09d8695e3715c3e1c2d6d31ca5af585a299 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 16 Dec 2025 16:56:42 -0800 Subject: [PATCH] =?UTF-8?q?feat(identity/blockchain):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=8A=A9=E8=AE=B0=E8=AF=8D=E5=AE=89=E5=85=A8=E6=80=A7=E5=92=8C?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../services/mnemonic-verification.service.ts | 21 ++- .../services/user-application.service.ts | 128 ++++++++++++++++-- .../src/domain/events/index.ts | 69 ++++++++++ 3 files changed, 207 insertions(+), 11 deletions(-) diff --git a/backend/services/blockchain-service/src/application/services/mnemonic-verification.service.ts b/backend/services/blockchain-service/src/application/services/mnemonic-verification.service.ts index d166fa17..6fba7cde 100644 --- a/backend/services/blockchain-service/src/application/services/mnemonic-verification.service.ts +++ b/backend/services/blockchain-service/src/application/services/mnemonic-verification.service.ts @@ -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) { diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index 9c7349f3..a530fff9 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -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 { 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 { 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); diff --git a/backend/services/identity-service/src/domain/events/index.ts b/backend/services/identity-service/src/domain/events/index.ts index d5a65bb5..7a13e7ac 100644 --- a/backend/services/identity-service/src/domain/events/index.ts +++ b/backend/services/identity-service/src/domain/events/index.ts @@ -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'; + } +}