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: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
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 { 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,
|
||||
|
||||
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 次 */
|
||||
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 });
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
|
||||
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', () => {
|
||||
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',
|
||||
};
|
||||
});
|
||||
|
||||
@ -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', '知识点')
|
||||
|
||||
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