Files
app/api/admin/handlers/totp-handlers.php
Simon 5ef6fc8064 refactor: odstraneni PSR-1 SideEffects warningu
- Handler funkce extrahovany z API souboru do api/admin/handlers/
- config.php rozdeleny na helpers.php (funkce) a constants.php (konstanty)
- require_once odstranen z class souboru (AuditLog, JWTAuth, LeaveNotification)
- vendor/autoload.php presunuto do config.php bootstrap
- totp-handlers.php: pridany use deklarace pro TwoFactorAuth
- phpstan.neon: bootstrapFiles, scanDirectories, dynamicConstantNames
- Opraveny chybejici routing bloky v totp.php a session.php

Vysledek: phpcs 0 errors 0 warnings, PHPStan 0 errors, ESLint 0 errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:29:21 +01:00

422 lines
13 KiB
PHP

<?php
declare(strict_types=1);
use RobThree\Auth\TwoFactorAuth;
use RobThree\Auth\Providers\Qr\QRServerProvider;
function getTfa(): TwoFactorAuth
{
static $tfa = null;
if ($tfa === null) {
$tfa = new TwoFactorAuth(new QRServerProvider(), 'BOHA Automation');
}
return $tfa;
}
/** GET ?action=status */
function handleStatus(PDO $pdo): void
{
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
$userId = $authData['user_id'];
$stmt = $pdo->prepare('SELECT totp_enabled FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
successResponse([
'totp_enabled' => (bool) ($user['totp_enabled'] ?? false),
]);
}
/** POST ?action=setup - vygenerovat secret + QR URI (jeste neaktivuje 2FA) */
function handleSetup(PDO $pdo, TwoFactorAuth $tfa): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
errorResponse('Metoda není povolena', 405);
}
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
$userId = $authData['user_id'];
$stmt = $pdo->prepare('SELECT totp_enabled, username, email FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if ($user['totp_enabled']) {
errorResponse('2FA je již aktivní. Nejdříve ji deaktivujte.');
}
$secret = $tfa->createSecret();
$stmt = $pdo->prepare('UPDATE users SET totp_secret = ? WHERE id = ?');
$stmt->execute([Encryption::encrypt($secret), $userId]);
$label = $user['email'] ?: $user['username'];
$qrUri = $tfa->getQRText($label, $secret);
successResponse([
'secret' => $secret,
'qr_uri' => $qrUri,
]);
}
/** POST ?action=enable { "code": "123456" } */
function handleEnable(PDO $pdo, TwoFactorAuth $tfa): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
errorResponse('Metoda není povolena', 405);
}
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
$userId = $authData['user_id'];
$input = getJsonInput();
$code = trim($input['code'] ?? '');
if (empty($code)) {
errorResponse('Ověřovací kód je povinný');
}
$stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user['totp_secret']) {
errorResponse('Nejprve vygenerujte tajný klíč (setup)');
}
if ($user['totp_enabled']) {
errorResponse('2FA je již aktivní');
}
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
if (!$tfa->verifyCode($decryptedSecret, $code)) {
errorResponse('Neplatný ověřovací kód. Zkontrolujte čas na telefonu.');
}
$backupCodes = generateBackupCodes();
$hashedCodes = array_map(fn ($c) => password_hash($c, PASSWORD_BCRYPT, ['cost' => 10]), $backupCodes);
$stmt = $pdo->prepare('UPDATE users SET totp_enabled = 1, totp_backup_codes = ? WHERE id = ?');
$stmt->execute([json_encode($hashedCodes), $userId]);
AuditLog::logUpdate('user', $userId, ['totp_enabled' => 0], ['totp_enabled' => 1], 'Uživatel aktivoval 2FA');
successResponse([
'backup_codes' => $backupCodes,
], '2FA bylo úspěšně aktivováno');
}
/** POST ?action=disable { "code": "123456" } */
function handleDisable(PDO $pdo, TwoFactorAuth $tfa): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
errorResponse('Metoda není povolena', 405);
}
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
$userId = $authData['user_id'];
$input = getJsonInput();
$code = trim($input['code'] ?? '');
if (empty($code)) {
errorResponse('Ověřovací kód je povinný');
}
$stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user['totp_enabled']) {
errorResponse('2FA není aktivní');
}
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
if (!$tfa->verifyCode($decryptedSecret, $code)) {
errorResponse('Neplatný ověřovací kód');
}
$stmt = $pdo->prepare(
'UPDATE users SET totp_enabled = 0, totp_secret = NULL,
totp_backup_codes = NULL WHERE id = ?'
);
$stmt->execute([$userId]);
AuditLog::logUpdate('user', $userId, ['totp_enabled' => 1], ['totp_enabled' => 0], 'Uživatel deaktivoval 2FA');
successResponse(null, '2FA bylo deaktivováno');
}
/**
* POST ?action=verify - overeni TOTP kodu pri loginu (pre-auth)
* Body: { "login_token": "...", "code": "123456", "remember": false }
*/
function handleVerify(PDO $pdo, TwoFactorAuth $tfa): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
errorResponse('Metoda není povolena', 405);
}
$rateLimiter = new RateLimiter();
$rateLimiter->setFailClosed();
$rateLimiter->enforce('totp_2fa', 5);
$input = getJsonInput();
$loginToken = $input['login_token'] ?? '';
$code = trim($input['code'] ?? '');
$remember = (bool) ($input['remember'] ?? false);
if (empty($loginToken) || empty($code)) {
errorResponse('Přihlašovací token a ověřovací kód jsou povinné');
}
$tokenData = verifyLoginToken($pdo, $loginToken);
if (!$tokenData) {
errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401);
}
$userId = $tokenData['user_id'];
$stmt = $pdo->prepare('
SELECT u.*, r.name as role_name, r.display_name as role_display_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.id = ? AND u.totp_enabled = 1
');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user) {
errorResponse('Uživatel nenalezen nebo 2FA není aktivní', 401);
}
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
if (!$tfa->verifyCode($decryptedSecret, $code, 1)) {
errorResponse('Neplatný ověřovací kód');
}
deleteLoginToken($pdo, $loginToken);
completeLogin($pdo, $user, $remember);
}
/** POST ?action=backup_verify { "login_token": "...", "code": "XXXXXXXX", "remember": false } */
function handleBackupVerify(PDO $pdo): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
errorResponse('Metoda není povolena', 405);
}
$rateLimiter = new RateLimiter();
$rateLimiter->setFailClosed();
$rateLimiter->enforce('totp_2fa', 5);
$input = getJsonInput();
$loginToken = $input['login_token'] ?? '';
$code = strtoupper(trim($input['code'] ?? ''));
$remember = (bool) ($input['remember'] ?? false);
if (empty($loginToken) || empty($code)) {
errorResponse('Přihlašovací token a záložní kód jsou povinné');
}
$tokenData = verifyLoginToken($pdo, $loginToken);
if (!$tokenData) {
errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401);
}
$userId = $tokenData['user_id'];
$stmt = $pdo->prepare('
SELECT u.*, r.name as role_name, r.display_name as role_display_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.id = ? AND u.totp_enabled = 1
');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user || !$user['totp_backup_codes']) {
errorResponse('Uživatel nenalezen nebo nemá záložní kódy', 401);
}
$hashedCodes = json_decode($user['totp_backup_codes'], true);
$matched = false;
$remainingCodes = [];
foreach ($hashedCodes as $hashed) {
if (!$matched && password_verify($code, $hashed)) {
$matched = true;
} else {
$remainingCodes[] = $hashed;
}
}
if (!$matched) {
errorResponse('Neplatný záložní kód');
}
$stmt = $pdo->prepare('UPDATE users SET totp_backup_codes = ? WHERE id = ?');
$stmt->execute([json_encode($remainingCodes), $userId]);
deleteLoginToken($pdo, $loginToken);
completeLogin($pdo, $user, $remember);
}
/** GET ?action=get_required (admin only) */
function handleGetRequired(PDO $pdo): void
{
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
requirePermission($authData, 'settings.security');
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
successResponse([
'require_2fa' => (bool) $stmt->fetchColumn(),
]);
}
/** POST ?action=set_required { "required": true/false } (admin only) */
function handleSetRequired(PDO $pdo): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
errorResponse('Metoda není povolena', 405);
}
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
requirePermission($authData, 'settings.security');
$input = getJsonInput();
$required = (bool) ($input['required'] ?? false);
$stmt = $pdo->prepare("UPDATE company_settings SET require_2fa = ? LIMIT 1");
$stmt->execute([$required ? 1 : 0]);
successResponse([
'require_2fa' => $required,
], $required ? '2FA je nyní povinná pro všechny uživatele' : '2FA již není povinná');
}
// --- Helper functions ---
/** Desifrovat TOTP secret z DB (zpetne kompatibilni s plaintextem pred migraci) */
function decryptTotpSecret(string $value): string
{
if (Encryption::isEncrypted($value)) {
return Encryption::decrypt($value);
}
return $value;
}
/**
* Generovat 8 nahodnych backup kodu
*
* @return list<string>
*/
function generateBackupCodes(int $count = 8): array
{
$codes = [];
for ($i = 0; $i < $count; $i++) {
$codes[] = strtoupper(bin2hex(random_bytes(4))); // 8-char hex
}
return $codes;
}
/** Docasny login token pro 2FA (5 min) */
function createLoginToken(PDO $pdo, int $userId): string
{
$token = bin2hex(random_bytes(32));
$hashedToken = hash('sha256', $token);
$expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE user_id = ? OR expires_at < NOW()');
$stmt->execute([$userId]);
$stmt = $pdo->prepare('
INSERT INTO totp_login_tokens (user_id, token_hash, expires_at)
VALUES (?, ?, ?)
');
$stmt->execute([$userId, $hashedToken, $expiresAt]);
return $token;
}
/**
* Overit login token
*
* @return array<string, mixed>|null
*/
function verifyLoginToken(PDO $pdo, string $token): ?array
{
$hashedToken = hash('sha256', $token);
$stmt = $pdo->prepare('
SELECT * FROM totp_login_tokens
WHERE token_hash = ? AND expires_at > NOW()
');
$stmt->execute([$hashedToken]);
return $stmt->fetch() ?: null;
}
/** Smazat login token po pouziti */
function deleteLoginToken(PDO $pdo, string $token): void
{
$hashedToken = hash('sha256', $token);
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE token_hash = ?');
$stmt->execute([$hashedToken]);
}
/**
* Dokoncit login po uspesnem 2FA - vydat JWT + refresh token
*
* @param array<string, mixed> $user
*/
function completeLogin(PDO $pdo, array $user, bool $remember): void
{
$stmt = $pdo->prepare('
UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login = NOW()
WHERE id = ?
');
$stmt->execute([$user['id']]);
$userData = [
'id' => $user['id'],
'username' => $user['username'],
'email' => $user['email'],
'first_name' => $user['first_name'],
'last_name' => $user['last_name'],
'role' => $user['role_name'] ?? null,
'role_display' => $user['role_display_name'] ?? $user['role_name'] ?? null,
'is_admin' => ($user['role_name'] ?? '') === 'admin',
];
$accessToken = JWTAuth::generateAccessToken($userData);
JWTAuth::generateRefreshToken($user['id'], $remember);
AuditLog::logLogin($user['id'], $user['username']);
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
$require2FA = (bool) $stmt->fetchColumn();
successResponse([
'access_token' => $accessToken,
'expires_in' => JWTAuth::getAccessTokenExpiry(),
'user' => [
'id' => $userData['id'],
'username' => $userData['username'],
'email' => $userData['email'],
'full_name' => trim($userData['first_name'] . ' ' . $userData['last_name']),
'role' => $userData['role'],
'role_display' => $userData['role_display'],
'is_admin' => $userData['is_admin'],
'permissions' => JWTAuth::getUserPermissions($user['id']),
'totp_enabled' => true,
'require_2fa' => $require2FA,
],
], 'Přihlášení úspěšné');
}