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?
|
deletedAt DateTime?
|
||||||
|
|
||||||
sessions AdminSession[]
|
sessions AdminSession[]
|
||||||
|
apiKeys AdminApiKey[]
|
||||||
conversations AdminConversation[]
|
conversations AdminConversation[]
|
||||||
auditLogs AdminAuditLog[]
|
auditLogs AdminAuditLog[]
|
||||||
|
|
||||||
@ -1037,6 +1038,24 @@ model AdminSession {
|
|||||||
@@index([refreshTokenHash])
|
@@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 {
|
model AdminAuditLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
adminUserId String
|
adminUserId String
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function sha256(input: string): string {
|
||||||
|
return crypto.createHash('sha256').update(input).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const email = process.env.SUPER_ADMIN_EMAIL;
|
const email = process.env.SUPER_ADMIN_EMAIL;
|
||||||
const password = process.env.SUPER_ADMIN_PASSWORD;
|
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) {
|
if (!email || !password) {
|
||||||
console.error('❌ 请设置环境变量 SUPER_ADMIN_EMAIL 和 SUPER_ADMIN_PASSWORD');
|
console.error('❌ 请设置环境变量 SUPER_ADMIN_EMAIL 和 SUPER_ADMIN_PASSWORD');
|
||||||
@ -37,6 +44,68 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ 超级管理员已创建/更新: ${adminUser.email} (id: ${adminUser.id})`);
|
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()
|
main()
|
||||||
|
|||||||
@ -10,6 +10,11 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { ADMIN_PUBLIC_KEY } from '../decorators/admin-public.decorator';
|
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()
|
@Injectable()
|
||||||
export class AdminAuthGuard implements CanActivate {
|
export class AdminAuthGuard implements CanActivate {
|
||||||
@ -28,11 +33,22 @@ export class AdminAuthGuard implements CanActivate {
|
|||||||
if (isPublic) return true;
|
if (isPublic) return true;
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
|
||||||
|
// Try JWT Bearer token first, then x-api-key
|
||||||
const token = this.extractToken(request);
|
const token = this.extractToken(request);
|
||||||
if (!token) {
|
if (token) {
|
||||||
throw new UnauthorizedException('请先登录');
|
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 {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: this.configService.get<string>('jwt.adminSecret'),
|
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 {
|
private extractToken(request: Request): string | undefined {
|
||||||
const authHeader = request.headers.authorization;
|
const authHeader = request.headers.authorization;
|
||||||
if (!authHeader?.startsWith('Bearer ')) return undefined;
|
if (!authHeader?.startsWith('Bearer ')) return undefined;
|
||||||
return authHeader.split(' ')[1];
|
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