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 REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
|
||||||
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
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 {
|
export interface PermissionSet {
|
||||||
super: boolean;
|
super: boolean;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
@ -84,8 +88,16 @@ export class AuthService {
|
|||||||
| { accessToken: string; refreshToken: string; expiresIn: number }
|
| { accessToken: string; refreshToken: string; expiresIn: number }
|
||||||
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
|
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
|
||||||
> {
|
> {
|
||||||
|
await this.assertNotLocked(dto.username);
|
||||||
if (dto.tenantCode) {
|
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({
|
const users = await this.prisma.client.user.findMany({
|
||||||
@ -102,15 +114,35 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (matched.length === 0) {
|
if (matched.length === 0) {
|
||||||
|
await this.recordLoginFail(dto.username);
|
||||||
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
|
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
|
||||||
throw new UnauthorizedException('账号或密码错误');
|
throw new UnauthorizedException('账号或密码错误');
|
||||||
}
|
}
|
||||||
|
await this.clearLoginFail(dto.username);
|
||||||
if (matched.length === 1) {
|
if (matched.length === 1) {
|
||||||
return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent);
|
return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent);
|
||||||
}
|
}
|
||||||
return { needSelectTenant: true, tenants: matched };
|
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(
|
private async loginTenant(
|
||||||
tenantCode: string,
|
tenantCode: string,
|
||||||
@ -198,7 +230,8 @@ export class AuthService {
|
|||||||
|
|
||||||
async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
||||||
const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } });
|
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 无效或已过期');
|
throw new UnauthorizedException('refresh token 无效或已过期');
|
||||||
}
|
}
|
||||||
const user = await this.prisma.client.user.findFirst({
|
const user = await this.prisma.client.user.findFirst({
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@ApiPropertyOptional({ description: '租户编码(多租户二选一时传入;缺省按用户名自动探测)' })
|
@ApiPropertyOptional({ description: '租户编码(多租户二选一时传入;缺省按用户名自动探测)' })
|
||||||
@ -32,6 +32,7 @@ export class RegisterDto {
|
|||||||
@ApiProperty({ description: '密码' })
|
@ApiProperty({ description: '密码' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '邮箱' })
|
@ApiPropertyOptional({ description: '邮箱' })
|
||||||
@ -54,6 +55,7 @@ export class ChangePasswordDto {
|
|||||||
@ApiProperty({ description: '新密码' })
|
@ApiProperty({ description: '新密码' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
||||||
newPassword!: string;
|
newPassword!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 { PageQuery } from '../common/page.dto.js';
|
||||||
import { UserStatus } from '../common/enums.js';
|
import { UserStatus } from '../common/enums.js';
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ export class CreateUserDto {
|
|||||||
@ApiProperty({ description: '初始密码' })
|
@ApiProperty({ description: '初始密码' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '昵称' })
|
@ApiPropertyOptional({ description: '昵称' })
|
||||||
@ -76,6 +77,7 @@ export class ResetPasswordDto {
|
|||||||
@ApiProperty({ description: '新密码' })
|
@ApiProperty({ description: '新密码' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
||||||
password!: string;
|
password!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user