feat(guild): validate bearer tokens via center introspection
This commit is contained in:
@@ -10,9 +10,6 @@ DB_NAME=fabric_guild
|
|||||||
DB_SYNC=true
|
DB_SYNC=true
|
||||||
DB_LOGGING=false
|
DB_LOGGING=false
|
||||||
|
|
||||||
# Unified inbound API auth
|
|
||||||
FABRIC_API_KEY=change-me-api-key
|
|
||||||
|
|
||||||
# Guild identity
|
# Guild identity
|
||||||
GUILD_NODE_ID=guild-node-1
|
GUILD_NODE_ID=guild-node-1
|
||||||
GUILD_NODE_NAME=Guild Node 1
|
GUILD_NODE_NAME=Guild Node 1
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query('guildId') guildId?: string) {
|
list(@Query('guildId') guildId?: string) {
|
||||||
if (!guildId) return [];
|
if (!guildId) return this.channelsService.listAll();
|
||||||
return this.channelsService.listByGuild(guildId);
|
return this.channelsService.listByGuild(guildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export class ChannelsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listAll() {
|
||||||
|
return this.channelRepo.find({
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
create(input: Partial<Channel>) {
|
create(input: Partial<Channel>) {
|
||||||
const channel = this.channelRepo.create({
|
const channel = this.channelRepo.create({
|
||||||
guildId: String(input.guildId ?? ''),
|
guildId: String(input.guildId ?? ''),
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import {
|
|||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
ServiceUnavailableException,
|
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { introspectGuildToken } from './center-auth';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiKeyGuard implements CanActivate {
|
export class ApiKeyGuard implements CanActivate {
|
||||||
canActivate(context: ExecutionContext): boolean {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const req = context.switchToHttp().getRequest<{ path?: string; headers: Record<string, string | string[] | undefined> }>();
|
const req = context.switchToHttp().getRequest<{ path?: string; headers: Record<string, string | string[] | undefined> }>();
|
||||||
const path = req.path ?? '';
|
const path = req.path ?? '';
|
||||||
|
|
||||||
@@ -17,17 +17,15 @@ export class ApiKeyGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expected = process.env.FABRIC_API_KEY;
|
const auth = req.headers['authorization'];
|
||||||
if (!expected || expected.trim() === '') {
|
const authValue = Array.isArray(auth) ? auth[0] : auth;
|
||||||
throw new ServiceUnavailableException('FABRIC_API_KEY is not configured');
|
const token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';
|
||||||
}
|
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||||
|
|
||||||
const received = req.headers['x-api-key'];
|
const result = await introspectGuildToken(token);
|
||||||
const receivedValue = Array.isArray(received) ? received[0] : received;
|
if (!result.active || !result.user?.id) throw new UnauthorizedException('invalid guild token');
|
||||||
|
|
||||||
if (!receivedValue || receivedValue !== expected) {
|
(req as { userId?: string }).userId = result.user.id;
|
||||||
throw new UnauthorizedException('invalid api key');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/common/center-auth.ts
Normal file
28
src/common/center-auth.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export async function introspectGuildToken(token: string): Promise<{ active: boolean; user?: { id: string; email?: string } }> {
|
||||||
|
const centerBaseUrl = process.env.CENTER_BASE_URL;
|
||||||
|
const sharedSecret = process.env.CENTER_SHARED_SECRET;
|
||||||
|
const guildNodeId = process.env.GUILD_NODE_ID;
|
||||||
|
|
||||||
|
if (!centerBaseUrl || !sharedSecret || !guildNodeId) {
|
||||||
|
return { active: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${centerBaseUrl}/api/auth/introspect`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-center-shared-secret': sharedSecret,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token, guildNodeId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return { active: false };
|
||||||
|
const data = (await res.json()) as { active?: boolean; user?: { id: string; email?: string } };
|
||||||
|
if (!data.active) return { active: false };
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
user: data.user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { introspectGuildToken } from '../common/center-auth';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
namespace: '/realtime',
|
namespace: '/realtime',
|
||||||
@@ -30,18 +31,24 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
return userId && typeof userId === 'string' && userId.trim() !== '' ? userId : `anon:${client.id}`;
|
return userId && typeof userId === 'string' && userId.trim() !== '' ? userId : `anon:${client.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection(client: Socket): void {
|
async handleConnection(client: Socket): Promise<void> {
|
||||||
const expected = process.env.FABRIC_API_KEY;
|
const authToken = client.handshake.auth?.token;
|
||||||
if (!expected) {
|
const headerAuth = client.handshake.headers['authorization'];
|
||||||
|
const bearer =
|
||||||
|
typeof authToken === 'string'
|
||||||
|
? authToken
|
||||||
|
: typeof headerAuth === 'string' && headerAuth.startsWith('Bearer ')
|
||||||
|
? headerAuth.slice(7)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!bearer) {
|
||||||
|
this.logger.warn(`socket rejected (missing token): ${client.id}`);
|
||||||
client.disconnect(true);
|
client.disconnect(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authKey = client.handshake.auth?.apiKey;
|
const result = await introspectGuildToken(bearer);
|
||||||
const headerKey = client.handshake.headers['x-api-key'];
|
if (!result.active || !result.user?.id) {
|
||||||
const apiKey = typeof authKey === 'string' ? authKey : Array.isArray(headerKey) ? headerKey[0] : headerKey;
|
|
||||||
|
|
||||||
if (apiKey !== expected) {
|
|
||||||
this.logger.warn(`socket rejected: ${client.id}`);
|
this.logger.warn(`socket rejected: ${client.id}`);
|
||||||
client.disconnect(true);
|
client.disconnect(true);
|
||||||
return;
|
return;
|
||||||
@@ -49,7 +56,7 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
|
|
||||||
this.logger.log(`socket connected: ${client.id}`);
|
this.logger.log(`socket connected: ${client.id}`);
|
||||||
|
|
||||||
const userId = this.userIdFromClient(client);
|
const userId = result.user.id || this.userIdFromClient(client);
|
||||||
client.data.userId = userId;
|
client.data.userId = userId;
|
||||||
this.onlineUsers.add(userId);
|
this.onlineUsers.add(userId);
|
||||||
this.server.emit('presence.online', {
|
this.server.emit('presence.online', {
|
||||||
|
|||||||
Reference in New Issue
Block a user