Files
Fabric.Backend.Center/src/auth/auth.service.ts

275 lines
8.6 KiB
TypeScript

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<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) {
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' };
}
}