From 606585c3bd20fbc65c8cf5a7e3e686c90911ad8a Mon Sep 17 00:00:00 2001 From: liuyun Date: Sun, 11 Oct 2026 16:46:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E7=99=BB=E5=BD=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=94=81=E5=AE=9A=20+=20=E5=AF=86=E7=A0=81=E9=95=BF?= =?UTF-8?q?=E5=BA=A6=E7=AD=96=E7=95=A5=20+=20refresh=20token=20=E5=93=88?= =?UTF-8?q?=E5=B8=8C=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录连续失败 5 次锁定 15 分钟(Redis 计数) - 密码字段 @MinLength(6) - refresh 校验 tokenHash Co-Authored-By: Claude Opus 4.8 --- src/auth/auth.service.ts | 37 +++++++++++++++++++++++++++++++++++-- src/auth/dto.ts | 4 +++- src/users/dto.ts | 4 +++- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 16ca0b1..694e5fe 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 { + const count = (await this.redis.getJson(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0; + if (count >= MAX_LOGIN_FAILS) { + throw new UnauthorizedException('登录失败次数过多,请稍后再试'); + } + } + + private async recordLoginFail(username: string): Promise { + const key = `${LOGIN_FAIL_PREFIX}${username}`; + const count = ((await this.redis.getJson(key)) ?? 0) + 1; + await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS); + } + + private async clearLoginFail(username: string): Promise { + 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({ diff --git a/src/auth/dto.ts b/src/auth/dto.ts index a63739b..4f5112d 100644 --- a/src/auth/dto.ts +++ b/src/auth/dto.ts @@ -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; } diff --git a/src/users/dto.ts b/src/users/dto.ts index 7025f8b..376b4e0 100644 --- a/src/users/dto.ts +++ b/src/users/dto.ts @@ -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; }