Files
app/src/services/auth.ts
BOHA 4608494a3f initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:46:51 +01:00

233 lines
6.9 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';
// --- 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;
const isAdmin = user.roles?.name === 'admin';
const permissions = isAdmin
? (await prisma.permissions.findMany()).map((p: { name: string }) => 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,
};
}
// --- 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) {
return { type: 'error', message: 'Neplatné přihlašovací údaje', status: 401 };
}
if (!user.is_active) {
return { type: 'error', message: 'Účet je deaktivován', status: 403 };
}
// Check lockout
if (user.locked_until && new Date(user.locked_until) > new Date()) {
return { type: 'error', message: 'Účet je dočasně uzamčen. Zkuste to později.', status: 429 };
}
const passwordValid = await bcrypt.compare(password, user.password_hash);
if (!passwordValid) {
const attempts = (user.failed_login_attempts ?? 0) + 1;
const updateData: Record<string, unknown> = { failed_login_attempts: attempts };
if (attempts >= config.security.maxLoginAttempts) {
updateData.locked_until = new Date(Date.now() + config.security.lockoutMinutes * 60_000);
}
await prisma.users.update({ where: { id: user.id }, data: updateData });
return { type: 'error', message: 'Neplatné přihlašovací údaje', status: 401 };
}
// Reset failed attempts
await prisma.users.update({
where: { id: user.id },
data: { failed_login_attempts: 0, locked_until: null, last_login: new Date() },
});
// Check if 2FA is enabled
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() + 5 * 60_000), // 5 minutes
},
});
return { type: 'totp_required', loginToken };
}
// Generate tokens
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);
const storedToken = await prisma.refresh_tokens.findUnique({
where: { token_hash: tokenHash },
});
if (!storedToken || storedToken.replaced_at || 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 };
}
// Rotate refresh token
const newRefreshTokenRaw = generateRefreshToken();
const newRefreshTokenHash = hashToken(newRefreshTokenRaw);
const expiresIn = storedToken.remember_me
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
await prisma.$transaction([
prisma.refresh_tokens.update({
where: { id: storedToken.id },
data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash },
}),
prisma.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,
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<void> {
const tokenHash = hashToken(refreshTokenRaw);
await prisma.refresh_tokens.deleteMany({ where: { token_hash: tokenHash } });
}
export async function verifyAccessToken(token: string): Promise<AuthData | null> {
try {
const payload = jwt.verify(token, config.jwt.secret) as unknown as JwtPayload;
return loadAuthData(payload.sub);
} catch {
return null;
}
}
export { hashToken, loadAuthData };