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 { 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 = { 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 { const tokenHash = hashToken(refreshTokenRaw); await prisma.refresh_tokens.deleteMany({ where: { token_hash: tokenHash } }); } 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 { return null; } } export { hashToken, loadAuthData };