diff --git a/package.json b/package.json index f7168e7..5a21324 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..f8b39e0 --- /dev/null +++ b/prisma/seed.ts @@ -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()); diff --git a/src/app.module.ts b/src/app.module.ts index dbef847..5680235 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/common/decorators/admin-public.decorator.ts b/src/common/decorators/admin-public.decorator.ts new file mode 100644 index 0000000..a351120 --- /dev/null +++ b/src/common/decorators/admin-public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ADMIN_PUBLIC_KEY = 'adminPublic'; +export const AdminPublic = () => SetMetadata(ADMIN_PUBLIC_KEY, true); diff --git a/src/common/decorators/admin-roles.decorator.ts b/src/common/decorators/admin-roles.decorator.ts new file mode 100644 index 0000000..76a5bce --- /dev/null +++ b/src/common/decorators/admin-roles.decorator.ts @@ -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); diff --git a/src/common/decorators/rate-limit.decorator.ts b/src/common/decorators/rate-limit.decorator.ts index 187fdfe..a9a23b0 100644 --- a/src/common/decorators/rate-limit.decorator.ts +++ b/src/common/decorators/rate-limit.decorator.ts @@ -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 }); diff --git a/src/common/guards/admin-auth.guard.ts b/src/common/guards/admin-auth.guard.ts new file mode 100644 index 0000000..6b6b15e --- /dev/null +++ b/src/common/guards/admin-auth.guard.ts @@ -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 { + const isPublic = this.reflector.getAllAndOverride(ADMIN_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + if (!token) { + throw new UnauthorizedException('请先登录'); + } + + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: this.configService.get('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]; + } +} diff --git a/src/common/guards/admin-roles.guard.ts b/src/common/guards/admin-roles.guard.ts new file mode 100644 index 0000000..b9307ac --- /dev/null +++ b/src/common/guards/admin-roles.guard.ts @@ -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(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; + } +} diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index 89ff697..9b29cc6 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -26,6 +26,12 @@ export class JwtAuthGuard implements CanActivate { if (isPublic) return true; const request = context.switchToHttp().getRequest(); + + // 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) { diff --git a/src/common/types/admin-role.enum.ts b/src/common/types/admin-role.enum.ts new file mode 100644 index 0000000..7081d09 --- /dev/null +++ b/src/common/types/admin-role.enum.ts @@ -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.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); +} diff --git a/src/common/utils/password.service.ts b/src/common/utils/password.service.ts new file mode 100644 index 0000000..30eae21 --- /dev/null +++ b/src/common/utils/password.service.ts @@ -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 { + return bcrypt.hash(password, SALT_ROUNDS); + } + + async verify(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + } +} diff --git a/src/config/jwt.config.ts b/src/config/jwt.config.ts index 5d28fe6..5ac611c 100644 --- a/src/config/jwt.config.ts +++ b/src/config/jwt.config.ts @@ -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', }; }); diff --git a/src/main.ts b/src/main.ts index 7f9f1c1..0eb285c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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', '知识点') diff --git a/src/modules/admin-auth/admin-audit.service.ts b/src/modules/admin-auth/admin-audit.service.ts new file mode 100644 index 0000000..5a3fb37 --- /dev/null +++ b/src/modules/admin-auth/admin-audit.service.ts @@ -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, + }, + }); + } +} diff --git a/src/modules/admin-auth/admin-auth.controller.ts b/src/modules/admin-auth/admin-auth.controller.ts new file mode 100644 index 0000000..630656c --- /dev/null +++ b/src/modules/admin-auth/admin-auth.controller.ts @@ -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); + } +} diff --git a/src/modules/admin-auth/admin-auth.module.ts b/src/modules/admin-auth/admin-auth.module.ts new file mode 100644 index 0000000..6e2bdc2 --- /dev/null +++ b/src/modules/admin-auth/admin-auth.module.ts @@ -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 {} diff --git a/src/modules/admin-auth/admin-auth.service.ts b/src/modules/admin-auth/admin-auth.service.ts new file mode 100644 index 0000000..2574ee1 --- /dev/null +++ b/src/modules/admin-auth/admin-auth.service.ts @@ -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, + }; + } +} diff --git a/src/modules/admin-auth/admin-token.service.ts b/src/modules/admin-auth/admin-token.service.ts new file mode 100644 index 0000000..ffcef81 --- /dev/null +++ b/src/modules/admin-auth/admin-token.service.ts @@ -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 { + const secret = this.configService.get('jwt.adminSecret')!; + const expiresIn = this.configService.get('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'); + } +} diff --git a/src/modules/admin-auth/dto/admin-login.dto.ts b/src/modules/admin-auth/dto/admin-login.dto.ts new file mode 100644 index 0000000..fd79fdd --- /dev/null +++ b/src/modules/admin-auth/dto/admin-login.dto.ts @@ -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; +} diff --git a/src/modules/admin-auth/dto/admin-refresh.dto.ts b/src/modules/admin-auth/dto/admin-refresh.dto.ts new file mode 100644 index 0000000..983d469 --- /dev/null +++ b/src/modules/admin-auth/dto/admin-refresh.dto.ts @@ -0,0 +1,8 @@ +import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AdminRefreshDto { + @ApiProperty({ description: '刷新令牌' }) + @IsString() + refreshToken: string; +} diff --git a/src/modules/admin-auth/dto/index.ts b/src/modules/admin-auth/dto/index.ts new file mode 100644 index 0000000..b953dac --- /dev/null +++ b/src/modules/admin-auth/dto/index.ts @@ -0,0 +1,2 @@ +export { AdminLoginDto } from './admin-login.dto'; +export { AdminRefreshDto } from './admin-refresh.dto';