diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f02c426..31cf239 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1013,6 +1013,7 @@ model AdminUser { deletedAt DateTime? sessions AdminSession[] + apiKeys AdminApiKey[] conversations AdminConversation[] auditLogs AdminAuditLog[] @@ -1037,6 +1038,24 @@ model AdminSession { @@index([refreshTokenHash]) } +model AdminApiKey { + id String @id @default(cuid()) + adminUserId String + name String @db.VarChar(100) + keyHash String @unique @db.VarChar(255) + prefix String @db.VarChar(8) + expiresAt DateTime? + lastUsedAt DateTime? + createdBy String? @db.VarChar(100) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + adminUser AdminUser @relation(fields: [adminUserId], references: [id]) + + @@index([adminUserId]) + @@index([keyHash]) +} + model AdminAuditLog { id String @id @default(cuid()) adminUserId String diff --git a/prisma/seed.ts b/prisma/seed.ts index f8b39e0..34d9ca2 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,11 +1,18 @@ import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; +import * as crypto from 'crypto'; const prisma = new PrismaClient(); +function sha256(input: string): string { + return crypto.createHash('sha256').update(input).digest('hex'); +} + async function main() { const email = process.env.SUPER_ADMIN_EMAIL; const password = process.env.SUPER_ADMIN_PASSWORD; + const testEmail = process.env.TEST_ADMIN_EMAIL || 'test-admin@zhixi.com'; + const testPassword = process.env.TEST_ADMIN_PASSWORD || 'test-zhixi-admin-2026'; if (!email || !password) { console.error('❌ 请设置环境变量 SUPER_ADMIN_EMAIL 和 SUPER_ADMIN_PASSWORD'); @@ -37,6 +44,68 @@ async function main() { }); console.log(`✅ 超级管理员已创建/更新: ${adminUser.email} (id: ${adminUser.id})`); + + // ── 测试专用管理员 + 永久 API Key ── + + const testPasswordHash = await bcrypt.hash(testPassword, 12); + + const testAdmin = await prisma.adminUser.upsert({ + where: { email: testEmail }, + update: { + passwordHash: testPasswordHash, + role: 'SUPER_ADMIN', + status: 'ACTIVE', + displayName: '自动化测试', + }, + create: { + email: testEmail, + passwordHash: testPasswordHash, + displayName: '自动化测试', + role: 'SUPER_ADMIN', + status: 'ACTIVE', + }, + }); + + console.log(`✅ 测试管理员已创建/更新: ${testAdmin.email} (id: ${testAdmin.id})`); + + // Generate permanent API key (only if --new-key flag or first time) + const args = process.argv.slice(2); + const forceNew = args.includes('--new-key'); + + const existingKey = !forceNew + ? await prisma.adminApiKey.findFirst({ + where: { adminUserId: testAdmin.id, name: 'auto-test-key', expiresAt: null }, + }) + : null; + + if (existingKey) { + console.log(`ℹ️ 永久 API Key 已存在 (prefix: ${existingKey.prefix}...),跳过生成。使用 --new-key 强制重新生成`); + } else { + const rawKey = `zxat_${crypto.randomBytes(32).toString('hex')}`; + const keyHash = sha256(rawKey); + const prefix = rawKey.slice(0, 8); + + await prisma.adminApiKey.create({ + data: { + adminUserId: testAdmin.id, + name: 'auto-test-key', + keyHash, + prefix, + expiresAt: null, // 永不过期 + createdBy: 'seed', + }, + }); + + console.log(''); + console.log('═══════════════════════════════════════════════════════'); + console.log('🔑 测试管理员永久 API Key(请妥善保存):'); + console.log(` ${rawKey}`); + console.log('═══════════════════════════════════════════════════════'); + console.log(` Email: ${testEmail}`); + console.log(` Password: ${testPassword}`); + console.log(' 使用方式: x-api-key header'); + console.log('═══════════════════════════════════════════════════════'); + } } main() diff --git a/src/common/guards/admin-auth.guard.ts b/src/common/guards/admin-auth.guard.ts index 6b6b15e..7add6e3 100644 --- a/src/common/guards/admin-auth.guard.ts +++ b/src/common/guards/admin-auth.guard.ts @@ -10,6 +10,11 @@ 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'; +import * as crypto from 'crypto'; + +function sha256(input: string): string { + return crypto.createHash('sha256').update(input).digest('hex'); +} @Injectable() export class AdminAuthGuard implements CanActivate { @@ -28,11 +33,22 @@ export class AdminAuthGuard implements CanActivate { if (isPublic) return true; const request = context.switchToHttp().getRequest(); + + // Try JWT Bearer token first, then x-api-key const token = this.extractToken(request); - if (!token) { - throw new UnauthorizedException('请先登录'); + if (token) { + return this.authenticateByJwt(request, token); } + const apiKey = this.extractApiKey(request); + if (apiKey) { + return this.authenticateByApiKey(request, apiKey); + } + + throw new UnauthorizedException('请先登录'); + } + + private async authenticateByJwt(request: Request, token: string): Promise { try { const payload = await this.jwtService.verifyAsync(token, { secret: this.configService.get('jwt.adminSecret'), @@ -80,9 +96,54 @@ export class AdminAuthGuard implements CanActivate { } } + private async authenticateByApiKey(request: Request, rawKey: string): Promise { + const keyHash = sha256(rawKey); + const apiKey = await this.prisma.adminApiKey.findUnique({ where: { keyHash } }); + + if (!apiKey) { + throw new UnauthorizedException('无效的 API Key'); + } + + if (apiKey.expiresAt && new Date(apiKey.expiresAt) < new Date()) { + throw new UnauthorizedException('API Key 已过期'); + } + + const adminUser = await this.prisma.adminUser.findUnique({ + where: { id: apiKey.adminUserId }, + }); + + 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('管理员账号已被锁定'); + } + + // Update lastUsedAt async (don't block the request) + this.prisma.adminApiKey.update({ + where: { id: apiKey.id }, + data: { lastUsedAt: new Date() }, + }).catch(() => {}); + + (request as any).adminUser = adminUser; + (request as any).apiKeyId = apiKey.id; + return true; + } + private extractToken(request: Request): string | undefined { const authHeader = request.headers.authorization; if (!authHeader?.startsWith('Bearer ')) return undefined; return authHeader.split(' ')[1]; } + + private extractApiKey(request: Request): string | undefined { + const header = request.headers['x-api-key']; + if (typeof header === 'string' && header.length > 0) return header; + return undefined; + } }