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
@@ -0,0 +1,10 @@
ALTER TABLE "User"
ADD COLUMN "email" TEXT,
ADD COLUMN "phone" TEXT,
ADD COLUMN "failedLoginCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "lockedUntil" TIMESTAMP(3),
ADD COLUMN "lastLoginAt" TIMESTAMP(3),
ADD COLUMN "deletedAt" TIMESTAMP(3);
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
+6
View File
@@ -65,9 +65,15 @@ model User {
id String @id @default(cuid())
tenantId String?
username String @unique
email String? @unique
phone String? @unique
displayName String
passwordHash String
status String @default("active")
failedLoginCount Int @default(0)
lockedUntil DateTime?
lastLoginAt DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+20 -5
View File
@@ -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');
}
}
+62
View File
@@ -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);
});
});
+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,
});
}
}
+70 -12
View File
@@ -1,56 +1,114 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import {
AssignPermissionDto,
AssignRoleDto,
ChangePasswordDto,
ChangeUserStatusDto,
CreatePermissionDto,
CreateRoleDto,
CreateUserDto,
UpdateUserDto,
UsersService,
} from './users.service';
@ApiTags('users')
@Controller('admin/users')
@Controller()
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
list(@TenantId() tenantId?: string) {
return this.users.list(tenantId);
@Get('admin/users')
list(@Query('tenantId') tenantId?: string, @Query('roleCode') roleCode?: string) {
return this.users.list(tenantId, roleCode);
}
@Post()
@Post('admin/users')
create(@Body() body: CreateUserDto) {
return this.users.create(body);
}
@Get('roles')
@Put('admin/users/:id')
update(@Param('id') id: string, @Body() body: UpdateUserDto) {
return this.users.update(id, body);
}
@Patch('admin/users/:id')
patch(@Param('id') id: string, @Body() body: UpdateUserDto) {
return this.users.update(id, body);
}
@Post('admin/users/:id/status')
changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto) {
return this.users.changeStatus(id, body);
}
@Post('admin/users/:id/password')
changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto) {
return this.users.changePassword(id, body);
}
@Delete('admin/users/:id')
remove(@Param('id') id: string, @Body('operatorId') operatorId?: string) {
return this.users.remove(id, operatorId);
}
@Get('client/users')
listClient(@TenantId() tenantId?: string) {
return this.users.listClientUsers(tenantId);
}
@Post('client/users')
createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto) {
return this.users.create({ ...body, roleCode: 'enterprise_admin' }, tenantId);
}
@Put('client/users/:id')
updateClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: UpdateUserDto) {
return this.users.update(id, { ...body, roleCode: 'enterprise_admin' }, tenantId);
}
@Post('client/users/:id/status')
changeClientStatus(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangeUserStatusDto) {
return this.users.changeStatus(id, body, tenantId);
}
@Post('client/users/:id/password')
changeClientPassword(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangePasswordDto) {
return this.users.changePassword(id, body, tenantId);
}
@Delete('client/users/:id')
removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body('operatorId') operatorId?: string) {
return this.users.remove(id, operatorId, tenantId);
}
@Get('admin/users/roles')
listRoles() {
return this.users.listRoles();
}
@Post('roles')
@Post('admin/users/roles')
createRole(@Body() body: CreateRoleDto) {
return this.users.createRole(body);
}
@Get('permissions')
@Get('admin/users/permissions')
listPermissions() {
return this.users.listPermissions();
}
@Post('permissions')
@Post('admin/users/permissions')
createPermission(@Body() body: CreatePermissionDto) {
return this.users.createPermission(body);
}
@Post('roles/assign')
@Post('admin/users/roles/assign')
assignRole(@Body() body: AssignRoleDto) {
return this.users.assignRole(body);
}
@Post('permissions/assign')
@Post('admin/users/permissions/assign')
assignPermission(@Body() body: AssignPermissionDto) {
return this.users.assignPermission(body);
}
+87
View File
@@ -0,0 +1,87 @@
import { BadRequestException } from '@nestjs/common';
import { UsersService } from './users.service';
function createPrismaMock() {
const roles = new Map<string, { id: string; code: string; name: string; scope: string }>();
return {
user: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'user-1', ...data, tenant: null, roles: [{ role: roles.get('platform_admin') ?? roles.get('enterprise_admin') }] })),
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
role: {
upsert: jest.fn().mockImplementation(({ where, create, update }) => {
const role = { id: `role-${where.code}`, ...create, ...update };
roles.set(where.code, role);
return Promise.resolve(role);
}),
findMany: jest.fn(),
create: jest.fn(),
},
userRole: {
upsert: jest.fn(),
deleteMany: jest.fn(),
create: jest.fn(),
},
permission: { findMany: jest.fn(), create: jest.fn() },
rolePermission: { upsert: jest.fn() },
operationLog: { create: jest.fn() },
$transaction: jest.fn((callback) => callback({
user: {
update: jest.fn().mockResolvedValue({ id: 'user-1', username: 'u', roles: [] }),
},
userRole: {
deleteMany: jest.fn(),
create: jest.fn(),
},
})),
};
}
describe('UsersService', () => {
it('requires enterprise admins to be linked to a tenant', async () => {
const service = new UsersService(createPrismaMock() as never);
await expect(service.create({
displayName: '企业管理员',
email: 'tenant@example.com',
password: 'secret1',
roleCode: 'enterprise_admin',
})).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects platform admins linked to tenants', async () => {
const service = new UsersService(createPrismaMock() as never);
await expect(service.create({
tenantId: 'tenant-1',
displayName: '平台管理员',
email: 'admin@example.com',
password: 'secret1',
roleCode: 'platform_admin',
})).rejects.toThrow('platform_admin must not be linked to a tenant');
});
it('creates platform admins and writes operation logs', async () => {
const prisma = createPrismaMock();
const service = new UsersService(prisma as never);
await service.create({
displayName: '平台管理员',
email: 'admin@example.com',
phone: '13800000000',
password: 'secret1',
roleCode: 'platform_admin',
operatorId: 'operator-1',
});
expect(prisma.user.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
email: 'admin@example.com',
phone: '13800000000',
status: 'active',
}),
}));
expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ action: 'user.created', resource: 'user' }),
}));
});
});
+227 -12
View File
@@ -1,13 +1,41 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type UserRoleCode = 'platform_admin' | 'enterprise_admin';
export interface CreateUserDto {
tenantId?: string;
username: string;
username?: string;
email?: string;
phone?: string;
displayName: string;
password: string;
status?: string;
roleCode: UserRoleCode;
operatorId?: string;
}
export interface UpdateUserDto {
tenantId?: string | null;
username?: string;
email?: string | null;
phone?: string | null;
displayName?: string;
status?: string;
roleCode?: UserRoleCode;
operatorId?: string;
}
export interface ChangeUserStatusDto {
status: 'active' | 'disabled';
operatorId?: string;
}
export interface ChangePasswordDto {
password: string;
operatorId?: string;
}
export interface CreateRoleDto {
@@ -33,31 +61,159 @@ export interface AssignPermissionDto {
permissionId: string;
}
const roleNames: Record<UserRoleCode, { name: string; scope: string }> = {
platform_admin: { name: '平台管理员', scope: 'platform' },
enterprise_admin: { name: '企业管理员', scope: 'tenant' },
};
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
list(tenantId?: string) {
list(tenantId?: string, roleCode?: string) {
return this.prisma.user.findMany({
where: tenantId ? { tenantId } : undefined,
include: { roles: { include: { role: true } } },
where: {
deletedAt: null,
...(tenantId ? { tenantId } : {}),
...(roleCode ? { roles: { some: { role: { code: roleCode } } } } : {}),
},
include: { tenant: true, roles: { include: { role: true } } },
orderBy: { createdAt: 'desc' },
take: 100,
take: 200,
});
}
findByUsername(username: string) {
return this.prisma.user.findUnique({ where: { username } });
listClientUsers(tenantId?: string) {
if (!tenantId) {
throw new BadRequestException('tenantId is required for client user management');
}
return this.list(tenantId);
}
create(data: CreateUserDto) {
return this.prisma.user.create({
findByUsername(username: string) {
return this.prisma.user.findUnique({ where: { username }, include: { tenant: true, roles: { include: { role: true } } } });
}
findByLogin(login: string) {
return this.prisma.user.findFirst({
where: {
deletedAt: null,
OR: [{ username: login }, { email: login }, { phone: login }],
},
include: { tenant: true, roles: { include: { role: true } } },
});
}
async create(data: CreateUserDto, scopeTenantId?: string) {
const roleCode = data.roleCode;
this.assertUserInput({ ...data, roleCode }, scopeTenantId, true);
const tenantId = scopeTenantId ?? data.tenantId;
const role = await this.ensureRole(roleCode);
const user = await this.prisma.user.create({
data: {
tenantId: data.tenantId,
username: data.username,
tenantId,
username: data.username ?? data.email ?? data.phone ?? '',
email: normalizeOptional(data.email),
phone: normalizeOptional(data.phone),
displayName: data.displayName,
passwordHash: hashPassword(data.password),
status: data.status ?? 'active',
roles: { create: [{ roleId: role.id }] },
},
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(tenantId, data.operatorId, 'user.created', user.id, { roleCode, username: user.username });
return user;
}
async update(id: string, data: UpdateUserDto, scopeTenantId?: string) {
const current = await this.getExisting(id, scopeTenantId);
const roleCode = data.roleCode ?? current.roles[0]?.role.code as UserRoleCode | undefined;
this.assertUserInput({
tenantId: data.tenantId ?? current.tenantId ?? undefined,
username: data.username ?? current.username,
email: data.email === undefined ? current.email ?? undefined : data.email ?? undefined,
phone: data.phone === undefined ? current.phone ?? undefined : data.phone ?? undefined,
displayName: data.displayName ?? current.displayName,
password: 'unchanged-password',
roleCode: roleCode ?? 'enterprise_admin',
}, scopeTenantId, false);
const tenantId = scopeTenantId ?? data.tenantId ?? current.tenantId;
const role = roleCode ? await this.ensureRole(roleCode) : null;
const updated = await this.prisma.$transaction(async (tx) => {
if (role) {
await tx.userRole.deleteMany({ where: { userId: id } });
await tx.userRole.create({ data: { userId: id, roleId: role.id } });
}
return tx.user.update({
where: { id },
data: {
tenantId,
username: data.username ?? current.username,
email: data.email === undefined ? undefined : normalizeOptional(data.email),
phone: data.phone === undefined ? undefined : normalizeOptional(data.phone),
displayName: data.displayName ?? current.displayName,
status: data.status ?? current.status,
},
include: { tenant: true, roles: { include: { role: true } } },
});
});
await this.writeLog(tenantId, data.operatorId, 'user.updated', id, { roleCode, username: updated.username });
return updated;
}
async changeStatus(id: string, data: ChangeUserStatusDto, scopeTenantId?: string) {
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { status: data.status },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username });
return updated;
}
async changePassword(id: string, data: ChangePasswordDto, scopeTenantId?: string) {
if (!data.password || data.password.length < 6) {
throw new BadRequestException('password must be at least 6 characters');
}
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
return updated;
}
async remove(id: string, operatorId?: string, scopeTenantId?: string) {
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { status: 'deleted', deletedAt: new Date() },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username });
return updated;
}
async recordLoginSuccess(id: string) {
return this.prisma.user.update({
where: { id },
data: { failedLoginCount: 0, lockedUntil: null, lastLoginAt: new Date() },
});
}
async recordLoginFailure(id: string) {
const current = await this.prisma.user.findUnique({ where: { id } });
if (!current) return null;
const failedLoginCount = current.failedLoginCount + 1;
return this.prisma.user.update({
where: { id },
data: {
failedLoginCount,
lockedUntil: failedLoginCount >= 5 ? new Date(Date.now() + 24 * 60 * 60 * 1000) : current.lockedUntil,
},
});
}
@@ -104,6 +260,65 @@ export class UsersService {
create: data,
});
}
private async ensureRole(code: UserRoleCode) {
const info = roleNames[code];
return this.prisma.role.upsert({
where: { code },
update: { name: info.name, scope: info.scope },
create: { code, name: info.name, scope: info.scope },
});
}
private async getExisting(id: string, scopeTenantId?: string) {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null, ...(scopeTenantId ? { tenantId: scopeTenantId } : {}) },
include: { tenant: true, roles: { include: { role: true } } },
});
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
private assertUserInput(data: CreateUserDto, scopeTenantId: string | undefined, requirePassword: boolean) {
if (!data.displayName?.trim()) {
throw new BadRequestException('displayName is required');
}
if (requirePassword && (!data.password || data.password.length < 6)) {
throw new BadRequestException('password must be at least 6 characters');
}
if (!data.email && !data.phone) {
throw new BadRequestException('email or phone is required');
}
if (data.roleCode === 'platform_admin' && (scopeTenantId || data.tenantId)) {
throw new BadRequestException('platform_admin must not be linked to a tenant');
}
if (data.roleCode === 'enterprise_admin' && !(scopeTenantId || data.tenantId)) {
throw new BadRequestException('enterprise_admin must be linked to a tenant');
}
if (scopeTenantId && data.roleCode === 'platform_admin') {
throw new BadRequestException('client user management cannot create platform admins');
}
}
private writeLog(tenantId: string | null | undefined, userId: string | undefined, action: string, resourceId: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({
data: {
tenantId: tenantId ?? undefined,
userId,
action,
resource: 'user',
resourceId,
detail: detail as Prisma.InputJsonObject,
},
});
}
}
function normalizeOptional(value?: string | null) {
const next = value?.trim();
return next ? next : null;
}
export function hashPassword(password: string) {