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