331 lines
8.9 KiB
PHP
331 lines
8.9 KiB
PHP
<?php
|
|
|
|
function secure_session_start(): void
|
|
{
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
return;
|
|
}
|
|
|
|
$is_https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
|
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443);
|
|
|
|
session_set_cookie_params([
|
|
'lifetime' => 0,
|
|
'path' => '/',
|
|
'domain' => '',
|
|
'secure' => $is_https,
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
|
|
session_start();
|
|
}
|
|
|
|
function json_response(array $data, int $status_code = 200): void
|
|
{
|
|
http_response_code($status_code);
|
|
header('Content-Type: application/json');
|
|
echo json_encode($data);
|
|
exit;
|
|
}
|
|
|
|
function require_same_origin_request(): void
|
|
{
|
|
if (!in_array($_SERVER['REQUEST_METHOD'] ?? 'GET', ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
|
|
return;
|
|
}
|
|
|
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
|
$source = $_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_REFERER'] ?? '';
|
|
|
|
if ($source === '') {
|
|
return;
|
|
}
|
|
|
|
$source_host = parse_url($source, PHP_URL_HOST);
|
|
$source_port = parse_url($source, PHP_URL_PORT);
|
|
if ($source_host && $source_port) {
|
|
$source_host .= ':' . $source_port;
|
|
}
|
|
|
|
if (!$source_host || !hash_equals(strtolower($host), strtolower($source_host))) {
|
|
json_response(['success' => false, 'message' => 'Invalid request origin.'], 403);
|
|
}
|
|
}
|
|
|
|
function csrf_token(): string
|
|
{
|
|
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
return $_SESSION['csrf_token'];
|
|
}
|
|
|
|
function print_csrf_script(): void
|
|
{
|
|
echo '<script>window.csrfToken = ' . json_encode(csrf_token()) . ';</script>';
|
|
}
|
|
|
|
function require_csrf_token(): void
|
|
{
|
|
if (!in_array($_SERVER['REQUEST_METHOD'] ?? 'GET', ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
|
|
return;
|
|
}
|
|
|
|
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_POST['csrf_token'] ?? '';
|
|
if (empty($_SESSION['csrf_token']) || !is_string($token) || !hash_equals($_SESSION['csrf_token'], $token)) {
|
|
json_response(['success' => false, 'message' => 'Invalid CSRF token.'], 403);
|
|
}
|
|
}
|
|
|
|
function require_logged_in(): void
|
|
{
|
|
if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true || empty($_SESSION['id'])) {
|
|
json_response(['success' => false, 'message' => 'Not logged in'], 401);
|
|
}
|
|
}
|
|
|
|
function is_admin_session(): bool
|
|
{
|
|
return !empty($_SESSION['permissions'])
|
|
&& is_string($_SESSION['permissions'])
|
|
&& isset($_SESSION['permissions'][0])
|
|
&& $_SESSION['permissions'][0] === '1';
|
|
}
|
|
|
|
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')
|
|
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443);
|
|
|
|
setcookie($name, $value, [
|
|
'expires' => $expires,
|
|
'path' => '/',
|
|
'secure' => $is_https,
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
}
|
|
|
|
function delete_cookie(string $name): void
|
|
{
|
|
set_secure_cookie($name, '', time() - 3600);
|
|
}
|
|
|
|
function normalize_redirect_target(?string $target): string
|
|
{
|
|
$target = trim((string) $target);
|
|
if ($target === '') {
|
|
return '/account/';
|
|
}
|
|
|
|
if (preg_match('/[\r\n]/', $target)) {
|
|
return '/account/';
|
|
}
|
|
|
|
if (str_starts_with($target, '/') && !str_starts_with($target, '//')) {
|
|
return $target;
|
|
}
|
|
|
|
$parts = parse_url($target);
|
|
if (!$parts || empty($parts['scheme']) || empty($parts['host'])) {
|
|
return '/account/';
|
|
}
|
|
|
|
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
|
|
return '/account/';
|
|
}
|
|
|
|
return $target;
|
|
}
|
|
|
|
function append_auth_token_to_redirect(string $redirect, string $auth_token): string
|
|
{
|
|
$separator = str_contains($redirect, '?') ? '&' : '?';
|
|
return $redirect . $separator . 'auth=' . rawurlencode($auth_token);
|
|
}
|
|
|
|
function log_activity(mysqli $conn, int $user_id, string $action, string $details = ''): void
|
|
{
|
|
$forwarded_for = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
|
$ip = trim(explode(',', $forwarded_for)[0]);
|
|
$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
|
|
$sql = "INSERT INTO activity_log (user_id, action, ip, user_agent, details) VALUES (?, ?, ?, ?, ?)";
|
|
$stmt = mysqli_prepare($conn, $sql);
|
|
mysqli_stmt_bind_param($stmt, 'issss', $user_id, $action, $ip, $user_agent, $details);
|
|
mysqli_stmt_execute($stmt);
|
|
mysqli_stmt_close($stmt);
|
|
}
|
|
|
|
function is_external_domain(string $url): ?string
|
|
{
|
|
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
|
return null;
|
|
}
|
|
|
|
$host = parse_url($url, PHP_URL_HOST);
|
|
if ($host === null || $host === '') {
|
|
return null;
|
|
}
|
|
|
|
$host = strtolower($host);
|
|
if ($host === 'auth.jakach.ch' || str_ends_with($host, '.jakach.ch')) {
|
|
return null;
|
|
}
|
|
|
|
return $host;
|
|
}
|
|
|
|
?>
|