feat: move to V2
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
+78 -223
View File
@@ -1,262 +1,117 @@
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { createHash, randomBytes } from 'crypto';
import { Me } from '@kooltap/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { TOKEN_EXPIRY_DAYS, TOKEN_BYTE_LENGTH, TOKEN_PREFIX_LENGTH } from '@kooltap/shared';
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 jwtService: JwtService,
private auditService: AuditService,
private realtime: RealtimeService,
private jwt: JwtService,
private audit: AuditService,
) {}
async login(email: string, password: string) {
const user = await this.prisma.clientUser.findUnique({ where: { email } });
if (!user) {
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' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
}
return this.issueTokens(user);
return { me: toMe(user), tokens: await this.issueTokens(user.id) };
}
async refresh(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: hash } });
/** 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;
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
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;
// Revoke first and atomically: of two requests with the same token, only one gets new tokens
const claimed = await this.prisma.refreshToken.updateMany({
where: { id: stored.id, revokedAt: null },
data: { revokedAt: new Date() },
});
if (claimed.count === 0) {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
const user = await this.prisma.clientUser.findUnique({ where: { id: stored.userId } });
if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
}
return this.issueTokens(user);
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) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
async logout(refreshToken: string | undefined) {
if (!refreshToken) return;
await this.prisma.refreshToken.updateMany({
where: { tokenHash: hash, revokedAt: null },
where: { tokenHash: sha256(refreshToken), revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Changes the signed-in user's password. Every session of the user is signed out, also this one, which gets new
* tokens instead.
*/
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
}
async me(userId: string): Promise<Me> {
return toMe(await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: userSelect }));
}
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
/** 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' });
}
const passwordHash = await bcrypt.hash(newPassword, 10);
await this.prisma.$transaction([
this.prisma.clientUser.update({ where: { id: user.id }, data: { passwordHash } }),
this.prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } }),
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.auditService.log(user.venueId, 'client_user', user.id, 'password_changed');
return this.issueTokens(user);
}
private async issueTokens(user: { id: string; email: string; name: string; role: string; venueId: string }) {
const payload = { sub: user.id, email: user.email, role: user.role, venueId: user.venueId };
const accessToken = this.jwtService.sign(payload);
const refreshToken = crypto.randomBytes(32).toString('hex');
const refreshHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await this.prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: refreshHash,
expiresAt,
},
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
venueId: user.venueId,
},
};
}
async generateDeviceToken(): Promise<{ rawToken: string; hash: string; prefix: string; expiresAt: Date }> {
const rawBytes = crypto.randomBytes(TOKEN_BYTE_LENGTH);
const rawToken = 'dev_' + rawBytes.toString('base64url');
const hash = crypto.createHash('sha256').update(rawToken).digest('hex');
const prefix = rawToken.substring(0, TOKEN_PREFIX_LENGTH);
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
return { rawToken, hash, prefix, expiresAt };
}
async registerDevice(venueId: string, name: string, registrationCode: string) {
const code = await this.prisma.registrationCode.findUnique({ where: { code: registrationCode } });
if (!code || code.usedAt || code.expiresAt < new Date()) {
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
}
if (code.venueId !== venueId) {
throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' });
}
// Claim the code atomically so it can only ever be used once
const claimed = await this.prisma.registrationCode.updateMany({
where: { id: code.id, usedAt: null },
data: { usedAt: new Date() },
});
if (claimed.count === 0) {
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
}
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
// A venue can have several terminals: every pairing adds one. A terminal that is replaced is unpaired on the
// dashboard
const deviceId = uuidv4();
await this.prisma.device.create({
data: {
id: deviceId,
venueId,
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
},
});
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
this.realtime.notify(venueId, 'device');
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
}
async createUser(venueId: string, email: string, password: string, name: string, role: string) {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
if (!venue) {
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found. Your session may be stale — try logging out and back in.' });
}
const existingUser = await this.prisma.clientUser.findUnique({ where: { email } });
if (existingUser) {
throw new ConflictException({ code: 'email_exists', message: 'A user with this email already exists' });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await this.prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email,
passwordHash,
name,
role,
},
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true },
});
this.realtime.notify(venueId, 'users');
return user;
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: userId }, action: 'password_changed' });
return { me: toMe(user), tokens: await this.issueTokens(userId) };
}
/**
* Deletes a dashboard user and signs them out: their refresh tokens are revoked, so they are out once their access
* token runs out. An admin cannot delete themself, so a venue always keeps an admin.
* 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 deleteUser(venueId: string, userId: string, actor: { id: string }) {
if (userId === actor.id) {
throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' });
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.clientUser.findFirst({ where: { id: userId, venueId } });
if (!user) {
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
}
await this.prisma.$transaction([
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
this.prisma.clientUser.delete({ where: { id: userId } }),
]);
await this.auditService.log(venueId, 'client_user', actor.id, 'user_deleted', { userId, email: user.email, role: user.role });
this.realtime.notify(venueId, 'users');
return { ok: true };
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 };
}
async validateDevice(deviceId: string, token: string): Promise<any> {
const hash = crypto.createHash('sha256').update(token).digest('hex');
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
if (!device || device.tokenHash !== hash) {
throw new UnauthorizedException({ code: 'invalid_device_token', message: 'Invalid device token' });
}
if (device.revokedAt) {
throw new UnauthorizedException({ code: 'device_revoked', message: 'Device has been revoked' });
}
if (device.tokenExpiresAt < new Date()) {
throw new UnauthorizedException({ code: 'token_expired', message: 'Device token has expired' });
}
return device;
}
async generateRegistrationCode(venueId: string): Promise<{ code: string; expiresAt: Date }> {
const code = crypto.randomBytes(3).toString('hex').toUpperCase();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
await this.prisma.registrationCode.create({
data: {
venueId,
code,
expiresAt,
},
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 { code, expiresAt };
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 };
}