fix: harden tenant auth and quality gates

This commit is contained in:
hectorzhao
2026-08-28 11:44:16 +08:00
parent c3bf8af3e6
commit 2744690f9f
51 changed files with 1750 additions and 466 deletions
+15 -43
View File
@@ -1,37 +1,20 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { hashPassword, UsersService } from '../users/users.service';
import { UsersService } from '../users/users.service';
import type { LoginDto } from './auth.dto';
import { SessionService } from './session.service';
export interface LoginDto {
login: string;
password: string;
captchaId: string;
captchaText: string;
}
type LoginPortal = 'admin' | 'client';
type CaptchaRecord = {
answer: string;
expiresAt: number;
};
const captchaStore = new Map<string, CaptchaRecord>();
const anonymousFailures = new Map<string, { count: number; lockedUntil?: number }>();
@Injectable()
export class AuthService {
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
createCaptcha() {
async createCaptcha() {
const left = Math.floor(10 + Math.random() * 40);
const right = Math.floor(1 + Math.random() * 9);
const captchaId = randomUUID();
captchaStore.set(captchaId, {
answer: String(left + right),
expiresAt: Date.now() + 5 * 60 * 1000,
});
await this.sessions.storeCaptcha(captchaId, String(left + right), 5 * 60);
return {
captchaId,
challenge: `${left} + ${right} = ?`,
@@ -44,12 +27,12 @@ export class AuthService {
if (!login || !data.password) {
throw new BadRequestException('login and password are required');
}
this.verifyCaptcha(data.captchaId, data.captchaText);
this.assertAnonymousNotLocked(login);
await this.verifyCaptcha(data.captchaId, data.captchaText);
await this.assertAnonymousNotLocked(login);
const user = await this.users.findByLogin(login);
if (!user) {
this.recordAnonymousFailure(login);
await this.sessions.recordAnonymousLoginFailure(login);
throw new UnauthorizedException('Invalid login or password');
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
@@ -59,7 +42,7 @@ export class AuthService {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('User is disabled or deleted');
}
if (user.passwordHash !== hashPassword(data.password)) {
if (!await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash)) {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Invalid login or password');
}
@@ -75,7 +58,7 @@ export class AuthService {
}
await this.users.recordLoginSuccess(user.id);
anonymousFailures.delete(login);
await this.sessions.clearAnonymousLoginFailures(login);
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
return {
@@ -105,30 +88,19 @@ export class AuthService {
return this.sessions.markReauthenticated(token);
}
private verifyCaptcha(captchaId?: string, captchaText?: string) {
const record = captchaId ? captchaStore.get(captchaId) : undefined;
captchaStore.delete(captchaId ?? '');
if (!record || record.expiresAt < Date.now()) {
private async verifyCaptcha(captchaId?: string, captchaText?: string) {
const answer = captchaId ? await this.sessions.consumeCaptcha(captchaId) : null;
if (!answer) {
throw new BadRequestException('Captcha expired, refresh and try again');
}
if (record.answer !== captchaText?.trim()) {
if (answer !== captchaText?.trim()) {
throw new BadRequestException('Captcha is incorrect');
}
}
private assertAnonymousNotLocked(login: string) {
const current = anonymousFailures.get(login);
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
private async assertAnonymousNotLocked(login: string) {
if (await this.sessions.isAnonymousLoginLocked(login)) {
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
}
}
private recordAnonymousFailure(login: string) {
const current = anonymousFailures.get(login) ?? { count: 0 };
const count = current.count + 1;
anonymousFailures.set(login, {
count,
lockedUntil: count >= 5 ? Date.now() + 24 * 60 * 60 * 1000 : current.lockedUntil,
});
}
}