This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
@@ -28,6 +28,69 @@ export class AuthService {
|
||||
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: hash } });
|
||||
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
async logout(refreshToken: string) {
|
||||
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { tokenHash: hash, 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' });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
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() } }),
|
||||
]);
|
||||
|
||||
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);
|
||||
|
||||
@@ -56,64 +119,6 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: hash } });
|
||||
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
|
||||
}
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
const payload = { sub: user.id, email: user.email, role: user.role, venueId: user.venueId };
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
const newRefreshToken = crypto.randomBytes(32).toString('hex');
|
||||
const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash: newHash,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
venueId: user.venueId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async logout(refreshToken: string) {
|
||||
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { tokenHash: hash, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async generateDeviceToken(): Promise<{ rawToken: string; hash: string; prefix: string; expiresAt: Date }> {
|
||||
const rawBytes = crypto.randomBytes(TOKEN_BYTE_LENGTH);
|
||||
const rawToken = 'dev_' + rawBytes.toString('base64url');
|
||||
@@ -195,6 +200,32 @@ export class AuthService {
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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' });
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 } });
|
||||
|
||||
Reference in New Issue
Block a user