feat(security): 登录失败锁定 + 密码长度策略 + refresh token 哈希校验
- 登录连续失败 5 次锁定 15 分钟(Redis 计数) - 密码字段 @MinLength(6) - refresh 校验 tokenHash Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5c1f2992b5
commit
606585c3bd
@ -15,6 +15,10 @@ import { PLATFORM_ROLE_CODE as SUPER_ROLE, permCacheKey, userStateCacheKey } fro
|
||||
const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
|
||||
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
||||
|
||||
const LOGIN_FAIL_PREFIX = 'loginFail:';
|
||||
const MAX_LOGIN_FAILS = 5;
|
||||
const LOGIN_LOCK_SECONDS = 900;
|
||||
|
||||
export interface PermissionSet {
|
||||
super: boolean;
|
||||
permissions: string[];
|
||||
@ -84,8 +88,16 @@ export class AuthService {
|
||||
| { accessToken: string; refreshToken: string; expiresIn: number }
|
||||
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
|
||||
> {
|
||||
await this.assertNotLocked(dto.username);
|
||||
if (dto.tenantCode) {
|
||||
return this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent);
|
||||
try {
|
||||
const result = await this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent);
|
||||
await this.clearLoginFail(dto.username);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof UnauthorizedException) await this.recordLoginFail(dto.username);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
|
||||
const users = await this.prisma.client.user.findMany({
|
||||
@ -102,15 +114,35 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
if (matched.length === 0) {
|
||||
await this.recordLoginFail(dto.username);
|
||||
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
await this.clearLoginFail(dto.username);
|
||||
if (matched.length === 1) {
|
||||
return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent);
|
||||
}
|
||||
return { needSelectTenant: true, tenants: matched };
|
||||
}
|
||||
|
||||
/** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期。 */
|
||||
private async assertNotLocked(username: string): Promise<void> {
|
||||
const count = (await this.redis.getJson<number>(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0;
|
||||
if (count >= MAX_LOGIN_FAILS) {
|
||||
throw new UnauthorizedException('登录失败次数过多,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
private async recordLoginFail(username: string): Promise<void> {
|
||||
const key = `${LOGIN_FAIL_PREFIX}${username}`;
|
||||
const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1;
|
||||
await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS);
|
||||
}
|
||||
|
||||
private async clearLoginFail(username: string): Promise<void> {
|
||||
await this.redis.del(`${LOGIN_FAIL_PREFIX}${username}`);
|
||||
}
|
||||
|
||||
/** 指定租户登录(单租户逻辑)。 */
|
||||
private async loginTenant(
|
||||
tenantCode: string,
|
||||
@ -198,7 +230,8 @@ export class AuthService {
|
||||
|
||||
async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
||||
const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } });
|
||||
if (!record || record.revokedAt || record.expireAt.getTime() < Date.now()) {
|
||||
const tokenHash = createHash('sha256').update(dto.refreshToken).digest('hex');
|
||||
if (!record || record.tokenHash !== tokenHash || record.revokedAt || record.expireAt.getTime() < Date.now()) {
|
||||
throw new UnauthorizedException('refresh token 无效或已过期');
|
||||
}
|
||||
const user = await this.prisma.client.user.findFirst({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiPropertyOptional({ description: '租户编码(多租户二选一时传入;缺省按用户名自动探测)' })
|
||||
@ -32,6 +32,7 @@ export class RegisterDto {
|
||||
@ApiProperty({ description: '密码' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
||||
password!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '邮箱' })
|
||||
@ -54,6 +55,7 @@ export class ChangePasswordDto {
|
||||
@ApiProperty({ description: '新密码' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
||||
newPassword!: string;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
import { UserStatus } from '../common/enums.js';
|
||||
|
||||
@ -12,6 +12,7 @@ export class CreateUserDto {
|
||||
@ApiProperty({ description: '初始密码' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
||||
password!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '昵称' })
|
||||
@ -76,6 +77,7 @@ export class ResetPasswordDto {
|
||||
@ApiProperty({ description: '新密码' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
||||
password!: string;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user