feat: add AdminApiKey permanent token for automated testing
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 50s
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:
parent
c3fd6a221f
commit
ed2dcb02f6
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) {
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user