commit cbbc60533683124a98e08a1ce9d6550d30b2cf71 Author: Jester Date: Mon Jan 19 05:35:37 2026 +0300 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..175e84d --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Auth Form (PHP drop-in) + +Современная форма авторизации с проверкой доступа к сайту через MariaDB. +Файлы можно копировать прямо в папку существующего сайта. + +## Требования + +- PHP 7.4+ (mysqli, sessions) +- MariaDB + +## Установка + +1. Создайте базу и таблицы: + - Запустите SQL из `db/schema.sql` +2. Заполните подключение к БД: + - Отредактируйте `auth/config.php` +3. Добавьте пользователя и доступ: + - `INSERT INTO users (login, password_hash) VALUES ('demo', '');` + - `INSERT INTO user_access (user_id, site_key) VALUES (1, 'example.com');` + +## Хэш пароля + +Создать bcrypt-хэш можно так: + +```php + + +``` + +3. Главную страницу не защищайте — вход находится в `/auth/login.html`. +4. Если страницы могут быть PHP, используйте серверную защиту: + +```php + $clientIp, + "siteKey" => $siteKey, + "status" => "invalid_payload", + ]); + auth_json_response(400, ["ok" => false, "message" => "Неверные данные."]); +} + +$userId = (int) ($_SESSION["auth_user_id"] ?? 0); +if ($userId <= 0) { + auth_json_response(401, ["ok" => false, "message" => "Требуется вход."]); +} + +$db = auth_get_db(); +$stmt = $db->prepare("SELECT 1 FROM user_access WHERE user_id = ? AND site_key = ? LIMIT 1"); +$stmt->bind_param("is", $userId, $siteKey); +$stmt->execute(); +$result = $stmt->get_result(); +$hasAccess = $result && $result->num_rows > 0; +$stmt->close(); + +if (!$hasAccess) { + auth_log_event([ + "ip" => $clientIp, + "userId" => $userId, + "siteKey" => $siteKey, + "status" => "access_denied", + ]); + auth_json_response(403, ["ok" => false, "message" => "Нет доступа."]); +} + +auth_json_response(200, ["ok" => true]); diff --git a/auth/api/login.php b/auth/api/login.php new file mode 100644 index 0000000..2c66d68 --- /dev/null +++ b/auth/api/login.php @@ -0,0 +1,87 @@ + $clientIp, + "login" => $login, + "siteKey" => $siteKey, + "status" => "invalid_payload", + ]); + auth_json_response(400, ["ok" => false, "message" => "Неверные данные."]); +} + +$rate = auth_check_rate_limit($clientIp); +if ($rate["limited"]) { + auth_log_event([ + "ip" => $clientIp, + "login" => $login, + "siteKey" => $siteKey, + "status" => "rate_limited", + ]); + header("Retry-After: " . (string) $rate["retry_after"]); + auth_json_response(429, ["ok" => false, "message" => "Слишком много попыток. Попробуйте позже."]); +} + +$db = auth_get_db(); +$stmt = $db->prepare("SELECT id, password_hash FROM users WHERE login = ? LIMIT 1"); +$stmt->bind_param("s", $login); +$stmt->execute(); +$result = $stmt->get_result(); +$row = $result ? $result->fetch_assoc() : null; +$stmt->close(); + +$hash = $row["password_hash"] ?? password_hash("invalid_password", PASSWORD_BCRYPT); +$passwordOk = password_verify($password, $hash); + +if (!$row || !$passwordOk) { + auth_log_event([ + "ip" => $clientIp, + "login" => $login, + "siteKey" => $siteKey, + "status" => "invalid_credentials", + ]); + auth_json_response(401, ["ok" => false, "message" => "Неверный логин или пароль."]); +} + +$stmt = $db->prepare("SELECT 1 FROM user_access WHERE user_id = ? AND site_key = ? LIMIT 1"); +$stmt->bind_param("is", $row["id"], $siteKey); +$stmt->execute(); +$accessResult = $stmt->get_result(); +$hasAccess = $accessResult && $accessResult->num_rows > 0; +$stmt->close(); + +if (!$hasAccess) { + auth_log_event([ + "ip" => $clientIp, + "login" => $login, + "siteKey" => $siteKey, + "status" => "access_denied", + ]); + auth_json_response(403, ["ok" => false, "message" => "Нет доступа к этому сайту."]); +} + +$_SESSION["auth_user_id"] = (int) $row["id"]; +$_SESSION["auth_login"] = $login; +$_SESSION["auth_time"] = time(); + +auth_log_event([ + "ip" => $clientIp, + "login" => $login, + "siteKey" => $siteKey, + "status" => "success", +]); + +auth_json_response(200, ["ok" => true]); diff --git a/auth/config.php b/auth/config.php new file mode 100644 index 0000000..ba7df4b --- /dev/null +++ b/auth/config.php @@ -0,0 +1,19 @@ + [ + "host" => "db", + "port" => 3306, + "user" => "root", + "password" => "rootpass", + "name" => "auth_db", + ], + "rate_limit" => [ + "window_seconds" => 300, + "max_attempts" => 10, + ], + "logging" => [ + "dir" => __DIR__ . "/logs", + "file" => __DIR__ . "/logs/auth.log", + ], +]; diff --git a/auth/config_nas.php b/auth/config_nas.php new file mode 100644 index 0000000..e2b4b75 --- /dev/null +++ b/auth/config_nas.php @@ -0,0 +1,19 @@ + [ + "host" => "127.0.0.1", + "port" => 3306, + "user" => "root", + "password" => "", + "name" => "auth_db", + ], + "rate_limit" => [ + "window_seconds" => 300, + "max_attempts" => 10, + ], + "logging" => [ + "dir" => __DIR__ . "/logs", + "file" => __DIR__ . "/logs/auth.log", + ], +]; diff --git a/auth/db.php b/auth/db.php new file mode 100644 index 0000000..c3feb29 --- /dev/null +++ b/auth/db.php @@ -0,0 +1,17 @@ +connect_errno) { + http_response_code(500); + echo json_encode(["ok" => false, "message" => "Ошибка сервера."]); + exit; + } + + $mysqli->set_charset("utf8mb4"); + return $mysqli; +} diff --git a/auth/demo.html b/auth/demo.html new file mode 100644 index 0000000..02c5228 --- /dev/null +++ b/auth/demo.html @@ -0,0 +1,24 @@ + + + + + + + Демо защищенной страницы + + + + +
+
+
+

