118 lines
5.7 KiB
TypeScript
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 };
|
|
}
|