feat: implement complete admin authentication system
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 9s

- Add AdminRole enum (SUPER_ADMIN/ADMIN/OPERATIONS/DEVELOPER/READONLY) with hierarchy
- Add PasswordService (bcryptjs, 12 rounds), AdminTokenService (type=admin JWT)
- Add AdminAuthService: login/lockout/refresh/logout with audit logging
- Add AdminAuthController: /admin-api/auth/{login,refresh,logout,me}
- Add AdminAuthGuard: validates type=admin, user status, session, lockout
- Add AdminRolesGuard + @AdminRoles() decorator for RBAC
- Add AdminAuditService for audit log persistence
- Add AdminLoginRateLimit (10 req/15min per IP)
- Add prisma/seed.ts for SUPER_ADMIN initialization via env vars
- Update JwtAuthGuard to skip /admin-api/* and /internal/* paths
- Update main.ts to exclude admin-api/internal from global 'api' prefix
- Update jwt.config.ts with admin JWT secrets and expiry config

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-21 15:05:31 +08:00
parent e5c6113b25
commit 5a7c21dd60
21 changed files with 642 additions and 2 deletions

View File

@ -17,7 +17,11 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed": "npx ts-node --compiler-options '{\"module\":\"commonjs\"}' prisma/seed.ts"
},
"prisma": {
"seed": "npx ts-node --compiler-options '{\"module\":\"commonjs\"}' prisma/seed.ts"
},
"dependencies": {
"@bull-board/nestjs": "^7.0.0",

47
prisma/seed.ts Normal file
View File

@ -0,0 +1,47 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
const email = process.env.SUPER_ADMIN_EMAIL;
const password = process.env.SUPER_ADMIN_PASSWORD;
if (!email || !password) {
console.error('❌ 请设置环境变量 SUPER_ADMIN_EMAIL 和 SUPER_ADMIN_PASSWORD');
process.exit(1);
}
if (password.length < 8) {
console.error('❌ SUPER_ADMIN_PASSWORD 长度不能少于 8 位');
process.exit(1);
}
const passwordHash = await bcrypt.hash(password, 12);
const adminUser = await prisma.adminUser.upsert({
where: { email },
update: {
passwordHash,
role: 'SUPER_ADMIN',
status: 'ACTIVE',
displayName: '超级管理员',
},
create: {
email,
passwordHash,
displayName: '超级管理员',
role: 'SUPER_ADMIN',
status: 'ACTIVE',
},
});
console.log(`✅ 超级管理员已创建/更新: ${adminUser.email} (id: ${adminUser.id})`);
}
main()
.catch((e) => {
console.error('❌ Seed 失败:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@ -12,6 +12,7 @@ import { LoggerModule } from './infrastructure/logger/logger.module';
import { SystemModule } from './modules/system/system.module';
import { AuthModule } from './modules/auth/auth.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { UsersModule } from './modules/users/users.module';
import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module';
import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module';
@ -80,6 +81,7 @@ import appleConfig from './config/apple.config';
LoggerModule,
SystemModule,
AuthModule,
AdminAuthModule,
UsersModule,
KnowledgeBaseModule,
KnowledgeItemsModule,

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ADMIN_PUBLIC_KEY = 'adminPublic';
export const AdminPublic = () => SetMetadata(ADMIN_PUBLIC_KEY, true);

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { AdminRole } from '../types/admin-role.enum';
export const ADMIN_ROLES_KEY = 'admin-roles';
export const AdminRoles = (...roles: AdminRole[]) => SetMetadata(ADMIN_ROLES_KEY, roles);

View File

@ -27,3 +27,7 @@ export const AiAnalysisRateLimit = () =>
/** 文件上传:单用户每小时 10 次 */
export const FileUploadRateLimit = () =>
RateLimit({ key: 'upload', maxRequests: 10, windowSeconds: 3600 });
/** 管理员登录:单 IP 每 15 分钟 10 次 */
export const AdminLoginRateLimit = () =>
RateLimit({ key: 'admin-login', maxRequests: 10, windowSeconds: 900, byIp: true });

