128 lines
5.0 KiB
TypeScript
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',
|
|
);
|
|
}
|
|
}
|
|
}
|