feat(center): issue per-guild tokens and add introspection API

This commit is contained in:
root
2026-05-13 07:59:27 +00:00
parent 03a3342d2a
commit a924bf656d
5 changed files with 162 additions and 3 deletions

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Post } from '@nestjs/common';
import { Body, Controller, Get, Headers, Post, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto.register.dto';
import { LoginDto } from './dto.login.dto';
@@ -28,4 +28,19 @@ export class AuthController {
logout(@Body() body: LogoutDto) {
return this.authService.logout(body.refreshToken);
}
@Get('me/guilds')
meGuilds(@Headers('authorization') authorization?: string) {
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
if (!token) throw new UnauthorizedException('missing bearer token');
return this.authService.listMyGuilds(token);
}
@Post('introspect')
introspect(
@Body() body: { token?: string; guildNodeId?: string },
@Headers('x-center-shared-secret') sharedSecret?: string,
) {
return this.authService.introspectGuildToken(body?.token ?? '', body?.guildNodeId ?? '', sharedSecret);
}
}

View File

@@ -3,9 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
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';
@Module({
imports: [TypeOrmModule.forFeature([User])],
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser])],
controllers: [AuthController],
providers: [AuthService],
})

View File

@@ -1,6 +1,7 @@
import {
ConflictException,
Injectable,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -8,6 +9,8 @@ import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken';
import { User } from '../entities/user.entity';
import { GuildNode } from '../entities/guild-node.entity';
import { GuildUser } from '../entities/guild-user.entity';
import { RegisterDto } from './dto.register.dto';
import { LoginDto } from './dto.login.dto';
import { AuditService } from '../audit/audit.service';
@@ -25,14 +28,72 @@ function signRefreshToken(userId: string, email: string): string {
return jwt.sign({ sub: userId, email, typ: 'refresh' }, secret, { expiresIn });
}
function signGuildAccessToken(userId: string, email: string, guildNodeId: string): string {
const secret = process.env.JWT_ACCESS_SECRET as string;
const expiresIn = parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m', 900);
return jwt.sign({ sub: userId, email, gid: guildNodeId, typ: 'guild_access' }, secret, { expiresIn });
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(GuildNode)
private readonly guildNodeRepo: Repository<GuildNode>,
@InjectRepository(GuildUser)
private readonly guildUserRepo: Repository<GuildUser>,
private readonly audit: AuditService,
) {}
private async getUserGuildsAndTokens(userId: string, email: string) {
let 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 }> };
}
const nodes = await this.guildNodeRepo
.createQueryBuilder('n')
.where('n.nodeId IN (:...nodeIds)', { nodeIds })
.andWhere('n.status = :status', { status: 'active' })
.getMany();
const guilds = nodes.map((n) => ({
nodeId: n.nodeId,
name: n.name,
endpoint: n.endpoint,
status: n.status,
}));
const guildAccessTokens = guilds.map((g) => ({
guildNodeId: g.nodeId,
token: signGuildAccessToken(userId, email, g.nodeId),
tokenType: 'Bearer',
}));
return { guilds, guildAccessTokens };
}
async register(input: RegisterDto) {
const exists = await this.userRepo.findOne({ where: { email: input.email } });
if (exists) {
@@ -82,6 +143,8 @@ export class AuthService {
detail: JSON.stringify({ email: user.email }),
});
const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email);
return {
accessToken,
refreshToken,
@@ -90,6 +153,59 @@ export class AuthService {
id: user.id,
email: user.email,
},
guilds,
guildAccessTokens,
};
}
async listMyGuilds(accessToken: string) {
const payload = this.verifyCenterAccessToken(accessToken);
const userId = String(payload.sub ?? '');
const email = String(payload.email ?? '');
return this.getUserGuildsAndTokens(userId, email);
}
verifyCenterAccessToken(accessToken: string): jwt.JwtPayload {
try {
const payload = jwt.verify(accessToken, process.env.JWT_ACCESS_SECRET as string) as jwt.JwtPayload;
if (!payload?.sub) throw new Error('invalid');
return payload;
} catch {
throw new UnauthorizedException('invalid access token');
}
}
async introspectGuildToken(token: string, guildNodeId: string, sharedSecret?: string) {
const expectedSecret = process.env.CENTER_SHARED_SECRET as string;
if (!sharedSecret || sharedSecret !== expectedSecret) {
throw new ForbiddenException('invalid shared secret');
}
let payload: jwt.JwtPayload;
try {
payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET as string) as jwt.JwtPayload;
} catch {
return { active: false };
}
const userId = String(payload.sub ?? '');
const tokenGuildNodeId = String(payload.gid ?? '');
if (!userId || tokenGuildNodeId !== guildNodeId || payload.typ !== 'guild_access') {
return { active: false };
}
const membership = await this.guildUserRepo.findOne({
where: { userId, guildNodeId, status: 'active' },
});
if (!membership) return { active: false };
return {
active: true,
user: {
id: userId,
email: String(payload.email ?? ''),
},
guildNodeId,
};
}

View File

@@ -2,6 +2,7 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { GuildNode } from './entities/guild-node.entity';
import { AuditLog } from './entities/audit-log.entity';
import { GuildUser } from './entities/guild-user.entity';
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
type: 'mysql',
@@ -10,7 +11,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
username: process.env.DB_USER ?? 'fabric',
password: process.env.DB_PASSWORD ?? 'fabric',
database: process.env.DB_NAME ?? 'fabric_center',
entities: [User, GuildNode, AuditLog],
entities: [User, GuildNode, GuildUser, AuditLog],
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
});

View File

@@ -0,0 +1,25 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
@Entity('guild_users')
@Unique('uniq_guild_user', ['guildNodeId', 'userId'])
export class GuildUser {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 64 })
guildNodeId!: string;
@Column({ type: 'varchar', length: 64 })
userId!: string;
@Column({
type: 'enum',
enum: ['active', 'revoked'],
default: 'active',
})
status!: 'active' | 'revoked';
@CreateDateColumn()
createdAt!: Date;
}