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