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 { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto.register.dto';
|
import { RegisterDto } from './dto.register.dto';
|
||||||
import { LoginDto } from './dto.login.dto';
|
import { LoginDto } from './dto.login.dto';
|
||||||
@@ -28,4 +28,19 @@ export class AuthController {
|
|||||||
logout(@Body() body: LogoutDto) {
|
logout(@Body() body: LogoutDto) {
|
||||||
return this.authService.logout(body.refreshToken);
|
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 { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
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 { GuildUser } from '../entities/guild-user.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([User])],
|
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser])],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService],
|
providers: [AuthService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
ForbiddenException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -8,6 +9,8 @@ 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 { User } from '../entities/user.entity';
|
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 { 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';
|
||||||
@@ -25,14 +28,72 @@ function signRefreshToken(userId: string, email: string): string {
|
|||||||
return jwt.sign({ sub: userId, email, typ: 'refresh' }, secret, { expiresIn });
|
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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly userRepo: Repository<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 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) {
|
async register(input: RegisterDto) {
|
||||||
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -82,6 +143,8 @@ export class AuthService {
|
|||||||
detail: JSON.stringify({ email: user.email }),
|
detail: JSON.stringify({ email: user.email }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
@@ -90,6 +153,59 @@ export class AuthService {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
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 { 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';
|
||||||
|
|
||||||
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
@@ -10,7 +11,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
|||||||
username: process.env.DB_USER ?? 'fabric',
|
username: process.env.DB_USER ?? 'fabric',
|
||||||
password: process.env.DB_PASSWORD ?? 'fabric',
|
password: process.env.DB_PASSWORD ?? 'fabric',
|
||||||
database: process.env.DB_NAME ?? 'fabric_center',
|
database: process.env.DB_NAME ?? 'fabric_center',
|
||||||
entities: [User, GuildNode, AuditLog],
|
entities: [User, GuildNode, GuildUser, AuditLog],
|
||||||
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
||||||
logging: (process.env.DB_LOGGING ?? 'false') === '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