Демо защищенной страницы

+

+ Если вы видите эту страницу, доступ разрешен. +

+
+ Перейти к форме входа +
+
+ + diff --git a/auth/guard.js b/auth/guard.js new file mode 100644 index 0000000..d01db12 --- /dev/null +++ b/auth/guard.js @@ -0,0 +1,35 @@ +function authGetSiteKey() { + const meta = document.querySelector('meta[name="site-key"]'); + if (meta && meta.content) return meta.content.trim(); + const bodyKey = document.body?.dataset?.siteKey; + if (bodyKey) return bodyKey.trim(); + return window.location.hostname || "unknown"; +} + +function authBuildLoginUrl(siteKey) { + const current = window.location.pathname + window.location.search; + const params = new URLSearchParams({ + siteKey, + redirect: current, + }); + return `/auth/login.html?${params.toString()}`; +} + +async function authCheckAccess() { + const siteKey = authGetSiteKey(); + try { + const response = await fetch(`/auth/api/check.php?siteKey=${encodeURIComponent(siteKey)}`, { + method: "GET", + headers: { "Accept": "application/json" }, + credentials: "same-origin", + }); + + if (response.ok) return; + + window.location.href = authBuildLoginUrl(siteKey); + } catch (error) { + window.location.href = authBuildLoginUrl(siteKey); + } +} + +authCheckAccess(); diff --git a/auth/guard.php b/auth/guard.php new file mode 100644 index 0000000..31bb05c --- /dev/null +++ b/auth/guard.php @@ -0,0 +1,46 @@ + auth_get_client_ip(), + "siteKey" => $siteKey, + "status" => "invalid_payload", + ]); + header("Location: /auth/login.html"); + exit; +} + +$userId = (int) ($_SESSION["auth_user_id"] ?? 0); +if ($userId <= 0) { + $redirect = $_SERVER["REQUEST_URI"] ?? "/"; + header("Location: /auth/login.html?siteKey=" . urlencode($siteKey) . "&redirect=" . urlencode($redirect)); + exit; +} + +$db = auth_get_db(); +$stmt = $db->prepare("SELECT 1 FROM user_access WHERE user_id = ? AND site_key = ? LIMIT 1"); +$stmt->bind_param("is", $userId, $siteKey); +$stmt->execute(); +$result = $stmt->get_result(); +$hasAccess = $result && $result->num_rows > 0; +$stmt->close(); + +if (!$hasAccess) { + auth_log_event([ + "ip" => auth_get_client_ip(), + "userId" => $userId, + "siteKey" => $siteKey, + "status" => "access_denied", + ]); + header("Location: /auth/login.html?siteKey=" . urlencode($siteKey)); + exit; +} diff --git a/auth/logger.php b/auth/logger.php new file mode 100644 index 0000000..e5c6644 --- /dev/null +++ b/auth/logger.php @@ -0,0 +1,30 @@ + + + + + + + Вход + + + +
+
+
+

Добро пожаловать

+

