feat(auth-service): 增强登录错误提示和指数退避锁定机制

- 区分用户不存在和密码错误的提示信息
- 登录失败最多允许6次尝试
- 每次密码错误显示剩余尝试次数
- 超过次数后实现指数退避锁定(1,2,4,8...分钟,最长24小时)
- 锁定时显示剩余等待时间
- 优化mining-app底部导航栏图标间距

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-11 18:33:23 -08:00
parent 608e22a8e7
commit 7a68668aa9
3 changed files with 79 additions and 22 deletions

View File

@ -123,7 +123,7 @@ export class AuthService {
// 尝试从同步的 V1 用户表查找
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phone);
if (!legacyUser) {
throw new UnauthorizedException('手机号或密码错误');
throw new UnauthorizedException('该手机号未注册');
}
if (legacyUser.migratedToV2) {
@ -193,16 +193,22 @@ export class AuthService {
): Promise<LoginResult> {
if (!user.canLogin) {
if (user.isLocked) {
throw new UnauthorizedException('账户已被锁定,请稍后再试');
const remainingSeconds = user.getLockRemainingSeconds();
const minutes = Math.ceil(remainingSeconds / 60);
throw new UnauthorizedException(`账户已被锁定,请${minutes}分钟后再试`);
}
throw new UnauthorizedException('账户已被禁用');
}
const isValid = await user.verifyPassword(password);
if (!isValid) {
user.recordLoginFailure();
const result = user.recordLoginFailure();
await this.userRepository.save(user);
throw new UnauthorizedException('手机号或密码错误');
if (result.remainingAttempts === 0) {
throw new UnauthorizedException(`密码错误次数过多,账户已锁定${result.lockMinutes}分钟`);
}
throw new UnauthorizedException(`密码错误,还剩${result.remainingAttempts}次尝试机会`);
}
user.recordLoginSuccess(ipAddress);
@ -224,7 +230,7 @@ export class AuthService {
const bcrypt = await import('bcrypt');
const isValid = await bcrypt.compare(password, legacyUser.passwordHash);
if (!isValid) {
throw new UnauthorizedException('手机号或密码错误');
throw new UnauthorizedException('密码错误');
}
// 创建 V2 用户(保留原 accountSequence

View File

@ -248,14 +248,59 @@ export class UserAggregate {
}
/**
*
* 退
* 第6次失败: 1分钟
* 第7次失败: 2分钟
* 第8次失败: 4分钟
* 第9次失败: 8分钟
* 第10次失败: 16分钟
* ...24
*/
recordLoginFailure(maxAttempts: number = 5, lockMinutes: number = 30): void {
private calculateLockMinutes(failCount: number, maxAttempts: number): number {
const excessAttempts = failCount - maxAttempts;
// 2^(excessAttempts) 分钟,最长 1440 分钟24小时
const lockMinutes = Math.pow(2, excessAttempts);
return Math.min(lockMinutes, 1440);
}
/**
*
* @param maxAttempts 6
* @returns
*/
recordLoginFailure(maxAttempts: number = 6): { remainingAttempts: number; lockedUntil?: Date; lockMinutes?: number } {
this._loginFailCount += 1;
if (this._loginFailCount >= maxAttempts) {
this._lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000);
}
this._updatedAt = new Date();
if (this._loginFailCount >= maxAttempts) {
const lockMinutes = this.calculateLockMinutes(this._loginFailCount, maxAttempts);
this._lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000);
return {
remainingAttempts: 0,
lockedUntil: this._lockedUntil,
lockMinutes,
};
}
return {
remainingAttempts: maxAttempts - this._loginFailCount,
};
}
/**
*
*/
getRemainingAttempts(maxAttempts: number = 6): number {
return Math.max(0, maxAttempts - this._loginFailCount);
}
/**
*
*/
getLockRemainingSeconds(): number {
if (!this._lockedUntil) return 0;
const remaining = this._lockedUntil.getTime() - Date.now();
return Math.max(0, Math.ceil(remaining / 1000));
}
/**

View File

@ -16,17 +16,23 @@ class MainShell extends StatelessWidget {
return Scaffold(
body: child,
bottomNavigationBar: Container(
decoration: const BoxDecoration(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Color(0xFFF3F4F6), width: 1),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
top: false,
child: Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildNavItem(
context,
@ -77,10 +83,10 @@ class MainShell extends StatelessWidget {
return GestureDetector(
onTap: () => _onItemTapped(index, context),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: SizedBox(
width: 64,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected ? activeIcon : icon,
@ -91,8 +97,8 @@ class MainShell extends StatelessWidget {
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
fontSize: 11,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? _orange : _grayText,
),
),