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:
liuyun 2026-10-11 16:46:13 +08:00
parent 5c1f2992b5
commit 606585c3bd
3 changed files with 41 additions and 4 deletions

View File

@ -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({

View File

@ -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;
}

View File

@ -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;
}