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', ); } } }