unused-vars (25): leftover imports/types across pages, routes and services; write-only sickHours accumulator; unused holidayCount pair; test helpers authPatch/authDelete; TOut dropped from ApiMutationOptions (the hook keeps its own generic — no call-site changes); requireAuth no longer importable in customers/warehouse routes (matches the no-bare-auth-reads convention). useless-assignment (5): initializers proven overwritten on every path (systemQty x2, hours, writtenSize -> let x: number) and the seed's dead adminUser reassignment (create kept, assignment dropped). useless-escape (4): [eE+\-] -> [eE+-] and [\/...] -> [/...] — identical semantics. Behavior-neutral; lint 102 -> 68 warnings (0 errors), suite 641 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
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<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;
|
|
|
|
// 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<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 };
|