import crypto from "crypto"; import jwt from "jsonwebtoken"; import bcrypt from "bcryptjs"; import { FastifyRequest, FastifyReply } from "fastify"; import prisma from "../config/database"; import { config } from "../config/env"; import { AuthData, JwtPayload } from "../types"; import { getSystemSettings } from "./system-settings"; // Pre-computed bcrypt hash for timing-safe comparison when user not found const DUMMY_HASH = "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO"; // --- Token helpers --- function hashToken(token: string): string { return crypto.createHash("sha256").update(token).digest("hex"); } function generateAccessToken(user: { id: number; username: string; roleName: string | null; }): string { return jwt.sign( { sub: user.id, username: user.username, role: user.roleName }, config.jwt.secret, { expiresIn: config.jwt.accessTokenExpiry }, ); } function generateRefreshToken(): string { return crypto.randomBytes(32).toString("hex"); } // --- Auth data loading --- async function loadAuthData(userId: number): Promise { const user = await prisma.users.findUnique({ where: { id: userId }, include: { roles: { include: { role_permissions: { include: { permissions: true }, }, }, }, }, }); if (!user || !user.is_active) return null; const isAdmin = user.roles?.name === "admin"; const permissions = isAdmin ? (await prisma.permissions.findMany({ select: { name: true } })).map( (p) => p.name, ) : (user.roles?.role_permissions ?? []).map( (rp: { permissions: { name: string } }) => rp.permissions.name, ); return { userId: user.id, username: user.username, email: user.email, firstName: user.first_name, lastName: user.last_name, roleId: user.role_id, roleName: user.roles?.name ?? null, permissions, totp_enabled: !!user.totp_enabled, require_2fa: !!( await prisma.company_settings.findFirst({ select: { require_2fa: true } }) )?.require_2fa, }; } // --- Public API --- export async function login( username: string, password: string, rememberMe: boolean, request: FastifyRequest, ): Promise< | { type: "success"; accessToken: string; refreshToken: string; user: AuthData; } | { type: "totp_required"; loginToken: string } | { type: "error"; message: string; status: number } > { const user = await prisma.users.findFirst({ where: { OR: [{ username }, { email: username }], }, include: { roles: true }, }); if (!user) { // Timing-safe: run bcrypt even when user not found await bcrypt.compare(password, DUMMY_HASH); return { type: "error", message: "Neplatné přihlašovací údaje", status: 401, }; } if (!user.is_active) { request.log.warn(`Login failed for deactivated user: ${username}`); return { type: "error", message: "Neplatné přihlašovací údaje", status: 401, }; } if (user.locked_until && new Date(user.locked_until) > new Date()) { request.log.warn(`Login failed for locked user: ${username}`); return { type: "error", message: "Neplatné přihlašovací údaje", status: 401, }; } const passwordValid = await bcrypt.compare(password, user.password_hash); if (!passwordValid) { const settings = await getSystemSettings(); await prisma.$transaction(async (tx) => { const updated = await tx.users.update({ where: { id: user.id }, data: { failed_login_attempts: { increment: 1 } }, select: { failed_login_attempts: true }, }); if ((updated.failed_login_attempts ?? 0) >= settings.max_login_attempts) { await tx.users.update({ where: { id: user.id }, data: { locked_until: new Date( Date.now() + settings.lockout_minutes * 60_000, ), }, }); } }); return { type: "error", message: "Neplatné přihlašovací údaje", status: 401, }; } await prisma.users.update({ where: { id: user.id }, data: { failed_login_attempts: 0, locked_until: null, last_login: new Date(), }, }); const companySettings = await prisma.company_settings.findFirst({ select: { require_2fa: true }, }); if (companySettings?.require_2fa && !user.totp_enabled) { return { type: "error", message: "Dvoufázové ověření je povinné", status: 403, }; } if (user.totp_enabled) { const loginToken = crypto.randomBytes(32).toString("hex"); const tokenHash = hashToken(loginToken); await prisma.totp_login_tokens.create({ data: { user_id: user.id, token_hash: tokenHash, expires_at: new Date( Date.now() + config.totp.loginTokenExpiryMinutes * 60_000, ), }, }); return { type: "totp_required", loginToken }; } const authData = await loadAuthData(user.id); if (!authData) { return { type: "error", message: "Chyba načítání uživatelských dat", status: 500, }; } const accessToken = generateAccessToken({ id: user.id, username: user.username, roleName: user.roles?.name ?? null, }); const refreshTokenRaw = generateRefreshToken(); const refreshTokenHash = hashToken(refreshTokenRaw); const expiresIn = rememberMe ? config.jwt.refreshTokenRememberExpiry : config.jwt.refreshTokenSessionExpiry; await prisma.refresh_tokens.create({ data: { user_id: user.id, token_hash: refreshTokenHash, expires_at: new Date(Date.now() + expiresIn * 1000), remember_me: rememberMe, ip_address: request.ip, user_agent: request.headers["user-agent"] ?? null, }, }); return { type: "success", accessToken, refreshToken: refreshTokenRaw, user: authData, }; } export async function refreshAccessToken( refreshTokenRaw: string, request: FastifyRequest, ): Promise< | { type: "success"; accessToken: string; refreshToken: string; user: AuthData; rememberMe: boolean; } | { type: "error"; message: string; status: number } > { const tokenHash = hashToken(refreshTokenRaw); return prisma.$transaction(async (tx) => { const tokens = await tx.$queryRaw< Array<{ id: number; user_id: number; expires_at: Date; replaced_at: Date | null; replaced_by_hash: string | null; remember_me: boolean | null; }> >` SELECT id, user_id, expires_at, replaced_at, replaced_by_hash, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE `; const storedToken = tokens[0] ?? null; if ( !storedToken || storedToken.replaced_at || storedToken.replaced_by_hash || new Date(storedToken.expires_at) < new Date() ) { return { type: "error", message: "Neplatný refresh token", status: 401 }; } const authData = await loadAuthData(storedToken.user_id); if (!authData) { return { type: "error", message: "Uživatel nenalezen", status: 401 }; } const newRefreshTokenRaw = generateRefreshToken(); const newRefreshTokenHash = hashToken(newRefreshTokenRaw); const expiresIn = storedToken.remember_me ? config.jwt.refreshTokenRememberExpiry : config.jwt.refreshTokenSessionExpiry; await tx.refresh_tokens.update({ where: { id: storedToken.id }, data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash }, }); await tx.refresh_tokens.create({ data: { user_id: storedToken.user_id, token_hash: newRefreshTokenHash, expires_at: new Date(Date.now() + expiresIn * 1000), remember_me: storedToken.remember_me ?? false, ip_address: request.ip, user_agent: request.headers["user-agent"] ?? null, }, }); const accessToken = generateAccessToken({ id: authData.userId, username: authData.username, roleName: authData.roleName, }); return { type: "success", accessToken, refreshToken: newRefreshTokenRaw, user: authData, rememberMe: storedToken.remember_me ?? false, }; }); } export async function logout(refreshTokenRaw: string): Promise { const tokenHash = hashToken(refreshTokenRaw); // Delete only the specific token presented, not all sessions await prisma.refresh_tokens.deleteMany({ where: { token_hash: tokenHash }, }); await prisma.refresh_tokens.deleteMany({ where: { expires_at: { lt: new Date() } }, }); } export async function verifyAccessToken( token: string, ): Promise { try { const payload = jwt.verify( token, config.jwt.secret, ) as unknown as JwtPayload; return loadAuthData(payload.sub); } catch (err) { console.error("JWT verification error:", err); return null; } } export { hashToken, loadAuthData };