Files
app/src/services/auth.ts
BOHA 88c9b7c2b8 fix(auth): stop logging expected token expiry as an error
verifyAccessToken logged every invalid/expired access token via console.error with a full stack trace. Access tokens are ~15 min and the client silently refreshes + retries on the 401, so this is an expected condition that flooded production logs (~every 15 min per active user) and buried real errors. Now only genuinely unexpected failures (non-JsonWebTokenError, e.g. a DB error in loadAuthData) are logged. No behavior change — verification still returns null on any token error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:23:56 +02:00

370 lines
10 KiB
TypeScript

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<AuthData | null> {
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;
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 };
}
// $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<void> {
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<AuthData | null> {
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 };