diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 0a1a11f..e4216af 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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); + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 344681c..6c84984 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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], }) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 6653643..ae13ea0 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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, + @InjectRepository(GuildNode) + private readonly guildNodeRepo: Repository, + @InjectRepository(GuildUser) + private readonly guildUserRepo: Repository, 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, }; } diff --git a/src/database.config.ts b/src/database.config.ts index 7e285df..a11ccb6 100644 --- a/src/database.config.ts +++ b/src/database.config.ts @@ -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', }); diff --git a/src/entities/guild-user.entity.ts b/src/entities/guild-user.entity.ts new file mode 100644 index 0000000..78c0722 --- /dev/null +++ b/src/entities/guild-user.entity.ts @@ -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; +} +