Files
lislgosms/api/src/auth/auth.service.ts
T

128 lines
5.0 KiB
TypeScript

import { randomUUID } from 'node:crypto';
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,
private readonly metrics: MetricsService,
) {}
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();
await this.sessions.storeCaptcha(captchaId, String(left + right), 5 * 60);
return {
captchaId,
challenge: `${left} + ${right} = ?`,
expiresInSeconds: 300,
};
}
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, sourceIp);
const user = await this.users.findByLogin(login);
if (!user) {
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))) {
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, sourceIp);
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
return {
sessionToken: token,
portal,
user: {
id: user.id,
tenantId: user.tenantId,
tenantName: user.tenant?.name,
username: user.username,
email: user.email,
phone: user.phone,
displayName: user.displayName,
roles: roleCodes,
},
...this.sessions.publicSession(record),
};
}
async unlock(token: string, userId: string, password: string) {
await this.users.verifyCurrentPassword(userId, password);
return this.sessions.unlock(token);
}
async reauthenticate(token: string, userId: string, password: string) {
await this.users.verifyCurrentPassword(userId, password);
return this.sessions.markReauthenticated(token);
}
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 (answer !== captchaText?.trim()) {
throw new BadRequestException('Captcha is incorrect');
}
}
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',
);
}
}
}