feat: add AdminApiKey permanent token for automated testing
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 50s

- Add AdminApiKey model (keyHash, expiresAt nullable for permanent)
- Extend AdminAuthGuard to accept x-api-key header as fallback auth
- Seed creates test-admin@zhixi.com with permanent SUPER_ADMIN API key
- Key format: zxat_<64 hex chars>, stored as SHA-256 hash

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-18 18:52:04 +08:00
parent c3fd6a221f
commit ed2dcb02f6
3 changed files with 151 additions and 2 deletions

View File

@ -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

View File

@ -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()

View File

@ -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<Request>();
// Try JWT Bearer token first, then x-api-key
const token = this.extractToken(request);
if (!token) {
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<boolean> {
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('jwt.adminSecret'),
@ -80,9 +96,54 @@ export class AdminAuthGuard implements CanActivate {
}
}
private async authenticateByApiKey(request: Request, rawKey: string): Promise<boolean> {
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;
}
}