From 5deb0e1056d62077c3fec97eaa1835da79986e3c Mon Sep 17 00:00:00 2001 From: janis steiner Date: Wed, 6 May 2026 09:27:02 +0200 Subject: [PATCH] adding ratelimiting with reddis db --- app-code/account/index.php | 42 ++++++- app-code/api/account/update_2fa.php | 25 +++- app-code/api/account/update_passkey.php | 38 +++++-- app-code/api/auth/check_auth_key.php | 7 +- app-code/api/login/check_mfa.php | 2 + app-code/api/login/check_passkey.php | 51 +++++++-- app-code/api/login/check_pw.php | 2 + app-code/api/login/redirect.php | 3 +- app-code/api/login/reset_pw.php | 7 +- app-code/api/login/send_reset_link.php | 6 +- app-code/api/login/set_username.php | 3 + app-code/api/register/register_user.php | 1 + app-code/api/utils/security.php | 144 ++++++++++++++++++++++++ app-code/config/config.php | 2 + docker-compose.yml | 12 +- srv_dockerfile | 4 +- 16 files changed, 312 insertions(+), 37 deletions(-) diff --git a/app-code/account/index.php b/app-code/account/index.php index 5e3e828..118657c 100644 --- a/app-code/account/index.php +++ b/app-code/account/index.php @@ -192,7 +192,11 @@ if (!isset($_SESSION["logged_in"]) || $_SESSION["logged_in"] !== true) {
+
+ +
@@ -362,8 +366,12 @@ if (!isset($_SESSION["logged_in"]) || $_SESSION["logged_in"] !== true) { // Handle success if(isEnabled==false){ showSuccessModal(result.message || (isEnabled ? '2FA enabled successfully.' : '2FA disabled successfully.')); - }else{ - show2FaModal(result.message, result.token); + }else{ + if (result.pending) { + show2FaModal(result.message, result.token); + } else { + showSuccessModal(result.message || '2FA enabled successfully.'); + } } } else { // Handle error @@ -427,11 +435,41 @@ if (!isset($_SESSION["logged_in"]) || $_SESSION["logged_in"] !== true) { } function show2FaModal(message,secret) { document.getElementById('twofaModalMessage').textContent = message; + document.getElementById('qrcode').innerHTML = ''; + document.getElementById('twofa-confirm-pin').value = ''; const errorModal = new bootstrap.Modal(document.getElementById('twofaModal')); generate2FAQRCode("Jakach Login",'',secret); errorModal.show(); } + async function confirm2FaEnrollment() { + const pin = document.getElementById('twofa-confirm-pin').value.trim(); + if (!pin) { + showErrorModal('Enter the current 2FA code.'); + return; + } + + const response = await fetch('/api/account/update_2fa.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.csrfToken, + }, + body: JSON.stringify({ + enable_2fa: true, + twofa_pin: pin + }), + }); + const result = await response.json(); + if (response.ok && result.success) { + bootstrap.Modal.getInstance(document.getElementById('twofaModal')).hide(); + document.getElementById('2fa-switch').checked = true; + showSuccessModal(result.message || '2FA enabled successfully.'); + } else { + showErrorModal(result.message || 'Invalid 2FA code.'); + } + } + function generate2FAQRCode(issuer, accountName, secret) { // Create the OTP URI const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`; diff --git a/app-code/api/account/update_2fa.php b/app-code/api/account/update_2fa.php index c3e4d1d..7ab160c 100644 --- a/app-code/api/account/update_2fa.php +++ b/app-code/api/account/update_2fa.php @@ -39,13 +39,28 @@ if(!isset($data->enable_2fa) || !is_bool($data->enable_2fa)){ exit(); } if($data->enable_2fa==true){ - //create 2fa secret key - $twofa_secret=generateBase32Secret(); + $twofa_pin = trim((string)($data->twofa_pin ?? "")); + if ($twofa_pin === "") { + $twofa_secret=generateBase32Secret(); + $_SESSION["pending_2fa_secret"]=$twofa_secret; + echo json_encode(['success' => true, 'pending' => true, 'message' => 'Scan this QR code, then enter the current 2FA code to confirm enrollment.', 'token' => $twofa_secret]); + exit(); + } + + check_rate_limit($conn, 'setup_2fa', 5, 10 * 60, (string)$id); + $twofa_secret = $_SESSION["pending_2fa_secret"] ?? ""; + if ($twofa_secret === "" || !hash_equals(generateTOTP($twofa_secret), $twofa_pin)) { + echo json_encode(['success' => false, 'message' => 'Invalid 2FA code.']); + exit(); + } + $sql="UPDATE users SET 2fa = ?, auth_method_enabled_2fa = 1, auth_method_required_2fa = 1 WHERE id = ?"; if ($update_stmt = $conn->prepare($sql)) { $update_stmt->bind_param("si", $twofa_secret, $id); if ($update_stmt->execute()) { - echo json_encode(['success' => true, 'message' => '2FA enabled. Your 2fa secret is: '.$twofa_secret.'', 'token' => $twofa_secret]); + unset($_SESSION["pending_2fa_secret"]); + clear_rate_limit($conn, 'setup_2fa', (string)$id); + echo json_encode(['success' => true, 'message' => '2FA enabled.']); } else { echo json_encode(['success' => false, 'message' => 'Failed to enable 2fa.']); } @@ -56,8 +71,8 @@ if($data->enable_2fa==true){ } if($data->enable_2fa==false){ - //create 2fa secret key - $sql="UPDATE users SET auth_method_enabled_2fa = 0, auth_method_required_2fa = 0 WHERE id = ?"; + unset($_SESSION["pending_2fa_secret"]); + $sql="UPDATE users SET 2fa = '', auth_method_enabled_2fa = 0, auth_method_required_2fa = 0 WHERE id = ?"; if ($update_stmt = $conn->prepare($sql)) { $update_stmt->bind_param("i",$id); if ($update_stmt->execute()) { diff --git a/app-code/api/account/update_passkey.php b/app-code/api/account/update_passkey.php index 7b92771..4c2a945 100644 --- a/app-code/api/account/update_passkey.php +++ b/app-code/api/account/update_passkey.php @@ -30,15 +30,19 @@ try { // read get argument and post body $fn = filter_input(INPUT_GET, 'fn'); + if (!in_array($fn, ['getCreateArgs', 'processCreate'], true)) { + throw new Exception('Invalid passkey operation.'); + } $requireResidentKey = !!filter_input(INPUT_GET, 'requireResidentKey'); - $userVerification = filter_input(INPUT_GET, 'userVerification', FILTER_SANITIZE_SPECIAL_CHARS); - - $userId = filter_input(INPUT_GET, 'userId', FILTER_SANITIZE_SPECIAL_CHARS); - $userName = filter_input(INPUT_GET, 'userName', FILTER_SANITIZE_SPECIAL_CHARS); - $userDisplayName = filter_input(INPUT_GET, 'userDisplayName', FILTER_SANITIZE_SPECIAL_CHARS); + $userVerification = 'preferred'; + $userName = preg_replace('/[^0-9a-z_]/i', '', $_SESSION["username"] ?? ""); + if ($userName === "") { + throw new Exception('Missing account session.'); + } + $userId = bin2hex($userName); + $userDisplayName = $userName; $userId = preg_replace('/[^0-9a-f]/i', '', $userId); - $userName = preg_replace('/[^0-9a-z]/i', '', $_SESSION["username"]); $userDisplayName = preg_replace('/[^0-9a-z öüäéèàÖÜÄÉÈÀÂÊÎÔÛâêîôû]/i', '', $userDisplayName); $post = trim(file_get_contents('php://input')); @@ -96,6 +100,7 @@ try { // Handle different functions if ($fn === 'getCreateArgs') { + check_rate_limit($conn, 'passkey_register_args', 10, 60 * 60, $userName); $createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment); header('Content-Type: application/json'); @@ -103,6 +108,7 @@ try { // save challange to session. you have to deliver it to processGet later. $_SESSION['challenge'] = $WebAuthn->getChallenge(); + $_SESSION['passkey_register_username'] = $userName; } else if ($fn === 'getGetArgs') { $ids = []; @@ -137,6 +143,15 @@ try { // save challange to session. you have to deliver it to processGet later. $_SESSION['challenge'] = $WebAuthn->getChallenge(); } else if ($fn === 'processCreate') { + check_rate_limit($conn, 'passkey_register_process', 5, 60 * 60, $userName); + if (empty($_SESSION['challenge']) || ($_SESSION['passkey_register_username'] ?? '') !== $userName) { + throw new Exception('Invalid passkey session.'); + } + foreach (['clientDataJSON', 'attestationObject'] as $requiredField) { + if (!is_object($post) || empty($post->{$requiredField})) { + throw new Exception('Invalid passkey response.'); + } + } // Process create $challenge = $_SESSION['challenge']; $clientDataJSON = base64_decode($post->clientDataJSON); @@ -151,8 +166,15 @@ try { $data->userDisplayName = $userDisplayName; // Store registration data in the database + $credentialId = $data->credentialId; + $credentialPublicKey = $data->credentialPublicKey; + $signatureCounter = (int)$data->signatureCounter; $stmt = $conn->prepare("UPDATE users set credential_id = ?, public_key = ?, counter = ?, auth_method_enabled_passkey = 1, auth_method_required_passkey = 1 WHERE username = ?"); - $stmt->execute([ $data->credentialId, $data->credentialPublicKey, $data->signatureCounter,$userName]); + $stmt->bind_param("ssis", $credentialId, $credentialPublicKey, $signatureCounter, $userName); + $stmt->execute(); + $stmt->close(); + unset($_SESSION['challenge'], $_SESSION['passkey_register_username']); + clear_rate_limit($conn, 'passkey_register_process', $userName); $msg = 'registration success.'; $return = new stdClass(); @@ -165,7 +187,7 @@ try { } catch (Throwable $ex) { $return = new stdClass(); $return->success = false; - $return->msg = $ex->getMessage(); + $return->msg = 'Passkey registration failed.'; header('Content-Type: application/json'); print(json_encode($return)); diff --git a/app-code/api/auth/check_auth_key.php b/app-code/api/auth/check_auth_key.php index 1543958..38bf315 100644 --- a/app-code/api/auth/check_auth_key.php +++ b/app-code/api/auth/check_auth_key.php @@ -1,7 +1,9 @@ ?;"; $stmt = mysqli_prepare($conn, $sql); -mysqli_stmt_bind_param($stmt, 'si', $auth_key,$now); +mysqli_stmt_bind_param($stmt, 'si', $auth_key_hash,$now); mysqli_stmt_execute($stmt); mysqli_stmt_store_result($stmt); //if auth key is valid @@ -48,7 +51,7 @@ if(mysqli_stmt_num_rows($stmt) == 1){ //remove auth key $sql="DELETE FROM auth_tokens WHERE auth_token = ?;"; $stmt = mysqli_prepare($conn, $sql); - mysqli_stmt_bind_param($stmt, 's', $auth_key); + mysqli_stmt_bind_param($stmt, 's', $auth_key_hash); mysqli_stmt_execute($stmt); echo(json_encode($data)); }else{ diff --git a/app-code/api/login/check_mfa.php b/app-code/api/login/check_mfa.php index 5d7b7b5..d9197ca 100644 --- a/app-code/api/login/check_mfa.php +++ b/app-code/api/login/check_mfa.php @@ -14,6 +14,7 @@ include "../utils/generate_pin.php"; $conn = new mysqli($DB_SERVERNAME, $DB_USERNAME, $DB_PASSWORD, $DB_DATABASE); $username=$_SESSION["username"]; +check_rate_limit($conn, 'login_mfa', 5, 10 * 60, $username); $sql="SELECT 2fa FROM users WHERE username = ?"; $stmt = mysqli_prepare($conn, $sql); mysqli_stmt_bind_param($stmt, 's', $username); @@ -28,6 +29,7 @@ $twofa_pin=$_POST["twofa_pin"] ?? ""; if($twofa_secret !== "" && hash_equals(generateTOTP($twofa_secret), $twofa_pin)){ $_SESSION["mfa_authenticated"]=1; session_regenerate_id(true); + clear_rate_limit($conn, 'login_mfa', $username); $data = [ 'status' => 'success' ]; diff --git a/app-code/api/login/check_passkey.php b/app-code/api/login/check_passkey.php index 1874db9..674fa2a 100644 --- a/app-code/api/login/check_passkey.php +++ b/app-code/api/login/check_passkey.php @@ -15,15 +15,19 @@ if ($conn->connect_error) { try { // read get argument and post body $fn = filter_input(INPUT_GET, 'fn'); + if (!in_array($fn, ['getGetArgs', 'processGet'], true)) { + throw new Exception('Invalid passkey operation.'); + } $requireResidentKey = !!filter_input(INPUT_GET, 'requireResidentKey'); - $userVerification = filter_input(INPUT_GET, 'userVerification', FILTER_SANITIZE_SPECIAL_CHARS); - - $userId = filter_input(INPUT_GET, 'userId', FILTER_SANITIZE_SPECIAL_CHARS); - $userName = filter_input(INPUT_GET, 'userName', FILTER_SANITIZE_SPECIAL_CHARS); - $userDisplayName = filter_input(INPUT_GET, 'userDisplayName', FILTER_SANITIZE_SPECIAL_CHARS); + $userVerification = 'preferred'; + $userName = preg_replace('/[^0-9a-z_]/i', '', $_SESSION["username"] ?? ""); + if ($userName === "") { + throw new Exception('Missing login session.'); + } + $userId = bin2hex($userName); + $userDisplayName = $userName; $userId = preg_replace('/[^0-9a-f]/i', '', $userId); - $userName = preg_replace('/[^0-9a-z]/i', '', $userName); $userDisplayName = preg_replace('/[^0-9a-z öüäéèàÖÜÄÉÈÀÂÊÎÔÛâêîôû]/i', '', $userDisplayName); $post = trim(file_get_contents('php://input')); @@ -88,6 +92,7 @@ try { // Save challenge to session or somewhere else if needed } else if ($fn === 'getGetArgs') { + check_rate_limit($conn, 'passkey_get_args', 10, 5 * 60, $userName); $ids = []; //get registrations form user table @@ -100,11 +105,12 @@ try { $row = $registration->fetch_assoc(); - if ($registration->num_rows <= 0) { + if ($registration->num_rows <= 0 || empty($row["credential_id"])) { throw new Exception('User does not exist'); } $_SESSION["registrations"]["credentialId"]=$row["credential_id"]; + $_SESSION["registrations"]["username"]=$userName; $ids[]=$row["credential_id"]; $_SESSION["registrations"]["userId"]=$userId; @@ -118,16 +124,28 @@ try { $_SESSION['challenge'] = $WebAuthn->getChallenge(); }else if ($fn === 'processGet') { + check_rate_limit($conn, 'passkey_process', 5, 10 * 60, $userName); + if (empty($_SESSION["challenge"]) || empty($_SESSION["registrations"]["credentialId"]) || ($_SESSION["registrations"]["username"] ?? "") !== $userName) { + throw new Exception('Invalid passkey session.'); + } + foreach (['id', 'clientDataJSON', 'authenticatorData', 'signature'] as $requiredField) { + if (!is_object($post) || empty($post->{$requiredField})) { + throw new Exception('Invalid passkey response.'); + } + } // Process get // Retrieve registration data from the database based on credential ID $id = base64_decode($post->id); - $stmt = $conn->prepare("SELECT * FROM users WHERE credential_id = ?"); - $stmt->bind_param("s", $_SESSION["registrations"]["credentialId"]); + if (!hash_equals($_SESSION["registrations"]["credentialId"], $id)) { + throw new Exception('Invalid credential.'); + } + $stmt = $conn->prepare("SELECT public_key, counter FROM users WHERE credential_id = ? AND username = ?"); + $stmt->bind_param("ss", $_SESSION["registrations"]["credentialId"], $userName); $stmt->execute(); $registration = $stmt->get_result(); $row = $registration->fetch_assoc(); - if (!$registration) { + if (!$row) { throw new Exception('Public Key for credential ID not found!'); } @@ -139,7 +157,14 @@ try { $credentialPublicKey = $row['public_key']; // Process the get request - $WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, $userVerification === 'required'); + $WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, (int)$row['counter'], $userVerification === 'required'); + $newCounter = $WebAuthn->getSignatureCounter(); + if ($newCounter !== null) { + $stmt = $conn->prepare("UPDATE users SET counter = ? WHERE username = ?"); + $stmt->bind_param("is", $newCounter, $userName); + $stmt->execute(); + $stmt->close(); + } // Authentication success //set sessionso user is authenticated @@ -147,6 +172,8 @@ try { $_SESSION["pw_authenticated"]=1; $_SESSION["passkey_authenticated"]=1; session_regenerate_id(true); + unset($_SESSION["challenge"], $_SESSION["registrations"]); + clear_rate_limit($conn, 'passkey_process', $userName); $return = new stdClass(); $return->success = true; @@ -158,7 +185,7 @@ try { } catch (Throwable $ex) { $return = new stdClass(); $return->success = false; - $return->msg = $ex->getMessage(); + $return->msg = 'Passkey authentication failed.'; header('Content-Type: application/json'); print(json_encode($return)); diff --git a/app-code/api/login/check_pw.php b/app-code/api/login/check_pw.php index 3f58973..56d2be3 100644 --- a/app-code/api/login/check_pw.php +++ b/app-code/api/login/check_pw.php @@ -13,6 +13,7 @@ include "../../config/config.php"; $conn = new mysqli($DB_SERVERNAME, $DB_USERNAME, $DB_PASSWORD, $DB_DATABASE); $username=$_SESSION["username"]; +check_rate_limit($conn, 'login_pw', 5, 15 * 60, $username); $sql="SELECT password,pepper FROM users WHERE username = ?"; $stmt = mysqli_prepare($conn, $sql); mysqli_stmt_bind_param($stmt, 's', $username); @@ -28,6 +29,7 @@ $password=$_POST["password"] ?? ""; if($pw !== "" && password_verify($password.$pepper,$pw)){ $_SESSION["pw_authenticated"]=1; session_regenerate_id(true); + clear_rate_limit($conn, 'login_pw', $username); $data = [ 'status' => 'success' ]; diff --git a/app-code/api/login/redirect.php b/app-code/api/login/redirect.php index 8ce7311..1e8e9ae 100644 --- a/app-code/api/login/redirect.php +++ b/app-code/api/login/redirect.php @@ -49,9 +49,10 @@ else if ($_SESSION["needs_auth"]===false && $_SESSION["mfa_authenticated"]==1 && $user_id=$_SESSION["id"]; $valid_until=time()+(15*60); $auth_token=bin2hex(random_bytes(128)); + $auth_token_hash=auth_token_hash($auth_token); $sql="INSERT INTO auth_tokens (auth_token,user_id, valid_until) VALUES(?,?,?);"; $stmt = mysqli_prepare($conn, $sql); - mysqli_stmt_bind_param($stmt, 'sii', $auth_token,$user_id,$valid_until); + mysqli_stmt_bind_param($stmt, 'sii', $auth_token_hash,$user_id,$valid_until); mysqli_stmt_execute($stmt); mysqli_stmt_close($stmt); if(!empty($send_to)){ diff --git a/app-code/api/login/reset_pw.php b/app-code/api/login/reset_pw.php index 8cf04f1..9e2bec8 100644 --- a/app-code/api/login/reset_pw.php +++ b/app-code/api/login/reset_pw.php @@ -15,13 +15,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $conn = new mysqli($DB_SERVERNAME, $DB_USERNAME, $DB_PASSWORD, $DB_DATABASE); $token = $_POST['token']; + $token_hash = auth_token_hash($token); $user_id=""; $valid_until=0; $password = $_POST['password']; $confirmPassword = $_POST['confirm_password']; $sql="SELECT user_id, valid_until FROM reset_tokens WHERE auth_token=?;"; + check_rate_limit($conn, 'reset_pw', 5, 60 * 60, $token_hash); $stmt = mysqli_prepare($conn, $sql); - mysqli_stmt_bind_param($stmt, 's', $token); + mysqli_stmt_bind_param($stmt, 's', $token_hash); mysqli_stmt_execute($stmt); mysqli_stmt_store_result($stmt); mysqli_stmt_bind_result($stmt, $user_id,$valid_until); @@ -47,6 +49,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($update_stmt = $conn->prepare($update_sql)) { $update_stmt->bind_param("ssi", $hashed_password, $new_pepper, $user_id); if ($update_stmt->execute()) { + clear_rate_limit($conn, 'reset_pw', $token_hash); echo json_encode(['status' => 'success','success' => true, 'message' => 'Password updated successfully.']); } else { echo json_encode(['success' => false, 'message' => 'Failed to update password.']); @@ -62,7 +65,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { //remove token $sql="DELETE FROM reset_tokens WHERE auth_token = ?;"; $stmt = mysqli_prepare($conn, $sql); - mysqli_stmt_bind_param($stmt, 's', $token); + mysqli_stmt_bind_param($stmt, 's', $token_hash); mysqli_stmt_execute($stmt); mysqli_stmt_close($stmt); diff --git a/app-code/api/login/send_reset_link.php b/app-code/api/login/send_reset_link.php index 8368782..cf57d76 100644 --- a/app-code/api/login/send_reset_link.php +++ b/app-code/api/login/send_reset_link.php @@ -11,12 +11,13 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { include "../../config/config.php"; include "../utils/get_location.php"; $username=$_SESSION["username"] ?? ""; +$conn = new mysqli($DB_SERVERNAME, $DB_USERNAME, $DB_PASSWORD, $DB_DATABASE); +check_rate_limit($conn, 'send_reset_link', 3, 60 * 60, $username); if ($username === "") { echo json_encode(['success' => false, 'message' => 'Missing username.']); exit; } $sql="SELECT id, email, telegram_id FROM users WHERE username = ?;"; -$conn = new mysqli($DB_SERVERNAME, $DB_USERNAME, $DB_PASSWORD, $DB_DATABASE); $mail=""; $id=""; $telegram_id=""; @@ -40,6 +41,7 @@ $ip=trim(explode(",",$forwarded_for)[0]); $location=get_location_from_ip($ip); $date=date('Y-m-d H:i:s'); $token=bin2hex(random_bytes(128)); +$token_hash=auth_token_hash($token); $link="https://auth.jakach.ch/login/reset_pw.php?token=$token"; $message = "*Password reset token*\n\n" @@ -648,7 +650,7 @@ if(!empty($mail)){ $valid_until=time()+(12 * 60 * 60); $sql="INSERT INTO reset_tokens (auth_token, user_id,valid_until) VALUES (?,?,?);"; $stmt = mysqli_prepare($conn, $sql); -mysqli_stmt_bind_param($stmt, 'sii', $token,$id,$valid_until); +mysqli_stmt_bind_param($stmt, 'sii', $token_hash,$id,$valid_until); mysqli_stmt_execute($stmt); mysqli_stmt_close($stmt); diff --git a/app-code/api/login/set_username.php b/app-code/api/login/set_username.php index 9f48a14..ab03182 100644 --- a/app-code/api/login/set_username.php +++ b/app-code/api/login/set_username.php @@ -6,6 +6,9 @@ require_csrf_token(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { json_response(['success' => false, 'message' => 'Invalid request method.'], 405); } +include "../../config/config.php"; +$conn = new mysqli($DB_SERVERNAME, $DB_USERNAME, $DB_PASSWORD, $DB_DATABASE); +check_rate_limit($conn, 'set_username', 30, 60); $_SESSION["needs_auth"]=true; $_SESSION["logged_in"]=false; $username = strtolower((string) ($_POST["username"] ?? "")); diff --git a/app-code/api/register/register_user.php b/app-code/api/register/register_user.php index 4edce3a..2a0e4cd 100644 --- a/app-code/api/register/register_user.php +++ b/app-code/api/register/register_user.php @@ -9,6 +9,7 @@ header('Content-Type: application/json'); include "../../config/config.php"; // Connect to the database $conn = new mysqli($DB_SERVERNAME, $DB_USERNAME, $DB_PASSWORD, $DB_DATABASE); +check_rate_limit($conn, 'register', 5, 60 * 60); // Check the connection if ($conn === false) { diff --git a/app-code/api/utils/security.php b/app-code/api/utils/security.php index de3dfe8..56737b8 100644 --- a/app-code/api/utils/security.php +++ b/app-code/api/utils/security.php @@ -99,6 +99,150 @@ function remember_token_hash(string $token): string return hash('sha256', $token); } +function auth_token_hash(string $token): string +{ + return hash('sha256', $token); +} + +function client_ip(): string +{ + return $_SERVER['REMOTE_ADDR'] ?? 'unknown'; +} + +function ensure_rate_limit_table(mysqli $conn): void +{ + static $created = false; + if ($created) { + return; + } + + $conn->query("CREATE TABLE IF NOT EXISTS rate_limits ( + id VARCHAR(64) PRIMARY KEY, + attempts INT NOT NULL, + reset_at INT NOT NULL + )"); + $created = true; +} + +function redis_rate_limit_connection() +{ + static $redis = null; + static $checked = false; + + if ($checked) { + return $redis; + } + $checked = true; + + if (!class_exists('Redis')) { + return null; + } + + $host = $GLOBALS['REDIS_HOST'] ?? getenv('REDIS_HOST') ?: null; + if (!$host) { + $host = 'jakach-login-redis'; + } + $port = $GLOBALS['REDIS_PORT'] ?? getenv('REDIS_PORT') ?: null; + $port = $port ? (int)$port : 6379; + + try { + $candidate = new Redis(); + if (!$candidate->connect($host, $port, 0.2)) { + return null; + } + $redis = $candidate; + } catch (Throwable $ex) { + $redis = null; + } + + return $redis; +} + +function rate_limit_key(string $bucket, string $identifier = ''): string +{ + return hash('sha256', $bucket . '|' . client_ip() . '|' . strtolower($identifier)); +} + +function check_rate_limit(mysqli $conn, string $bucket, int $max_attempts, int $window_seconds, string $identifier = ''): void +{ + $redis = redis_rate_limit_connection(); + if ($redis !== null) { + $key = 'rl:' . rate_limit_key($bucket, $identifier); + $attempts = (int) $redis->incr($key); + if ($attempts === 1) { + $redis->expire($key, $window_seconds); + } + if ($attempts > $max_attempts) { + json_response([ + 'success' => false, + 'status' => 'failure', + 'message' => 'Too many attempts. Please try again later.' + ], 429); + } + return; + } + + ensure_rate_limit_table($conn); + + $now = time(); + $key = rate_limit_key($bucket, $identifier); + $attempts = 0; + $reset_at = 0; + + $sql = "SELECT attempts, reset_at FROM rate_limits WHERE id = ?"; + $stmt = mysqli_prepare($conn, $sql); + mysqli_stmt_bind_param($stmt, 's', $key); + mysqli_stmt_execute($stmt); + mysqli_stmt_store_result($stmt); + mysqli_stmt_bind_result($stmt, $attempts, $reset_at); + $found = mysqli_stmt_fetch($stmt); + mysqli_stmt_close($stmt); + + if (!$found || $reset_at <= $now) { + $attempts = 1; + $reset_at = $now + $window_seconds; + $sql = "REPLACE INTO rate_limits (id, attempts, reset_at) VALUES (?, ?, ?)"; + $stmt = mysqli_prepare($conn, $sql); + mysqli_stmt_bind_param($stmt, 'sii', $key, $attempts, $reset_at); + mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); + return; + } + + if ($attempts >= $max_attempts) { + json_response([ + 'success' => false, + 'status' => 'failure', + 'message' => 'Too many attempts. Please try again later.' + ], 429); + } + + $attempts++; + $sql = "UPDATE rate_limits SET attempts = ? WHERE id = ?"; + $stmt = mysqli_prepare($conn, $sql); + mysqli_stmt_bind_param($stmt, 'is', $attempts, $key); + mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); +} + +function clear_rate_limit(mysqli $conn, string $bucket, string $identifier = ''): void +{ + $redis = redis_rate_limit_connection(); + if ($redis !== null) { + $redis->del('rl:' . rate_limit_key($bucket, $identifier)); + return; + } + + ensure_rate_limit_table($conn); + + $key = rate_limit_key($bucket, $identifier); + $sql = "DELETE FROM rate_limits WHERE id = ?"; + $stmt = mysqli_prepare($conn, $sql); + mysqli_stmt_bind_param($stmt, 's', $key); + mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); +} + function set_secure_cookie(string $name, string $value, int $expires): void { $is_https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') diff --git a/app-code/config/config.php b/app-code/config/config.php index 01868f0..a47a056 100644 --- a/app-code/config/config.php +++ b/app-code/config/config.php @@ -6,4 +6,6 @@ $TELEGRAM_BOT_API=""; $SENDGRID_KEY=""; $SENDGRID_MAIL=""; + $REDIS_HOST="jakach-login-redis"; + $REDIS_PORT=6379; ?> diff --git a/docker-compose.yml b/docker-compose.yml index 33c8820..eef54fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.3' - services: jakach-login-db: image: yobasystems/alpine-mariadb:latest @@ -13,6 +11,15 @@ services: volumes: - jakach-login-db-storage:/var/lib/mysql + jakach-login-redis: + image: redis:7-alpine + container_name: jakach-login-redis + restart: unless-stopped + command: ["redis-server", "--save", "", "--appendonly", "no"] + networks: + jakach-login-network: + ipv4_address: 192.168.5.4 + jakach-login-srv: build: context: . @@ -28,6 +35,7 @@ services: - "447:80" depends_on: - jakach-login-db + - jakach-login-redis volumes: - ./app-code:/var/www/html - ./apache-conf:/etc/apache2/sites-available diff --git a/srv_dockerfile b/srv_dockerfile index 8f65d2d..cabab47 100644 --- a/srv_dockerfile +++ b/srv_dockerfile @@ -3,7 +3,9 @@ FROM php:apache # Install necessary PHP extensions and tools RUN apt-get update && \ apt-get install -y libzip-dev zip zlib1g-dev git unzip && \ - docker-php-ext-install mysqli zip + docker-php-ext-install mysqli zip && \ + pecl install redis && \ + docker-php-ext-enable redis # Enable SSL module for Apache RUN a2enmod ssl