Войдите, чтобы продолжить

+
+ +
+ + + + + + +
+
+
+
+ + + diff --git a/auth/login.js b/auth/login.js new file mode 100644 index 0000000..29c6671 --- /dev/null +++ b/auth/login.js @@ -0,0 +1,64 @@ +const form = document.getElementById("login-form"); +const messageEl = document.getElementById("form-message"); + +function getSiteKey() { + const params = new URLSearchParams(window.location.search); + const urlKey = params.get("siteKey"); + if (urlKey) return urlKey.trim(); + const meta = document.querySelector('meta[name="site-key"]'); + if (meta && meta.content) return meta.content.trim(); + return window.location.hostname || "unknown"; +} + +function getRedirectUrl() { + const params = new URLSearchParams(window.location.search); + return params.get("redirect") || "/"; +} + +function setMessage(text, type) { + messageEl.textContent = text; + messageEl.classList.remove("form__message--error", "form__message--success"); + if (type === "error") messageEl.classList.add("form__message--error"); + if (type === "success") messageEl.classList.add("form__message--success"); +} + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + const formData = new FormData(form); + const login = String(formData.get("login") || "").trim(); + const password = String(formData.get("password") || ""); + const siteKey = getSiteKey(); + + if (!login || !password) { + setMessage("Введите логин и пароль.", "error"); + return; + } + + setMessage("Проверяем данные...", ""); + const submitButton = form.querySelector("button[type='submit']"); + submitButton.disabled = true; + + try { + const response = await fetch("./api/login.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ login, password, siteKey }), + }); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + setMessage(data?.message || "Ошибка входа.", "error"); + return; + } + + setMessage("Доступ разрешен.", "success"); + const redirect = getRedirectUrl(); + setTimeout(() => { + window.location.href = redirect; + }, 400); + } catch (error) { + setMessage("Сеть недоступна.", "error"); + } finally { + submitButton.disabled = false; + } +}); diff --git a/auth/rate_limit.php b/auth/rate_limit.php new file mode 100644 index 0000000..69c1c89 --- /dev/null +++ b/auth/rate_limit.php @@ -0,0 +1,55 @@ + 1, + "reset_at" => $now + $window, + ]; + + if (is_file($file)) { + $raw = @file_get_contents($file); + $decoded = $raw ? json_decode($raw, true) : null; + if (is_array($decoded) && isset($decoded["count"], $decoded["reset_at"])) { + if ($now <= (int) $decoded["reset_at"]) { + $decoded["count"] = (int) $decoded["count"] + 1; + $data = $decoded; + } + } + } + + $limited = $data["count"] > $max; + $retryAfter = max(0, $data["reset_at"] - $now); + + $payload = json_encode($data); + if ($payload !== false) { + $fh = @fopen($file, "cb+"); + if ($fh !== false) { + if (flock($fh, LOCK_EX)) { + ftruncate($fh, 0); + fwrite($fh, $payload); + fflush($fh); + flock($fh, LOCK_UN); + } + fclose($fh); + } + } + + return [ + "limited" => $limited, + "retry_after" => $retryAfter, + ]; +} diff --git a/auth/styles.css b/auth/styles.css new file mode 100644 index 0000000..90cb6aa --- /dev/null +++ b/auth/styles.css @@ -0,0 +1,117 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + color: #0b1320; + background: radial-gradient(circle at top, #f0f4ff 0%, #e4ecff 45%, #dfe8ff 100%); + min-height: 100vh; +} + +.page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 40px 16px; +} + +.card { + width: min(420px, 100%); + background: rgba(255, 255, 255, 0.75); + backdrop-filter: blur(16px); + border-radius: 20px; + padding: 32px; + box-shadow: 0 20px 60px rgba(25, 42, 80, 0.15); + border: 1px solid rgba(255, 255, 255, 0.6); +} + +.card__header { + margin-bottom: 24px; +} + +.card__header h1 { + margin: 0 0 8px; + font-size: 26px; + font-weight: 700; +} + +.card__subtitle { + margin: 0; + color: #51607a; + font-size: 14px; +} + +.form { + display: grid; + gap: 16px; +} + +.field { + display: grid; + gap: 8px; +} + +.field__label { + font-size: 13px; + color: #4c5a73; +} + +.field__input { + width: 100%; + border-radius: 12px; + border: 1px solid #d6def2; + padding: 12px 14px; + font-size: 15px; + transition: border 0.2s ease, box-shadow 0.2s ease; +} + +.field__input:focus { + outline: none; + border-color: #6f87ff; + box-shadow: 0 0 0 4px rgba(111, 135, 255, 0.2); +} + +.button { + display: inline-block; + text-decoration: none; + text-align: center; + border: none; + border-radius: 12px; + padding: 12px 16px; + font-size: 15px; + font-weight: 600; + color: #fff; + background: linear-gradient(120deg, #5b7cfa, #7b5bfa); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.button:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(91, 124, 250, 0.25); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.7; + box-shadow: none; +} + +.form__message { + min-height: 20px; + font-size: 13px; + color: #4c5a73; +} + +.form__message--error { + color: #b91c1c; +} + +.form__message--success { + color: #166534; +} diff --git a/auth/util.php b/auth/util.php new file mode 100644 index 0000000..f62a945 --- /dev/null +++ b/auth/util.php @@ -0,0 +1,53 @@ += 3 && $len <= 64; +} + +function auth_is_valid_password(string $password): bool +{ + $len = mb_strlen($password, "UTF-8"); + return $len >= 8 && $len <= 128; +} + +function auth_is_valid_site_key(string $siteKey): bool +{ + $len = mb_strlen($siteKey, "UTF-8"); + return $len >= 3 && $len <= 255; +} diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..a77a91e --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,32 @@ +CREATE DATABASE IF NOT EXISTS auth_db + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE auth_db; + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + login VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS user_access ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, + site_key VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY unique_access (user_id, site_key), + CONSTRAINT fk_user_access_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +) ENGINE=InnoDB; + +-- Пример: +-- INSERT INTO users (login, password_hash) +-- VALUES ('demo', '$2a$10$YOUR_BCRYPT_HASH_HERE'); +-- +-- INSERT INTO user_access (user_id, site_key) +-- VALUES (1, 'example.com'); diff --git a/docker-pack/Dockerfile b/docker-pack/Dockerfile new file mode 100644 index 0000000..96ac392 --- /dev/null +++ b/docker-pack/Dockerfile @@ -0,0 +1,7 @@ +FROM php:8.2-apache + +RUN docker-php-ext-install mysqli + +RUN a2enmod rewrite headers + +WORKDIR /var/www/html diff --git a/docker-pack/README.md b/docker-pack/README.md new file mode 100644 index 0000000..c3e4ca5 --- /dev/null +++ b/docker-pack/README.md @@ -0,0 +1,56 @@ +# Docker инструкция (Windows) + +Все файлы для Docker лежат в `docker-pack/` и не трогают текущее решение. + +## Требования + +- Docker Desktop (включите WSL2 backend) + +## Быстрый старт + +1. Перейдите в каталог: + - `cd d:\work\code\auth\docker-pack` +2. Соберите и запустите контейнеры: + - `docker compose up -d --build` +3. Откройте в браузере: + - `http://localhost:8080/auth/login.html` + +## Настройка БД + +По умолчанию MariaDB поднимается с: + +- БД: `auth_db` +- root пароль: `rootpass` + +Схема создается автоматически из `../db/schema.sql`. +Пользователь `demo` добавляется автоматически из `seed.sql`. + +### Тестовые данные + +По умолчанию создается пользователь: + +- логин: `demo` +- пароль: `demo12345` +- site_key: `localhost` + +Если нужно изменить — отредактируйте `seed.sql` и пересоздайте контейнеры: + +- `docker compose down -v` +- `docker compose up -d --build` + +## Важно про конфиг PHP + +В `auth/config.php` укажите параметры подключения: + +- host: `db` +- port: `3306` +- user: `root` +- password: `rootpass` +- name: `auth_db` + +Если хотите оставить `auth/config.php` для локальной сети, заведите отдельную копию +только для Docker и подмените ее через volume в `docker-compose.yml`. + +## Остановка + +- `docker compose down` diff --git a/docker-pack/docker-compose.yml b/docker-pack/docker-compose.yml new file mode 100644 index 0000000..24e4167 --- /dev/null +++ b/docker-pack/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3.9" + +services: + web: + build: . + ports: + - "8080:80" + volumes: + - ../auth:/var/www/html/auth:ro + depends_on: + - db + + db: + image: mariadb:11 + environment: + MARIADB_ROOT_PASSWORD: rootpass + MARIADB_DATABASE: auth_db + volumes: + - ../db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + - ./seed.sql:/docker-entrypoint-initdb.d/02-seed.sql:ro + - db_data:/var/lib/mysql + +volumes: + db_data: diff --git a/docker-pack/seed.sql b/docker-pack/seed.sql new file mode 100644 index 0000000..a9b3545 --- /dev/null +++ b/docker-pack/seed.sql @@ -0,0 +1,5 @@ +INSERT INTO users (login, password_hash) +VALUES ('demo', '$2y$10$MyDhXOP.UZAHJcE1KWoQU.PJfxa5C82FePAuZYphCMaXya.Ylxlne'); + +INSERT INTO user_access (user_id, site_key) +VALUES (1, 'localhost');