import { ConflictException, ForbiddenException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../prisma/prisma.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import type { JwtPayload } from './strategies/jwt.strategy'; export interface PublicUser { id: string; username: string; role: string; createdAt: string; } export interface LoginResult { accessToken: string; refreshToken: string; user: PublicUser; } const BCRYPT_ROUNDS = 10; const ACCESS_TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '30m'; const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN ?? '7d'; /** * Compared against when the username does not exist so that login takes * the same time either way (prevents user enumeration via timing). */ const DUMMY_HASH = '$2b$10$l232BFW3u63Mhfx0BatxUOLtw.qEofG9fNYjLsh2zce7MdIKDAIR6'; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, ) {} /** * Bootstrap-only registration: allowed just while the instance has no * users. Once an admin exists the endpoint refuses to create accounts * (use database seeding / an operator flow instead). */ async register(dto: RegisterDto): Promise { const userCount = await this.prisma.user.count(); if (userCount > 0) { throw new ForbiddenException('Registration is disabled'); } const existing = await this.prisma.user.findUnique({ where: { username: dto.username }, }); if (existing) { throw new ConflictException('Username already exists'); } const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS); const created = await this.prisma.user.create({ data: { username: dto.username, passwordHash }, }); return this.toPublic(created); } /** * Verifies credentials and returns signed access + refresh tokens. * Both tokens embed the user's tokenVersion so bumping it on the user * row (logout / revocation) invalidates them immediately. */ async login(dto: LoginDto): Promise { const user = await this.prisma.user.findUnique({ where: { username: dto.username }, }); // Always run a bcrypt compare (against a dummy hash when the user is // unknown) so response timing cannot be used to enumerate usernames. const ok = await bcrypt.compare(dto.password, user?.passwordHash ?? DUMMY_HASH); if (!user || !ok) { throw new UnauthorizedException('Invalid credentials'); } return { accessToken: await this.signAccessToken(user), refreshToken: await this.signRefreshToken(user), user: this.toPublic(user), }; } /** * Rotates a refresh token: the old refresh token becomes invalid as * soon as tokenVersion is bumped (logout, revocation). */ async refresh(refreshToken: string): Promise { let payload: JwtPayload; try { payload = await this.jwt.verifyAsync(refreshToken); } catch { throw new UnauthorizedException('Invalid refresh token'); } if (payload.typ !== 'refresh') { throw new UnauthorizedException('Invalid refresh token'); } const user = await this.prisma.user .findUnique({ where: { id: BigInt(payload.sub) } }) .catch(() => null); if (!user || user.tokenVersion !== payload.tv) { throw new UnauthorizedException('Invalid refresh token'); } return { accessToken: await this.signAccessToken(user), refreshToken: await this.signRefreshToken(user), user: this.toPublic(user), }; } /** * Revokes all tokens of a user by bumping tokenVersion. */ async logout(userId: bigint): Promise { await this.prisma.user.update({ where: { id: userId }, data: { tokenVersion: { increment: 1 } }, }); } async me(userId: bigint): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedException(); } return this.toPublic(user); } private async signAccessToken(user: { id: bigint; username: string; role: string; tokenVersion: number; }): Promise { const payload: JwtPayload = { sub: user.id.toString(), username: user.username, role: user.role, tv: user.tokenVersion, typ: 'access', }; return this.jwt.signAsync(payload, { expiresIn: ACCESS_TOKEN_EXPIRES_IN, }); } private async signRefreshToken(user: { id: bigint; username: string; role: string; tokenVersion: number; }): Promise { const payload: JwtPayload = { sub: user.id.toString(), username: user.username, role: user.role, tv: user.tokenVersion, typ: 'refresh', }; return this.jwt.signAsync(payload, { expiresIn: REFRESH_TOKEN_EXPIRES_IN, }); } private toPublic(user: { id: bigint; username: string; role: string; createdAt: Date; }): PublicUser { return { id: user.id.toString(), username: user.username, role: user.role, createdAt: user.createdAt.toISOString(), }; } }