From dea946653b319b2ad812810aa9e1c6487b13f7ca Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 15 May 2026 16:52:42 +0100 Subject: [PATCH] feat(center): API-key agent auth UserApiKey (apiKeyHash->userId); CLI 'user apikey --email'; POST /auth/agent/login {apiKey} -> normal user session (api-key-guard exempt). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/auth/auth.controller.ts | 5 +++ src/auth/auth.module.ts | 3 +- src/auth/auth.service.ts | 67 +++++++++++++++++++++++++++++ src/cli.ts | 12 ++++++ src/common/center-api-key.guard.ts | 1 + src/database.config.ts | 3 +- src/entities/user-api-key.entity.ts | 22 ++++++++++ 7 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 src/entities/user-api-key.entity.ts diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 371367f..29878c6 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -63,6 +63,11 @@ export class AuthController { return this.authService.listGuildMembers(token, guildNodeId); } + @Post('agent/login') + agentLogin(@Body() body: { apiKey?: string }) { + return this.authService.agentLogin(String(body?.apiKey ?? '')); + } + @Post('introspect') introspect(@Body() body: { token?: string; guildNodeId?: string }) { return this.authService.introspectGuildToken(body?.token ?? '', body?.guildNodeId ?? ''); diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 6c84984..5527f47 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,9 +5,10 @@ import { AuthService } from './auth.service'; import { User } from '../entities/user.entity'; import { GuildNode } from '../entities/guild-node.entity'; import { GuildUser } from '../entities/guild-user.entity'; +import { UserApiKey } from '../entities/user-api-key.entity'; @Module({ - imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser])], + imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey])], controllers: [AuthController], providers: [AuthService], }) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 0674c49..655299f 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -7,9 +7,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as bcrypt from 'bcryptjs'; import * as jwt from 'jsonwebtoken'; +import { randomBytes } from 'crypto'; import { User } from '../entities/user.entity'; import { GuildNode } from '../entities/guild-node.entity'; import { GuildUser } from '../entities/guild-user.entity'; +import { UserApiKey } from '../entities/user-api-key.entity'; import { RegisterDto } from './dto.register.dto'; import { LoginDto } from './dto.login.dto'; import { AuditService } from '../audit/audit.service'; @@ -46,9 +48,74 @@ export class AuthService { private readonly guildNodeRepo: Repository, @InjectRepository(GuildUser) private readonly guildUserRepo: Repository, + @InjectRepository(UserApiKey) + private readonly apiKeyRepo: Repository, private readonly audit: AuditService, ) {} + // Mint a long-lived API key for a user (agent machine credential). + async createUserApiKey(email: string, label?: string) { + const user = await this.userRepo.findOne({ where: { email } }); + if (!user) throw new UnauthorizedException('user not found'); + const raw = `fak_${randomBytes(24).toString('hex')}`; + await this.apiKeyRepo.save( + this.apiKeyRepo.create({ + userId: user.id, + apiKeyHash: await bcrypt.hash(raw, 10), + label: label ?? null, + }), + ); + await this.audit.write({ + action: 'auth.apikey.create', + actorId: user.id, + targetType: 'user', + targetId: user.id, + detail: JSON.stringify({ label: label ?? null }), + }); + return { userId: user.id, email: user.email, apiKey: raw }; + } + + // Exchange an agent API key for a normal user session (same shape as login). + async agentLogin(apiKey: string) { + if (!apiKey) throw new UnauthorizedException('missing api key'); + const keys = await this.apiKeyRepo.find(); + let userId: string | null = null; + for (const k of keys) { + if (await bcrypt.compare(apiKey, k.apiKeyHash)) { + userId = k.userId; + break; + } + } + if (!userId) throw new UnauthorizedException('invalid api key'); + + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new UnauthorizedException('invalid api key'); + + const accessToken = signAccessToken(user.id, user.email); + const refreshToken = signRefreshToken(user.id, user.email); + user.refreshTokenHash = await bcrypt.hash(refreshToken, 10); + await this.userRepo.save(user); + + await this.audit.write({ + action: 'auth.agent_login', + actorId: user.id, + targetType: 'user', + targetId: user.id, + detail: null, + }); + + const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email); + return { + accessToken, + refreshToken, + tokenType: 'Bearer', + expiresIn: getAccessTokenExpiresInSeconds(), + user: { id: user.id, email: user.email, name: user.name ?? user.email }, + guilds, + guildAccessTokens, + }; + } + private async getUserGuildsAndTokens(userId: string, email: string) { const memberships = await this.guildUserRepo.find({ where: { userId, status: 'active' }, diff --git a/src/cli.ts b/src/cli.ts index 985e1ff..71b57ea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ function getArg(flag: string): string | null { function printUsageAndExit(): never { console.error('Usage:'); console.error(' node dist/cli.js user create --email --password '); + console.error(' node dist/cli.js user apikey --email [--label