Compare commits
5 Commits
81dfc227e3
...
2792f78ada
| Author | SHA1 | Date | |
|---|---|---|---|
| 2792f78ada | |||
| 1eb30348a2 | |||
| ebc3571823 | |||
| 0b32dc8e3c | |||
| 7afd220b4a |
@@ -5,6 +5,7 @@
|
||||
"description": "Fabric Identity Hub (Center service)",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"cli": "node dist/cli.js",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "ts-node src/main.ts",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Body, Controller, Get, Headers, Post, UnauthorizedException } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, Param, Post, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto.register.dto';
|
||||
import { LoginDto } from './dto.login.dto';
|
||||
import { RefreshDto } from './dto.refresh.dto';
|
||||
import { LogoutDto } from './dto.logout.dto';
|
||||
@@ -9,11 +8,6 @@ import { LogoutDto } from './dto.logout.dto';
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() body: RegisterDto) {
|
||||
return this.authService.register(body);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() body: LoginDto) {
|
||||
return this.authService.login(body);
|
||||
@@ -36,6 +30,20 @@ export class AuthController {
|
||||
return this.authService.listMyGuilds(token);
|
||||
}
|
||||
|
||||
@Post('me/guilds/join')
|
||||
joinGuild(@Headers('authorization') authorization: string | undefined, @Body() body: { guildNodeId?: string }) {
|
||||
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
|
||||
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||
return this.authService.joinGuild(token, String(body?.guildNodeId ?? ''));
|
||||
}
|
||||
|
||||
@Get('guilds/:guildNodeId/members')
|
||||
guildMembers(@Headers('authorization') authorization: string | undefined, @Param('guildNodeId') guildNodeId: string) {
|
||||
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
|
||||
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||
return this.authService.listGuildMembers(token, guildNodeId);
|
||||
}
|
||||
|
||||
@Post('introspect')
|
||||
introspect(@Body() body: { token?: string; guildNodeId?: string }) {
|
||||
return this.authService.introspectGuildToken(body?.token ?? '', body?.guildNodeId ?? '');
|
||||
|
||||
@@ -21,6 +21,10 @@ function signAccessToken(userId: string, email: string): string {
|
||||
return jwt.sign({ sub: userId, email }, secret, { expiresIn });
|
||||
}
|
||||
|
||||
function getAccessTokenExpiresInSeconds(): number {
|
||||
return parseDurationToSeconds(process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_EXPIRES_IN ?? '15m', 900);
|
||||
}
|
||||
|
||||
function signRefreshToken(userId: string, email: string): string {
|
||||
const secret = process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET as string;
|
||||
const expiresIn = parseDurationToSeconds(process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_EXPIRES_IN ?? '30d', 2592000);
|
||||
@@ -29,7 +33,7 @@ function signRefreshToken(userId: string, email: string): string {
|
||||
|
||||
function signGuildAccessToken(userId: string, email: string, guildNodeId: string): string {
|
||||
const secret = process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string;
|
||||
const expiresIn = parseDurationToSeconds(process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_EXPIRES_IN ?? '15m', 900);
|
||||
const expiresIn = getAccessTokenExpiresInSeconds();
|
||||
return jwt.sign({ sub: userId, email, gid: guildNodeId, typ: 'guild_access' }, secret, { expiresIn });
|
||||
}
|
||||
|
||||
@@ -46,26 +50,10 @@ export class AuthService {
|
||||
) {}
|
||||
|
||||
private async getUserGuildsAndTokens(userId: string, email: string) {
|
||||
let memberships = await this.guildUserRepo.find({
|
||||
const memberships = await this.guildUserRepo.find({
|
||||
where: { userId, status: 'active' },
|
||||
});
|
||||
|
||||
if (!memberships.length) {
|
||||
const activeNodes = await this.guildNodeRepo.find({ where: { status: 'active' } });
|
||||
if (activeNodes.length) {
|
||||
await this.guildUserRepo.save(
|
||||
activeNodes.map((n) =>
|
||||
this.guildUserRepo.create({
|
||||
userId,
|
||||
guildNodeId: n.nodeId,
|
||||
status: 'active',
|
||||
}),
|
||||
),
|
||||
);
|
||||
memberships = await this.guildUserRepo.find({ where: { userId, status: 'active' } });
|
||||
}
|
||||
}
|
||||
|
||||
const nodeIds = memberships.map((x) => x.guildNodeId);
|
||||
if (!nodeIds.length) {
|
||||
return { guilds: [], guildAccessTokens: [] as Array<{ guildNodeId: string; token: string; tokenType: string }> };
|
||||
@@ -88,6 +76,7 @@ export class AuthService {
|
||||
guildNodeId: g.nodeId,
|
||||
token: signGuildAccessToken(userId, email, g.nodeId),
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: getAccessTokenExpiresInSeconds(),
|
||||
}));
|
||||
|
||||
return { guilds, guildAccessTokens };
|
||||
@@ -148,6 +137,7 @@ export class AuthService {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: getAccessTokenExpiresInSeconds(),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -164,6 +154,50 @@ export class AuthService {
|
||||
return this.getUserGuildsAndTokens(userId, email);
|
||||
}
|
||||
|
||||
async joinGuild(accessToken: string, guildNodeId: string) {
|
||||
const payload = this.verifyCenterAccessToken(accessToken);
|
||||
const userId = String(payload.sub ?? '');
|
||||
if (!userId) throw new UnauthorizedException('invalid access token');
|
||||
|
||||
const node = await this.guildNodeRepo.findOne({ where: { nodeId: guildNodeId, status: 'active' } });
|
||||
if (!node) throw new UnauthorizedException('guild node not found or inactive');
|
||||
|
||||
const existed = await this.guildUserRepo.findOne({ where: { userId, guildNodeId } });
|
||||
if (!existed) {
|
||||
await this.guildUserRepo.save(
|
||||
this.guildUserRepo.create({ userId, guildNodeId, status: 'active' }),
|
||||
);
|
||||
} else if (existed.status !== 'active') {
|
||||
existed.status = 'active';
|
||||
await this.guildUserRepo.save(existed);
|
||||
}
|
||||
|
||||
return { status: 'ok' as const };
|
||||
}
|
||||
|
||||
async listGuildMembers(accessToken: string, guildNodeId: string) {
|
||||
const payload = this.verifyCenterAccessToken(accessToken);
|
||||
const userId = String(payload.sub ?? '');
|
||||
if (!userId) throw new UnauthorizedException('invalid access token');
|
||||
|
||||
const selfMembership = await this.guildUserRepo.findOne({ where: { userId, guildNodeId, status: 'active' } });
|
||||
if (!selfMembership) throw new UnauthorizedException('not a guild member');
|
||||
|
||||
const members = await this.guildUserRepo.find({ where: { guildNodeId, status: 'active' } });
|
||||
const userIds = [...new Set(members.map((m) => m.userId))];
|
||||
if (!userIds.length) return [];
|
||||
|
||||
const users = await this.userRepo
|
||||
.createQueryBuilder('u')
|
||||
.where('u.id IN (:...userIds)', { userIds })
|
||||
.getMany();
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
return members
|
||||
.map((m) => ({ userId: m.userId, email: userMap.get(m.userId)?.email ?? '', status: m.status }))
|
||||
.filter((x) => !!x.email);
|
||||
}
|
||||
|
||||
verifyCenterAccessToken(accessToken: string): jwt.JwtPayload {
|
||||
try {
|
||||
const payload = jwt.verify(accessToken, process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string) as jwt.JwtPayload;
|
||||
@@ -237,6 +271,7 @@ export class AuthService {
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: getAccessTokenExpiresInSeconds(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
60
src/cli.ts
Normal file
60
src/cli.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { NodeAdminService } from './nodes/node-admin.service';
|
||||
|
||||
function getArg(flag: string): string | null {
|
||||
const idx = process.argv.indexOf(flag);
|
||||
if (idx === -1) return null;
|
||||
return process.argv[idx + 1] ?? null;
|
||||
}
|
||||
|
||||
function printUsageAndExit(): never {
|
||||
console.error('Usage:');
|
||||
console.error(' node dist/cli.js user create --email <email> --password <password>');
|
||||
console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [subject, action] = process.argv.slice(2);
|
||||
if (!subject || !action) printUsageAndExit();
|
||||
|
||||
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
|
||||
try {
|
||||
if (subject === 'user' && action === 'create') {
|
||||
const email = getArg('--email');
|
||||
const password = getArg('--password');
|
||||
if (!email || !password) printUsageAndExit();
|
||||
|
||||
const auth = app.get(AuthService);
|
||||
const user = await auth.register({ email, password });
|
||||
process.stdout.write(JSON.stringify({ ok: true, user }) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'node' && action === 'register') {
|
||||
const nodeId = getArg('--node-id');
|
||||
const name = getArg('--name');
|
||||
const endpoint = getArg('--endpoint');
|
||||
if (!nodeId || !name || !endpoint) printUsageAndExit();
|
||||
|
||||
const nodes = app.get(NodeAdminService);
|
||||
const result = await nodes.registerNode({ nodeId, name, endpoint });
|
||||
process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
printUsageAndExit();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'unknown error';
|
||||
process.stderr.write(JSON.stringify({ ok: false, error: message }) + '\n');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -21,8 +21,17 @@ export class CenterApiKeyGuard implements CanActivate {
|
||||
const path = req.path ?? '';
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
|
||||
// only guild registration is exempt from API key; it is protected by HMAC secret
|
||||
if (method === 'POST' && (path === '/nodes/register' || path.endsWith('/nodes/register'))) {
|
||||
const noApiKeyRequired =
|
||||
path === '/healthz' ||
|
||||
path.endsWith('/healthz') ||
|
||||
(method === 'POST' && (path === '/auth/login' || path.endsWith('/auth/login'))) ||
|
||||
(method === 'POST' && (path === '/auth/refresh' || path.endsWith('/auth/refresh'))) ||
|
||||
(method === 'POST' && (path === '/auth/logout' || path.endsWith('/auth/logout'))) ||
|
||||
(method === 'GET' && (path === '/auth/me/guilds' || path.endsWith('/auth/me/guilds'))) ||
|
||||
(method === 'POST' && (path === '/auth/me/guilds/join' || path.endsWith('/auth/me/guilds/join'))) ||
|
||||
(method === 'GET' && (path.includes('/auth/guilds/') && path.endsWith('/members')));
|
||||
|
||||
if (noApiKeyRequired) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -40,4 +49,3 @@ export class CenterApiKeyGuard implements CanActivate {
|
||||
throw new UnauthorizedException('invalid api key');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
src/main.ts
23
src/main.ts
@@ -28,6 +28,29 @@ async function bootstrap() {
|
||||
validateEnv();
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const corsOrigins = (process.env.FABRIC_BACKEND_CENTER_CORS_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
// no Origin header: curl/server-to-server/most desktop local calls
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// desktop/electron local file origin
|
||||
if (origin === 'null') return callback(null, true);
|
||||
|
||||
// empty allowlist => allow all origins
|
||||
if (!corsOrigins.length) return callback(null, true);
|
||||
|
||||
if (corsOrigins.includes(origin)) return callback(null, true);
|
||||
return callback(new Error('CORS origin not allowed'), false);
|
||||
},
|
||||
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'x-client-name', 'x-request-id', 'x-api-key'],
|
||||
credentials: false,
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
const metrics = app.get(MetricsService);
|
||||
app.use(createRequestContextMiddleware('center', metrics));
|
||||
|
||||
59
src/nodes/node-admin.service.ts
Normal file
59
src/nodes/node-admin.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { GuildNode } from '../entities/guild-node.entity';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class NodeAdminService {
|
||||
constructor(
|
||||
@InjectRepository(GuildNode)
|
||||
private readonly nodeRepo: Repository<GuildNode>,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async registerNode(input: { nodeId: string; name: string; endpoint: string }) {
|
||||
const existedByNodeId = await this.nodeRepo.findOne({ where: { nodeId: input.nodeId } });
|
||||
if (existedByNodeId) {
|
||||
throw new ConflictException('nodeId already exists');
|
||||
}
|
||||
|
||||
const existedByEndpoint = await this.nodeRepo.findOne({ where: { endpoint: input.endpoint } });
|
||||
if (existedByEndpoint) {
|
||||
throw new ConflictException('endpoint already exists');
|
||||
}
|
||||
|
||||
const node = this.nodeRepo.create({
|
||||
nodeId: input.nodeId,
|
||||
name: input.name,
|
||||
endpoint: input.endpoint,
|
||||
status: 'active',
|
||||
apiKeyHash: null,
|
||||
});
|
||||
|
||||
const rawApiKey = `gk_${randomBytes(24).toString('hex')}`;
|
||||
node.apiKeyHash = await bcrypt.hash(rawApiKey, 10);
|
||||
const saved = await this.nodeRepo.save(node);
|
||||
|
||||
await this.audit.write({
|
||||
action: 'node.register',
|
||||
targetType: 'node',
|
||||
targetId: saved.nodeId,
|
||||
detail: JSON.stringify({ endpoint: saved.endpoint, via: 'cli' }),
|
||||
});
|
||||
|
||||
return {
|
||||
node: {
|
||||
id: saved.id,
|
||||
nodeId: saved.nodeId,
|
||||
name: saved.name,
|
||||
endpoint: saved.endpoint,
|
||||
status: saved.status,
|
||||
},
|
||||
apiKey: rawApiKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import {
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { GuildNode } from '../entities/guild-node.entity';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RegisterNodeDto } from './dto.register-node.dto';
|
||||
import { UpdateNodeStatusDto } from './dto.update-node-status.dto';
|
||||
import { FABRIC_PROTOCOL_VERSION, normalizeVersion } from '../common/version';
|
||||
|
||||
@Controller('nodes')
|
||||
export class NodesController {
|
||||
@@ -33,81 +24,6 @@ export class NodesController {
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
async register(
|
||||
@Req() req: { ip?: string; socket?: { remoteAddress?: string } },
|
||||
@Body() body: RegisterNodeDto,
|
||||
@Headers('x-fabric-version') fabricVersion?: string,
|
||||
) {
|
||||
const remoteAddress = (req.ip ?? req.socket?.remoteAddress ?? '').toLowerCase();
|
||||
const isLoopback =
|
||||
remoteAddress === '127.0.0.1' ||
|
||||
remoteAddress === '::1' ||
|
||||
remoteAddress === '::ffff:127.0.0.1';
|
||||
if (!isLoopback) {
|
||||
throw new ForbiddenException('register endpoint only allows localhost caller');
|
||||
}
|
||||
|
||||
const requestedVersion = normalizeVersion(fabricVersion);
|
||||
if (requestedVersion !== FABRIC_PROTOCOL_VERSION) {
|
||||
throw new HttpException(
|
||||
{
|
||||
error: {
|
||||
code: 'FABRIC_VERSION_NOT_SUPPORTED',
|
||||
message: `unsupported protocol version: ${requestedVersion}`,
|
||||
retryable: false,
|
||||
},
|
||||
supportedVersion: FABRIC_PROTOCOL_VERSION,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const existedByNodeId = await this.nodeRepo.findOne({
|
||||
where: { nodeId: body.nodeId },
|
||||
});
|
||||
if (existedByNodeId) {
|
||||
throw new ConflictException('nodeId already exists');
|
||||
}
|
||||
|
||||
const existedByEndpoint = await this.nodeRepo.findOne({
|
||||
where: { endpoint: body.endpoint },
|
||||
});
|
||||
if (existedByEndpoint) {
|
||||
throw new ConflictException('endpoint already exists');
|
||||
}
|
||||
|
||||
const node = this.nodeRepo.create({
|
||||
nodeId: body.nodeId,
|
||||
name: body.name,
|
||||
endpoint: body.endpoint,
|
||||
status: 'active',
|
||||
apiKeyHash: null,
|
||||
});
|
||||
const rawApiKey = `gk_${randomBytes(24).toString('hex')}`;
|
||||
node.apiKeyHash = await bcrypt.hash(rawApiKey, 10);
|
||||
const saved = await this.nodeRepo.save(node);
|
||||
await this.audit.write({
|
||||
action: 'node.register',
|
||||
targetType: 'node',
|
||||
targetId: saved.nodeId,
|
||||
detail: JSON.stringify({ endpoint: saved.endpoint }),
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'accepted',
|
||||
negotiatedVersion: FABRIC_PROTOCOL_VERSION,
|
||||
node: {
|
||||
id: saved.id,
|
||||
nodeId: saved.nodeId,
|
||||
name: saved.name,
|
||||
endpoint: saved.endpoint,
|
||||
status: saved.status,
|
||||
},
|
||||
apiKey: rawApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':nodeId/heartbeat')
|
||||
async heartbeat(@Param('nodeId') nodeId: string) {
|
||||
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { NodesController } from './nodes.controller';
|
||||
import { GuildNode } from '../entities/guild-node.entity';
|
||||
import { NodeAdminService } from './node-admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GuildNode])],
|
||||
controllers: [NodesController],
|
||||
providers: [NodeAdminService],
|
||||
exports: [NodeAdminService],
|
||||
})
|
||||
export class NodesModule {}
|
||||
|
||||
Reference in New Issue
Block a user