refactor: strengthen client boundaries and quality gates
This commit is contained in:
@@ -1,16 +1,26 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { LoginDto } from './auth.dto';
|
||||
import { SessionService } from './session.service';
|
||||
import { MetricsService } from '../metrics/metrics.service';
|
||||
|
||||
type LoginPortal = 'admin' | 'client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
|
||||
constructor(
|
||||
private readonly users: UsersService,
|
||||
private readonly sessions: SessionService,
|
||||
private readonly metrics: MetricsService,
|
||||
) {}
|
||||
|
||||
async createCaptcha() {
|
||||
async createCaptcha(sourceIp: string) {
|
||||
if (!(await this.sessions.assertCaptchaRequestAllowed(sourceIp))) {
|
||||
this.metrics.recordAuthProtectionResult('captcha_rejected');
|
||||
throw new HttpException('验证码请求过于频繁,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
this.metrics.recordAuthProtectionResult('captcha_allowed');
|
||||
const left = Math.floor(10 + Math.random() * 40);
|
||||
const right = Math.floor(1 + Math.random() * 9);
|
||||
const captchaId = randomUUID();
|
||||
@@ -22,43 +32,48 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async login(data: LoginDto, portal: LoginPortal) {
|
||||
async login(data: LoginDto, portal: LoginPortal, sourceIp: string) {
|
||||
const login = data.login?.trim();
|
||||
if (!login || !data.password) {
|
||||
throw new BadRequestException('login and password are required');
|
||||
}
|
||||
await this.verifyCaptcha(data.captchaId, data.captchaText);
|
||||
await this.assertAnonymousNotLocked(login);
|
||||
await this.assertAnonymousNotLocked(login, sourceIp);
|
||||
|
||||
const user = await this.users.findByLogin(login);
|
||||
if (!user) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login);
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
throw new UnauthorizedException('Invalid login or password');
|
||||
}
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
||||
}
|
||||
if (user.status !== 'active' || user.deletedAt) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('User is disabled or deleted');
|
||||
}
|
||||
if (!await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash)) {
|
||||
if (!(await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash))) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Invalid login or password');
|
||||
}
|
||||
|
||||
const roleCodes = user.roles.map((item) => item.role.code);
|
||||
if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Only platform admins can login to admin portal');
|
||||
}
|
||||
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
|
||||
}
|
||||
|
||||
await this.users.recordLoginSuccess(user.id);
|
||||
await this.sessions.clearAnonymousLoginFailures(login);
|
||||
await this.sessions.clearAnonymousLoginFailures(login, sourceIp);
|
||||
|
||||
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
|
||||
return {
|
||||
@@ -98,9 +113,15 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAnonymousNotLocked(login: string) {
|
||||
if (await this.sessions.isAnonymousLoginLocked(login)) {
|
||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
||||
private async assertAnonymousNotLocked(login: string, sourceIp: string) {
|
||||
const scope = await this.sessions.anonymousLoginLockScope(login, sourceIp);
|
||||
if (scope) {
|
||||
this.metrics.recordAuthProtectionResult('login_locked', scope);
|
||||
throw new UnauthorizedException(
|
||||
scope === 'account'
|
||||
? 'User is locked for 24 hours after repeated failures'
|
||||
: 'Too many login attempts from this source, try again later',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user