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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 ?? '');
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<GuildNode>,
|
||||
@InjectRepository(GuildUser)
|
||||
private readonly guildUserRepo: Repository<GuildUser>,
|
||||
@InjectRepository(UserApiKey)
|
||||
private readonly apiKeyRepo: Repository<UserApiKey>,
|
||||
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' },
|
||||
|
||||
Reference in New Issue
Block a user