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 用户表查找
|
// 尝试从同步的 V1 用户表查找
|
||||||
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phone);
|
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phone);
|
||||||
if (!legacyUser) {
|
if (!legacyUser) {
|
||||||
throw new UnauthorizedException('手机号或密码错误');
|
throw new UnauthorizedException('该手机号未注册');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (legacyUser.migratedToV2) {
|
if (legacyUser.migratedToV2) {
|
||||||
|
|
@ -193,16 +193,22 @@ export class AuthService {
|
||||||
): Promise<LoginResult> {
|
): Promise<LoginResult> {
|
||||||
if (!user.canLogin) {
|
if (!user.canLogin) {
|
||||||
if (user.isLocked) {
|
if (user.isLocked) {
|
||||||
throw new UnauthorizedException('账户已被锁定,请稍后再试');
|
const remainingSeconds = user.getLockRemainingSeconds();
|
||||||
|
const minutes = Math.ceil(remainingSeconds / 60);
|
||||||
|
throw new UnauthorizedException(`账户已被锁定,请${minutes}分钟后再试`);
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException('账户已被禁用');
|
throw new UnauthorizedException('账户已被禁用');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = await user.verifyPassword(password);
|
const isValid = await user.verifyPassword(password);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
user.recordLoginFailure();
|
const result = user.recordLoginFailure();
|
||||||
await this.userRepository.save(user);
|
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);
|
user.recordLoginSuccess(ipAddress);
|
||||||
|
|
@ -224,7 +230,7 @@ export class AuthService {
|
||||||
const bcrypt = await import('bcrypt');
|
const bcrypt = await import('bcrypt');
|
||||||
const isValid = await bcrypt.compare(password, legacyUser.passwordHash);
|
const isValid = await bcrypt.compare(password, legacyUser.passwordHash);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
throw new UnauthorizedException('手机号或密码错误');
|
throw new UnauthorizedException('密码错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 V2 用户(保留原 accountSequence)
|
// 创建 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 {
|
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._loginFailCount += 1;
|
||||||
if (this._loginFailCount >= maxAttempts) {
|
|
||||||
this._lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000);
|
|
||||||
}
|
|
||||||
this._updatedAt = new Date();
|
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(
|
return Scaffold(
|
||||||
body: child,
|
body: child,
|
||||||
bottomNavigationBar: Container(
|
bottomNavigationBar: Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
border: Border(
|
boxShadow: [
|
||||||
top: BorderSide(color: Color(0xFFF3F4F6), width: 1),
|
BoxShadow(
|
||||||
),
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Padding(
|
top: false,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
child: Container(
|
||||||
|
height: 56,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_buildNavItem(
|
_buildNavItem(
|
||||||
context,
|
context,
|
||||||
|
|
@ -77,10 +83,10 @@ class MainShell extends StatelessWidget {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _onItemTapped(index, context),
|
onTap: () => _onItemTapped(index, context),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Padding(
|
child: SizedBox(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
width: 64,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isSelected ? activeIcon : icon,
|
isSelected ? activeIcon : icon,
|
||||||
|
|
@ -91,8 +97,8 @@ class MainShell extends StatelessWidget {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 11,
|
||||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||||
color: isSelected ? _orange : _grayText,
|
color: isSelected ? _orange : _grayText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue