feat(center): issue per-guild tokens and add introspection API
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
25
src/entities/guild-user.entity.ts
Normal file
25
src/entities/guild-user.entity.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user