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);
|
return this.authService.listGuildMembers(token, guildNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('agent/login')
|
||||||
|
agentLogin(@Body() body: { apiKey?: string }) {
|
||||||
|
return this.authService.agentLogin(String(body?.apiKey ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
@Post('introspect')
|
@Post('introspect')
|
||||||
introspect(@Body() body: { token?: string; guildNodeId?: string }) {
|
introspect(@Body() body: { token?: string; guildNodeId?: string }) {
|
||||||
return this.authService.introspectGuildToken(body?.token ?? '', body?.guildNodeId ?? '');
|
return this.authService.introspectGuildToken(body?.token ?? '', body?.guildNodeId ?? '');
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { AuthService } from './auth.service';
|
|||||||
import { User } from '../entities/user.entity';
|
import { User } from '../entities/user.entity';
|
||||||
import { GuildNode } from '../entities/guild-node.entity';
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
import { GuildUser } from '../entities/guild-user.entity';
|
import { GuildUser } from '../entities/guild-user.entity';
|
||||||
|
import { UserApiKey } from '../entities/user-api-key.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser])],
|
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey])],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService],
|
providers: [AuthService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import * as jwt from 'jsonwebtoken';
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { User } from '../entities/user.entity';
|
import { User } from '../entities/user.entity';
|
||||||
import { GuildNode } from '../entities/guild-node.entity';
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
import { GuildUser } from '../entities/guild-user.entity';
|
import { GuildUser } from '../entities/guild-user.entity';
|
||||||
|
import { UserApiKey } from '../entities/user-api-key.entity';
|
||||||
import { RegisterDto } from './dto.register.dto';
|
import { RegisterDto } from './dto.register.dto';
|
||||||
import { LoginDto } from './dto.login.dto';
|
import { LoginDto } from './dto.login.dto';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
@@ -46,9 +48,74 @@ export class AuthService {
|
|||||||
private readonly guildNodeRepo: Repository<GuildNode>,
|
private readonly guildNodeRepo: Repository<GuildNode>,
|
||||||
@InjectRepository(GuildUser)
|
@InjectRepository(GuildUser)
|
||||||
private readonly guildUserRepo: Repository<GuildUser>,
|
private readonly guildUserRepo: Repository<GuildUser>,
|
||||||
|
@InjectRepository(UserApiKey)
|
||||||
|
private readonly apiKeyRepo: Repository<UserApiKey>,
|
||||||
private readonly audit: AuditService,
|
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) {
|
private async getUserGuildsAndTokens(userId: string, email: string) {
|
||||||
const memberships = await this.guildUserRepo.find({
|
const memberships = await this.guildUserRepo.find({
|
||||||
where: { userId, status: 'active' },
|
where: { userId, status: 'active' },
|
||||||
|
|||||||
12
src/cli.ts
12
src/cli.ts
@@ -13,6 +13,7 @@ function getArg(flag: string): string | null {
|
|||||||
function printUsageAndExit(): never {
|
function printUsageAndExit(): never {
|
||||||
console.error('Usage:');
|
console.error('Usage:');
|
||||||
console.error(' node dist/cli.js user create --email <email> --password <password>');
|
console.error(' node dist/cli.js user create --email <email> --password <password>');
|
||||||
|
console.error(' node dist/cli.js user apikey --email <email> [--label <label>]');
|
||||||
console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>');
|
console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,17 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subject === 'user' && action === 'apikey') {
|
||||||
|
const email = getArg('--email');
|
||||||
|
const label = getArg('--label');
|
||||||
|
if (!email) printUsageAndExit();
|
||||||
|
|
||||||
|
const auth = app.get(AuthService);
|
||||||
|
const res = await auth.createUserApiKey(email, label ?? undefined);
|
||||||
|
process.stdout.write(JSON.stringify({ ok: true, ...res }) + '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (subject === 'node' && action === 'register') {
|
if (subject === 'node' && action === 'register') {
|
||||||
const nodeId = getArg('--node-id');
|
const nodeId = getArg('--node-id');
|
||||||
const name = getArg('--name');
|
const name = getArg('--name');
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class CenterApiKeyGuard implements CanActivate {
|
|||||||
path === '/healthz' ||
|
path === '/healthz' ||
|
||||||
path.endsWith('/healthz') ||
|
path.endsWith('/healthz') ||
|
||||||
(method === 'POST' && (path === '/auth/login' || path.endsWith('/auth/login'))) ||
|
(method === 'POST' && (path === '/auth/login' || path.endsWith('/auth/login'))) ||
|
||||||
|
(method === 'POST' && (path === '/auth/agent/login' || path.endsWith('/auth/agent/login'))) ||
|
||||||
(method === 'POST' && (path === '/auth/refresh' || path.endsWith('/auth/refresh'))) ||
|
(method === 'POST' && (path === '/auth/refresh' || path.endsWith('/auth/refresh'))) ||
|
||||||
(method === 'POST' && (path === '/auth/logout' || path.endsWith('/auth/logout'))) ||
|
(method === 'POST' && (path === '/auth/logout' || path.endsWith('/auth/logout'))) ||
|
||||||
(method === 'GET' && (path === '/auth/me' || path.endsWith('/auth/me'))) ||
|
(method === 'GET' && (path === '/auth/me' || path.endsWith('/auth/me'))) ||
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { User } from './entities/user.entity';
|
|||||||
import { GuildNode } from './entities/guild-node.entity';
|
import { GuildNode } from './entities/guild-node.entity';
|
||||||
import { AuditLog } from './entities/audit-log.entity';
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
import { GuildUser } from './entities/guild-user.entity';
|
import { GuildUser } from './entities/guild-user.entity';
|
||||||
|
import { UserApiKey } from './entities/user-api-key.entity';
|
||||||
|
|
||||||
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
@@ -11,7 +12,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
|||||||
username: process.env.FABRIC_BACKEND_CENTER_DB_USER ?? 'fabric',
|
username: process.env.FABRIC_BACKEND_CENTER_DB_USER ?? 'fabric',
|
||||||
password: process.env.FABRIC_BACKEND_CENTER_DB_PASSWORD ?? 'fabric',
|
password: process.env.FABRIC_BACKEND_CENTER_DB_PASSWORD ?? 'fabric',
|
||||||
database: process.env.FABRIC_BACKEND_CENTER_DB_NAME ?? 'fabric_center',
|
database: process.env.FABRIC_BACKEND_CENTER_DB_NAME ?? 'fabric_center',
|
||||||
entities: [User, GuildNode, GuildUser, AuditLog],
|
entities: [User, GuildNode, GuildUser, AuditLog, UserApiKey],
|
||||||
synchronize: (process.env.FABRIC_BACKEND_CENTER_DB_SYNC ?? 'true') === 'true',
|
synchronize: (process.env.FABRIC_BACKEND_CENTER_DB_SYNC ?? 'true') === 'true',
|
||||||
logging: (process.env.FABRIC_BACKEND_CENTER_DB_LOGGING ?? 'false') === 'true',
|
logging: (process.env.FABRIC_BACKEND_CENTER_DB_LOGGING ?? 'false') === 'true',
|
||||||
});
|
});
|
||||||
|
|||||||
22
src/entities/user-api-key.entity.ts
Normal file
22
src/entities/user-api-key.entity.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
// Machine credential for an agent: a long-lived API key that maps to a user.
|
||||||
|
// The plugin exchanges it for a normal user session (POST /auth/agent/login).
|
||||||
|
@Entity('user_api_keys')
|
||||||
|
export class UserApiKey {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'varchar', length: 64 })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'api_key_hash', type: 'varchar', length: 255 })
|
||||||
|
apiKeyHash!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||||
|
label!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user