import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; import { Me } from '@kooltap/shared'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AuthUser } from '../common/auth'; import { REFRESH_TOKEN_TTL_MS } from './cookies'; export const sha256 = (value: string) => createHash('sha256').update(value).digest('hex'); export const hashPassword = (password: string) => bcrypt.hash(password, 12); // Compared against when there is no such user, so the response time doesn't tell which emails exist const NO_USER_HASH = bcrypt.hashSync('no user', 12); type Tokens = { accessToken: string; refreshToken: string }; const userSelect = { id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true, venue: { select: { name: true } }, } as const; /** Whether a token was issued before the user's sessions were revoked. iat is in whole seconds. */ export function issuedBefore(payload: { iat?: number }, revokedAt: Date | null | undefined): boolean { return revokedAt != null && (payload.iat ?? 0) < Math.floor(revokedAt.getTime() / 1000); } @Injectable() export class AuthService { constructor( private prisma: PrismaService, private jwt: JwtService, private audit: AuditService, ) {} async login(email: string, password: string): Promise<{ me: Me; tokens: Tokens }> { const user = await this.prisma.user.findUnique({ where: { email }, select: { ...userSelect, passwordHash: true } }); const valid = await bcrypt.compare(password, user?.passwordHash ?? NO_USER_HASH); if (!user || !valid) { throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' }); } return { me: toMe(user), tokens: await this.issueTokens(user.id) }; } /** Rotates the refresh token: of two requests with the same token, only one gets new tokens. */ async refresh(refreshToken: string | undefined): Promise<{ me: Me; tokens: Tokens }> { const invalid = new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' }); if (!refreshToken) throw invalid; const now = new Date(); const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: sha256(refreshToken) } }); if (!stored || stored.revokedAt || stored.expiresAt < now) throw invalid; const { count } = await this.prisma.refreshToken.updateMany({ where: { id: stored.id, revokedAt: null }, data: { revokedAt: now } }); if (count === 0) throw invalid; const user = await this.prisma.user.findUnique({ where: { id: stored.userId }, select: userSelect }); if (!user) throw invalid; return { me: toMe(user), tokens: await this.issueTokens(user.id) }; } async logout(refreshToken: string | undefined) { if (!refreshToken) return; await this.prisma.refreshToken.updateMany({ where: { tokenHash: sha256(refreshToken), revokedAt: null }, data: { revokedAt: new Date() }, }); } async me(userId: string): Promise { return toMe(await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: userSelect })); } /** Changes the user's password and signs out every session; this one gets new tokens. */ async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<{ me: Me; tokens: Tokens }> { const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { ...userSelect, passwordHash: true } }); if (!(await bcrypt.compare(currentPassword, user.passwordHash))) { throw new BadRequestException({ code: 'wrong_password', message: 'The current password is not correct' }); } await this.prisma.$transaction([ this.prisma.user.update({ where: { id: userId }, data: { passwordHash: await hashPassword(newPassword) } }), this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }), ]); await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: userId }, action: 'password_changed' }); return { me: toMe(user), tokens: await this.issueTokens(userId) }; } /** * The user an access token belongs to, as the database has them now: a deleted or signed-out user loses access at * once, and a changed role applies right away. Null for a missing, invalid or revoked token. */ async userFromAccessToken(token: string | undefined): Promise { if (!token) return null; let payload: { sub: string; iat?: number }; try { payload = await this.jwt.verifyAsync(token); } catch { return null; } const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, select: userSelect }); if (!user || issuedBefore(payload, user.sessionsRevokedAt)) return null; return { id: user.id, email: user.email, name: user.name, role: user.role, venueId: user.venueId }; } private async issueTokens(userId: string): Promise { const refreshToken = randomBytes(32).toString('base64url'); await this.prisma.refreshToken.create({ data: { userId, tokenHash: sha256(refreshToken), expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }, }); return { accessToken: await this.jwt.signAsync({ sub: userId }), refreshToken }; } } function toMe(user: { id: string; email: string; name: string; role: Me['role']; venueId: string | null; venue: { name: string } | null }): Me { return { id: user.id, email: user.email, name: user.name, role: user.role, venueId: user.venueId, venueName: user.venue?.name ?? null }; }