feat: complete login and user management flow
This commit is contained in:
@@ -1,30 +1,122 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { hashPassword, UsersService } from '../users/users.service';
|
||||
|
||||
export interface LoginDto {
|
||||
username: string;
|
||||
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) {}
|
||||
|
||||
async login(data: LoginDto) {
|
||||
const user = await this.users.findByUsername(data.username);
|
||||
if (!user || user.passwordHash !== hashPassword(data.password) || user.status !== 'active') {
|
||||
throw new UnauthorizedException('Invalid username or password');
|
||||
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,
|
||||
});
|
||||
return {
|
||||
captchaId,
|
||||
challenge: `${left} + ${right} = ?`,
|
||||
expiresInSeconds: 300,
|
||||
};
|
||||
}
|
||||
|
||||
async login(data: LoginDto, portal: LoginPortal) {
|
||||
const login = data.login?.trim();
|
||||
if (!login || !data.password) {
|
||||
throw new BadRequestException('login and password are required');
|
||||
}
|
||||
this.verifyCaptcha(data.captchaId, data.captchaText);
|
||||
this.assertAnonymousNotLocked(login);
|
||||
|
||||
const user = await this.users.findByLogin(login);
|
||||
if (!user) {
|
||||
this.recordAnonymousFailure(login);
|
||||
throw new UnauthorizedException('Invalid login or password');
|
||||
}
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
||||
}
|
||||
if (user.status !== 'active' || user.deletedAt) {
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('User is disabled or deleted');
|
||||
}
|
||||
if (user.passwordHash !== hashPassword(data.password)) {
|
||||
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.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.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);
|
||||
anonymousFailures.delete(login);
|
||||
|
||||
return {
|
||||
accessToken: `dev-token-${user.id}`,
|
||||
tokenType: 'Bearer',
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private verifyCaptcha(captchaId?: string, captchaText?: string) {
|
||||
const record = captchaId ? captchaStore.get(captchaId) : undefined;
|
||||
captchaStore.delete(captchaId ?? '');
|
||||
if (!record || record.expiresAt < Date.now()) {
|
||||
throw new BadRequestException('Captcha expired, refresh and try again');
|
||||
}
|
||||
if (record.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()) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user