233 lines
6.9 KiB
TypeScript
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 };
|