feat(center): issue per-guild tokens and add introspection API
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user