feat: complete login and user management flow

This commit is contained in:
hectorzhao
2026-07-02 16:28:48 +08:00
parent 9c639d54b9
commit e26d8324c3
22 changed files with 1320 additions and 300 deletions
+98 -6
View File
@@ -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,
});
}
}