initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
232
src/services/auth.ts
Normal file
232
src/services/auth.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user