View File

@ -0,0 +1,88 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { Request } from 'express';
import { ADMIN_PUBLIC_KEY } from '../decorators/admin-public.decorator';
@Injectable()
export class AdminAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(ADMIN_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('请先登录');
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('jwt.adminSecret'),
});
if (payload.type !== 'admin') {
throw new UnauthorizedException('无效的管理员令牌');
}
const adminUser = await this.prisma.adminUser.findUnique({
where: { id: payload.sub },
});
if (!adminUser || adminUser.deletedAt) {
throw new UnauthorizedException('管理员账号不存在');
}
if (adminUser.status !== 'ACTIVE') {
throw new UnauthorizedException('管理员账号已被禁用');
}
if (adminUser.lockedUntil && new Date(adminUser.lockedUntil) > new Date()) {
throw new UnauthorizedException('管理员账号已被锁定,请稍后再试');
}
if (payload.sessionId) {
const session = await this.prisma.adminSession.findUnique({
where: { id: payload.sessionId },
});
if (!session || session.revokedAt) {
throw new UnauthorizedException('会话已失效');
}
if (new Date(session.expiresAt) < new Date()) {
throw new UnauthorizedException('会话已过期,请重新登录');
}
}
(request as any).adminUser = adminUser;
return true;
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
throw new UnauthorizedException('登录已过期,请重新登录');
}
}
private extractToken(request: Request): string | undefined {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) return undefined;
return authHeader.split(' ')[1];
}
}

View File

@ -0,0 +1,35 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ADMIN_ROLES_KEY } from '../decorators/admin-roles.decorator';
import { AdminRole, hasAdminRole } from '../types/admin-role.enum';
import type { AdminUser } from '@prisma/client';
@Injectable()
export class AdminRolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<AdminRole[]>(ADMIN_ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const adminUser = (request as any).adminUser as AdminUser | undefined;
if (!adminUser) {
throw new ForbiddenException('请先登录');
}
const hasRequiredRole = requiredRoles.some((role) => hasAdminRole(adminUser.role, role));
if (!hasRequiredRole) {
throw new ForbiddenException('权限不足');
}
return true;
}
}

View File

