import { ConflictException, Injectable, ForbiddenException, UnauthorizedException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; 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'; import { parseDurationToSeconds } from './token.util'; function signAccessToken(userId: string, email: 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 }, secret, { expiresIn }); } function signRefreshToken(userId: string, email: string): string { const secret = process.env.JWT_REFRESH_SECRET as string; const expiresIn = parseDurationToSeconds(process.env.JWT_REFRESH_EXPIRES_IN ?? '30d', 2592000); 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) { throw new ConflictException('email already exists'); } const passwordHash = await bcrypt.hash(input.password, 10); const user = this.userRepo.create({ email: input.email, passwordHash, refreshTokenHash: null, }); const saved = await this.userRepo.save(user); await this.audit.write({ action: 'auth.register', actorId: saved.id, targetType: 'user', targetId: saved.id, detail: JSON.stringify({ email: saved.email }), }); return { id: saved.id, email: saved.email, createdAt: saved.createdAt, }; } async login(input: LoginDto) { const user = await this.userRepo.findOne({ where: { email: input.email } }); if (!user) throw new UnauthorizedException('invalid credentials'); const ok = await bcrypt.compare(input.password, user.passwordHash); if (!ok) throw new UnauthorizedException('invalid credentials'); const accessToken = signAccessToken(user.id, user.email); const refreshToken = signRefreshToken(user.id, user.email); user.refreshTokenHash = await bcrypt.hash(refreshToken, 10); await this.userRepo.save(user); await this.audit.write({ action: 'auth.login', actorId: user.id, targetType: 'user', targetId: user.id, detail: JSON.stringify({ email: user.email }), }); const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email); return { accessToken, refreshToken, tokenType: 'Bearer', user: { 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, }; } async refresh(refreshToken: string) { let payload: jwt.JwtPayload; try { payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload; } catch { throw new UnauthorizedException('invalid refresh token'); } const userId = String(payload.sub ?? ''); const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user || !user.refreshTokenHash) { throw new UnauthorizedException('invalid refresh token'); } const tokenOk = await bcrypt.compare(refreshToken, user.refreshTokenHash); if (!tokenOk) throw new UnauthorizedException('invalid refresh token'); const newAccessToken = signAccessToken(user.id, user.email); const newRefreshToken = signRefreshToken(user.id, user.email); user.refreshTokenHash = await bcrypt.hash(newRefreshToken, 10); await this.userRepo.save(user); await this.audit.write({ action: 'auth.refresh', actorId: user.id, targetType: 'user', targetId: user.id, detail: null, }); return { accessToken: newAccessToken, refreshToken: newRefreshToken, tokenType: 'Bearer', }; } async logout(refreshToken: string) { let payload: jwt.JwtPayload; try { payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload; } catch { return { status: 'ok' }; } const userId = String(payload.sub ?? ''); if (!userId) return { status: 'ok' }; const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user) return { status: 'ok' }; user.refreshTokenHash = null; await this.userRepo.save(user); await this.audit.write({ action: 'auth.logout', actorId: user.id, targetType: 'user', targetId: user.id, detail: null, }); return { status: 'ok' }; } }