feat: implement complete admin authentication system
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 9s
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:
parent
e5c6113b25
commit
5a7c21dd60
@ -17,7 +17,11 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": {
|
"dependencies": {
|
||||||
"@bull-board/nestjs": "^7.0.0",
|
"@bull-board/nestjs": "^7.0.0",
|
||||||
|
|||||||
47
prisma/seed.ts
Normal file
47
prisma/seed.ts
Normal 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());
|
||||||
@ -12,6 +12,7 @@ import { LoggerModule } from './infrastructure/logger/logger.module';
|
|||||||
|
|
||||||
import { SystemModule } from './modules/system/system.module';
|
import { SystemModule } from './modules/system/system.module';
|
||||||
import { AuthModule } from './modules/auth/auth.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 { UsersModule } from './modules/users/users.module';
|
||||||
import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module';
|
import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module';
|
||||||
import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module';
|
import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module';
|
||||||
@ -80,6 +81,7 @@ import appleConfig from './config/apple.config';
|
|||||||
LoggerModule,
|
LoggerModule,
|
||||||
SystemModule,
|
SystemModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
AdminAuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
KnowledgeBaseModule,
|
KnowledgeBaseModule,
|
||||||
KnowledgeItemsModule,
|
KnowledgeItemsModule,
|
||||||
|
|||||||
4
src/common/decorators/admin-public.decorator.ts
Normal file
4
src/common/decorators/admin-public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ADMIN_PUBLIC_KEY = 'adminPublic';
|
||||||
|
export const AdminPublic = () => SetMetadata(ADMIN_PUBLIC_KEY, true);
|
||||||
5
src/common/decorators/admin-roles.decorator.ts
Normal file
5
src/common/decorators/admin-roles.decorator.ts
Normal 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);
|
||||||
@ -27,3 +27,7 @@ export const AiAnalysisRateLimit = () =>
|
|||||||
/** 文件上传:单用户每小时 10 次 */
|
/** 文件上传:单用户每小时 10 次 */
|
||||||
export const FileUploadRateLimit = () =>
|
export const FileUploadRateLimit = () =>
|
||||||
RateLimit({ key: 'upload', maxRequests: 10, windowSeconds: 3600 });
|
RateLimit({ key: 'upload', maxRequests: 10, windowSeconds: 3600 });
|
||||||
|
|
||||||
|
/** 管理员登录:单 IP 每 15 分钟 10 次 */
|
||||||
|
export const AdminLoginRateLimit = () =>
|
||||||
|
RateLimit({ key: 'admin-login', maxRequests: 10, windowSeconds: 900, byIp: true });
|
||||||
|
|||||||
88
src/common/guards/admin-auth.guard.ts
Normal file
88
src/common/guards/admin-auth.guard.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/common/guards/admin-roles.guard.ts
Normal file
35
src/common/guards/admin-roles.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,12 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
if (isPublic) return true;
|
if (isPublic) return true;
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
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);
|
const token = this.extractToken(request);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
41
src/common/types/admin-role.enum.ts
Normal file
41
src/common/types/admin-role.enum.ts
Normal 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);
|
||||||
|
}
|
||||||
15
src/common/utils/password.service.ts
Normal file
15
src/common/utils/password.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { registerAs } from '@nestjs/config';
|
|||||||
export default registerAs('jwt', () => {
|
export default registerAs('jwt', () => {
|
||||||
const accessSecret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET;
|
const accessSecret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET;
|
||||||
const refreshSecret = process.env.JWT_REFRESH_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 (
|
if (
|
||||||
!accessSecret ||
|
!accessSecret ||
|
||||||
@ -23,7 +24,11 @@ export default registerAs('jwt', () => {
|
|||||||
secret: accessSecret || 'change_me_in_production',
|
secret: accessSecret || 'change_me_in_production',
|
||||||
accessSecret: accessSecret || 'change_me_in_production',
|
accessSecret: accessSecret || 'change_me_in_production',
|
||||||
refreshSecret: refreshSecret || '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',
|
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
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',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,7 +12,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
|
|
||||||
app.setGlobalPrefix('api', { exclude: ['health'] });
|
app.setGlobalPrefix('api', { exclude: ['health', 'admin-api/(.*)', 'internal/(.*)'] });
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: isProduction
|
origin: isProduction
|
||||||
@ -34,6 +34,7 @@ async function bootstrap() {
|
|||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.addTag('health', '服务健康检查')
|
.addTag('health', '服务健康检查')
|
||||||
.addTag('auth', '用户认证')
|
.addTag('auth', '用户认证')
|
||||||
|
.addTag('admin-auth', '管理员认证')
|
||||||
.addTag('users', '用户管理')
|
.addTag('users', '用户管理')
|
||||||
.addTag('knowledge-base', '知识库')
|
.addTag('knowledge-base', '知识库')
|
||||||
.addTag('knowledge-items', '知识点')
|
.addTag('knowledge-items', '知识点')
|
||||||
|
|||||||
33
src/modules/admin-auth/admin-audit.service.ts
Normal file
33
src/modules/admin-auth/admin-audit.service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/modules/admin-auth/admin-auth.controller.ts
Normal file
58
src/modules/admin-auth/admin-auth.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/modules/admin-auth/admin-auth.module.ts
Normal file
30
src/modules/admin-auth/admin-auth.module.ts
Normal 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 {}
|
||||||
204
src/modules/admin-auth/admin-auth.service.ts
Normal file
204
src/modules/admin-auth/admin-auth.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/modules/admin-auth/admin-token.service.ts
Normal file
33
src/modules/admin-auth/admin-token.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/modules/admin-auth/dto/admin-login.dto.ts
Normal file
15
src/modules/admin-auth/dto/admin-login.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
8
src/modules/admin-auth/dto/admin-refresh.dto.ts
Normal file
8
src/modules/admin-auth/dto/admin-refresh.dto.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class AdminRefreshDto {
|
||||||
|
@ApiProperty({ description: '刷新令牌' })
|
||||||
|
@IsString()
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
2
src/modules/admin-auth/dto/index.ts
Normal file
2
src/modules/admin-auth/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { AdminLoginDto } from './admin-login.dto';
|
||||||
|
export { AdminRefreshDto } from './admin-refresh.dto';
|
||||||
Loading…
x
Reference in New Issue
Block a user