@ -26,6 +26,12 @@ export class JwtAuthGuard implements CanActivate {
if (isPublic) return true;
const request = context.switchToHttp().getRequest<Request>();
// Admin and internal routes use their own auth guards
if (request.path.startsWith('/admin-api') || request.path.startsWith('/internal')) {
return true;
}
const token = this.extractToken(request);
if (!token) {

View File

@ -0,0 +1,41 @@
export enum AdminRole {
SUPER_ADMIN = 'SUPER_ADMIN',
ADMIN = 'ADMIN',
OPERATIONS = 'OPERATIONS',
DEVELOPER = 'DEVELOPER',
READONLY = 'READONLY',
}
export const ADMIN_ROLE_HIERARCHY: Record<AdminRole, AdminRole[]> = {
[AdminRole.SUPER_ADMIN]: [
AdminRole.SUPER_ADMIN,
AdminRole.ADMIN,
AdminRole.OPERATIONS,
AdminRole.DEVELOPER,
AdminRole.READONLY,
],
[AdminRole.ADMIN]: [
AdminRole.ADMIN,
AdminRole.OPERATIONS,
AdminRole.DEVELOPER,
AdminRole.READONLY,
],
[AdminRole.OPERATIONS]: [
AdminRole.OPERATIONS,
AdminRole.READONLY,
],
[AdminRole.DEVELOPER]: [
AdminRole.DEVELOPER,
AdminRole.READONLY,
],
[AdminRole.READONLY]: [
AdminRole.READONLY,
],
};
export function hasAdminRole(userRole: string | undefined, required: AdminRole): boolean {
if (!userRole) return false;
const resolved = ADMIN_ROLE_HIERARCHY[userRole as AdminRole];
if (!resolved) return false;
return resolved.includes(required);
}

View File

@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
@Injectable()
export class PasswordService {
async hash(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async verify(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
}

View File

@ -3,6 +3,7 @@ import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => {
const accessSecret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET;
const refreshSecret = process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET;
const adminSecret = process.env.ADMIN_JWT_ACCESS_SECRET || process.env.JWT_SECRET;
if (
!accessSecret ||
@ -23,7 +24,11 @@ export default registerAs('jwt', () => {
secret: accessSecret || 'change_me_in_production',
accessSecret: accessSecret || 'change_me_in_production',
refreshSecret: refreshSecret || 'change_me_in_production',
adminSecret: adminSecret || 'change_me_in_production',
adminRefreshSecret: process.env.ADMIN_JWT_REFRESH_SECRET || adminSecret || 'change_me_in_production',
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
adminExpiresIn: process.env.ADMIN_JWT_EXPIRES_IN || '1h',
adminRefreshExpiresIn: process.env.ADMIN_JWT_REFRESH_EXPIRES_IN || '7d',
};
});

View File

@ -12,7 +12,7 @@ async function bootstrap() {
app.use(helmet());
app.setGlobalPrefix('api', { exclude: ['health'] });
app.setGlobalPrefix('api', { exclude: ['health', 'admin-api/(.*)', 'internal/(.*)'] });
app.enableCors({
origin: isProduction
@ -34,6 +34,7 @@ async function bootstrap() {
.addBearerAuth()
.addTag('health', '服务健康检查')
.addTag('auth', '用户认证')
.addTag('admin-auth', '管理员认证')
.addTag('users', '用户管理')
.addTag('knowledge-base', '知识库')
.addTag('knowledge-items', '知识点')

View File

@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
export interface AuditLogInput {
adminUserId: string;
action: string;
resourceType?: string;
resourceId?: string;
beforeJson?: any;
afterJson?: any;
ip?: string;
userAgent?: string;
}
@Injectable()
export class AdminAuditService {
constructor(private readonly prisma: PrismaService) {}
async log(input: AuditLogInput) {
return this.prisma.adminAuditLog.create({
data: {
adminUserId: input.adminUserId,
action: input.action,
resourceType: input.resourceType ?? null,
resourceId: input.resourceId ?? null,
beforeJson: input.beforeJson ?? null,
afterJson: input.afterJson ?? null,
ip: input.ip ?? null,
userAgent: input.userAgent ?? null,
},
});
}
}

View File

@ -0,0 +1,58 @@
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Post, Body, Get, HttpCode, HttpStatus, Req, UseGuards } from '@nestjs/common';
import { AdminAuthService } from './admin-auth.service';
import { AdminLoginDto, AdminRefreshDto } from './dto';
import { AdminPublic } from '../../common/decorators/admin-public.decorator';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
import { AdminLoginRateLimit } from '../../common/decorators/rate-limit.decorator';
import type { Request } from 'express';
@ApiTags('admin-auth')
@Controller('admin-api/auth')
@UseGuards(AdminAuthGuard, AdminRolesGuard)
export class AdminAuthController {
constructor(private readonly adminAuthService: AdminAuthService) {}
@AdminPublic()
@Post('login')
@HttpCode(HttpStatus.OK)
@AdminLoginRateLimit()
@ApiOperation({ summary: '管理员登录' })
@ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 401, description: '邮箱或密码错误' })
@ApiResponse({ status: 403, description: '账号已禁用或锁定' })
async login(@Body() dto: AdminLoginDto, @Req() req: Request) {
return this.adminAuthService.login(dto.email, dto.password, req.ip, req.headers['user-agent']);
}
@AdminPublic()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新管理员令牌' })
@ApiResponse({ status: 200, description: '刷新成功' })
@ApiResponse({ status: 401, description: '刷新令牌无效' })
async refresh(@Body() dto: AdminRefreshDto, @Req() req: Request) {
return this.adminAuthService.refresh(dto.refreshToken, req.ip, req.headers['user-agent']);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: '管理员退出登录' })
@ApiResponse({ status: 200, description: '退出成功' })
async logout(@Req() req: Request, @Body() dto: AdminRefreshDto) {
const adminUser = (req as any).adminUser;
await this.adminAuthService.logout(adminUser.id, dto.refreshToken);
return { success: true, message: '已退出登录' };
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: '获取当前管理员信息' })
@ApiResponse({ status: 200, description: '成功' })
async getMe(@Req() req: Request) {
const adminUser = (req as any).adminUser;
return this.adminAuthService.getMe(adminUser.id);
}
}

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { AdminAuthController } from './admin-auth.controller';
import { AdminAuthService } from './admin-auth.service';
import { AdminTokenService } from './admin-token.service';
import { AdminAuditService } from './admin-audit.service';
import { PasswordService } from '../../common/utils/password.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AdminAuthController],
providers: [
AdminAuthService,
AdminTokenService,
AdminAuditService,
PasswordService,
AdminAuthGuard,
AdminRolesGuard,
],
exports: [
AdminAuthService,
AdminTokenService,
AdminAuditService,
AdminAuthGuard,
AdminRolesGuard,
],
})
export class AdminAuthModule {}

View File

@ -0,0 +1,204 @@
import {
Injectable,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { PasswordService } from '../../common/utils/password.service';
import { AdminTokenService } from './admin-token.service';
import { AdminAuditService } from './admin-audit.service';
import type { AdminUser } from '@prisma/client';
const MAX_FAILED_LOGINS = 5;
const LOCK_DURATION_MINUTES = 15;
@Injectable()
export class AdminAuthService {
constructor(
private readonly prisma: PrismaService,
private readonly passwordService: PasswordService,
private readonly tokenService: AdminTokenService,
private readonly auditService: AdminAuditService,
) {}
async login(email: string, password: string, ip?: string, userAgent?: string) {
const adminUser = await this.prisma.adminUser.findUnique({
where: { email },
});
if (!adminUser || adminUser.deletedAt) {
throw new UnauthorizedException('邮箱或密码错误');
}
if (adminUser.status !== 'ACTIVE') {
throw new ForbiddenException('账号已被禁用');
}
if (adminUser.lockedUntil && new Date(adminUser.lockedUntil) > new Date()) {
const remaining = Math.ceil(
(new Date(adminUser.lockedUntil).getTime() - Date.now()) / 60000,
);
throw new ForbiddenException(
`账号已被锁定,请在 ${remaining} 分钟后重试`,
);
}
const valid = await this.passwordService.verify(password, adminUser.passwordHash);
if (!valid) {
const newCount = adminUser.failedLoginCount + 1;
const updates: any = { failedLoginCount: newCount };
if (newCount >= MAX_FAILED_LOGINS) {
updates.lockedUntil = new Date(
Date.now() + LOCK_DURATION_MINUTES * 60 * 1000,
);
}
await this.prisma.adminUser.update({
where: { id: adminUser.id },
data: updates,
});
await this.auditService.log({
adminUserId: adminUser.id,
action: 'LOGIN_FAILED',
ip,
userAgent,
});
throw new UnauthorizedException('邮箱或密码错误');
}
await this.prisma.adminUser.update({
where: { id: adminUser.id },
data: {
failedLoginCount: 0,
lockedUntil: null,
lastLoginAt: new Date(),
lastLoginIp: ip ?? null,
},
});
const { token: refreshToken, hash } = this.tokenService.generateRefreshToken();
const session = await this.prisma.adminSession.create({
data: {
adminUserId: adminUser.id,
refreshTokenHash: hash,
ip: ip ?? null,
userAgent: userAgent ?? null,
expiresAt: new Date(Date.now() + 7 * 86400000),
},
});
const accessToken = await this.tokenService.generateAccessToken(
adminUser,
session.id,
);
await this.auditService.log({
adminUserId: adminUser.id,
action: 'LOGIN',
ip,
userAgent,
});
return {
accessToken,
refreshToken,
adminUser: this.serializeAdminUser(adminUser),
};
}
async refresh(refreshToken: string, ip?: string, userAgent?: string) {
const hash = this.tokenService.hashToken(refreshToken);
const session = await this.prisma.adminSession.findFirst({
where: { refreshTokenHash: hash, revokedAt: null },
include: { adminUser: true },
});
if (!session || new Date(session.expiresAt) < new Date()) {
throw new UnauthorizedException('刷新令牌无效或已过期');
}
if (
session.adminUser.deletedAt ||
session.adminUser.status !== 'ACTIVE'
) {
throw new UnauthorizedException('账号不可用');
}
await this.prisma.adminSession.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
const { token: newRefreshToken, hash: newHash } =
this.tokenService.generateRefreshToken();
const newSession = await this.prisma.adminSession.create({
data: {
adminUserId: session.adminUserId,
refreshTokenHash: newHash,
ip: ip ?? null,
userAgent: userAgent ?? null,
expiresAt: new Date(Date.now() + 7 * 86400000),
},
});
const accessToken = await this.tokenService.generateAccessToken(
session.adminUser,
newSession.id,
);
return {
accessToken,
refreshToken: newRefreshToken,
adminUser: this.serializeAdminUser(session.adminUser),
};
}
async logout(adminUserId: string, refreshToken: string) {
const hash = this.tokenService.hashToken(refreshToken);
const session = await this.prisma.adminSession.findFirst({
where: {
refreshTokenHash: hash,
adminUserId,
revokedAt: null,
},
});
if (session) {
await this.prisma.adminSession.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
}
}
async getMe(adminUserId: string) {
const adminUser = await this.prisma.adminUser.findUnique({
where: { id: adminUserId },
});
if (!adminUser || adminUser.deletedAt) {
throw new UnauthorizedException('管理员账号不存在');
}
return this.serializeAdminUser(adminUser);
}
private serializeAdminUser(adminUser: AdminUser) {
return {
id: adminUser.id,
email: adminUser.email,
displayName: adminUser.displayName,
role: adminUser.role,
status: adminUser.status,
twoFactorEnabled: adminUser.twoFactorEnabled,
lastLoginAt: adminUser.lastLoginAt,
lastLoginIp: adminUser.lastLoginIp,
createdAt: adminUser.createdAt,
};
}
}

View File

@ -0,0 +1,33 @@
import * as crypto from 'crypto';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import type { AdminUser } from '@prisma/client';
@Injectable()
export class AdminTokenService {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async generateAccessToken(adminUser: AdminUser, sessionId: string): Promise<string> {
const secret = this.configService.get<string>('jwt.adminSecret')!;
const expiresIn = this.configService.get<string>('jwt.adminExpiresIn') || '1h';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return this.jwtService.signAsync(
{ sub: adminUser.id, type: 'admin', role: adminUser.role, sessionId },
{ secret, expiresIn } as any,
);
}
generateRefreshToken(): { token: string; hash: string } {
const token = crypto.randomBytes(48).toString('hex');
const hash = crypto.createHash('sha256').update(token).digest('hex');
return { token, hash };
}
hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
}

View File

@ -0,0 +1,15 @@
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AdminLoginDto {
@ApiProperty({ description: '管理员邮箱', example: 'admin@longde.cloud' })
@IsEmail()
@MaxLength(255)
email: string;
@ApiProperty({ description: '密码', example: 'Str0ngP@ss!' })
@IsString()
@MinLength(8)
@MaxLength(128)
password: string;
}

View File

@ -0,0 +1,8 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AdminRefreshDto {
@ApiProperty({ description: '刷新令牌' })
@IsString()
refreshToken: string;
}

View File

@ -0,0 +1,2 @@
export { AdminLoginDto } from './admin-login.dto';
export { AdminRefreshDto } from './admin-refresh.dto';