import crypto from "crypto"; import jwt from "jsonwebtoken"; import bcrypt from "bcryptjs"; import { FastifyRequest } 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; if (user.locked_until && new Date(user.locked_until) > new Date()) 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) { await bcrypt.compare(password, DUMMY_HASH); // timing-safe 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()) { await bcrypt.compare(password, DUMMY_HASH); // timing-safe 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; // Detected reuse: a token that has ALREADY been rotated (replaced_by_hash // points at its descendant) is being presented again. This is the classic // refresh-token-theft signature — either the legitimate client or an // attacker is replaying a consumed token. Breach containment: revoke the // WHOLE token family for that user so both the stolen and the still-valid // descendant tokens are invalidated, forcing a fresh login. // A token with ONLY replaced_at set was manually TERMINATED (sessions // DELETE / password change set replaced_at without a descendant) — that // is an expected dead token, NOT theft: reject it below as plain invalid // without nuking the user's other sessions. if (storedToken && storedToken.replaced_by_hash) { const reuseUserId = Number(storedToken.user_id); await tx.refresh_tokens.deleteMany({ where: { user_id: reuseUserId } }); request.log.warn( `Refresh-token reuse detected for user ${reuseUserId}; revoked all sessions`, ); return { type: "error", message: "Neplatný refresh token", status: 401 }; } if ( !storedToken || storedToken.replaced_at || new Date(storedToken.expires_at) < new Date() ) { return { type: "error", message: "Neplatný refresh token", status: 401 }; } // $queryRaw on MySQL may return BigInt for integer columns const storedTokenId = Number(storedToken.id); const storedUserId = Number(storedToken.user_id); const authData = await loadAuthData(storedUserId); 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; // $queryRaw on MySQL returns 0/1 for booleans; Prisma expects true/false const rememberMe = Boolean(storedToken.remember_me); await tx.refresh_tokens.update({ where: { id: storedTokenId }, data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash }, }); await tx.refresh_tokens.create({ data: { user_id: storedUserId, token_hash: newRefreshTokenHash, expires_at: new Date(Date.now() + expiresIn * 1000), remember_me: rememberMe, 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: rememberMe, }; }); } 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, { algorithms: ["HS256"], }) as unknown as JwtPayload; return loadAuthData(payload.sub); } catch (err) { // An expired or otherwise invalid access token is an EXPECTED condition: // access tokens live ~15 min, and the client silently refreshes + retries // on the resulting 401 (see src/admin/utils/api.ts). Logging every such // case as an error — with a stack trace — floods the logs (roughly every // 15 min per active user) and buries genuine errors. `JsonWebTokenError` // is the base class of `TokenExpiredError`/`NotBeforeError`, so this skips // all normal token-validation failures while still surfacing anything // unexpected (e.g. a DB failure in loadAuthData). if (!(err instanceof jwt.JsonWebTokenError)) { console.error("Unexpected error verifying access token:", err); } return null; } } export { hashToken, loadAuthData };