feat: complete login and user management flow
This commit is contained in:
@@ -1,14 +1,29 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuthService, LoginDto } from './auth.service';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('client/auth')
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() body: LoginDto) {
|
||||
return this.auth.login(body);
|
||||
@Get('admin/auth/captcha')
|
||||
adminCaptcha() {
|
||||
return this.auth.createCaptcha();
|
||||
}
|
||||
|
||||
@Post('admin/auth/login')
|
||||
adminLogin(@Body() body: LoginDto) {
|
||||
return this.auth.login(body, 'admin');
|
||||
}
|
||||
|
||||
@Get('client/auth/captcha')
|
||||
clientCaptcha() {
|
||||
return this.auth.createCaptcha();
|
||||
}
|
||||
|
||||
@Post('client/auth/login')
|
||||
clientLogin(@Body() body: LoginDto) {
|
||||
return this.auth.login(body, 'client');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { hashPassword } from '../users/users.service';
|
||||
|
||||
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
tenantId: roleCode === 'enterprise_admin' ? 'tenant-1' : null,
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
phone: '13800000000',
|
||||
displayName: '用户',
|
||||
passwordHash: hashPassword('secret1'),
|
||||
status: 'active',
|
||||
deletedAt: null,
|
||||
lockedUntil: null,
|
||||
tenant: { id: 'tenant-1', name: '企业A' },
|
||||
roles: [{ role: { code: roleCode } }],
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
findByLogin: jest.fn().mockResolvedValue(user),
|
||||
recordLoginSuccess: jest.fn(),
|
||||
recordLoginFailure: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
|
||||
const captcha = service.createCaptcha();
|
||||
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
|
||||
return service.login({
|
||||
login: 'user@example.com',
|
||||
password,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaText: String(answer),
|
||||
}, portal);
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('allows platform admins to login admin portal', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const service = new AuthService(users as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin' }));
|
||||
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('rejects enterprise admins on admin portal', async () => {
|
||||
const users = createUsersMock('enterprise_admin');
|
||||
const service = new AuthService(users as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('locks user after five failed password attempts', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const service = new AuthService(users as never);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
}
|
||||
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
@@ -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