Files
kooltab-backoffice/apps/api/src/auth/auth.service.ts
T
brammie15 314717ba3a
CI / build-and-test (push) Canceled after 0s
feat: move to V2
2026-10-29 11:21:52 +01:00

118 lines
5.7 KiB
TypeScript

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<Me> {
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<AuthUser | null> {
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<Tokens> {
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 };
}