@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user