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:
parent
608e22a8e7
commit
7a68668aa9
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
this._loginFailCount += 1;
|
||||
if (this._loginFailCount >= maxAttempts) {
|
||||
this._lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000);
|
||||
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;
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in New Issue