init
This commit is contained in:
52
README.md
Normal file
52
README.md
Normal file
@@ -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', '<HASH>');`
|
||||||
|
- `INSERT INTO user_access (user_id, site_key) VALUES (1, 'example.com');`
|
||||||
|
|
||||||
|
## Хэш пароля
|
||||||
|
|
||||||
|
Создать bcrypt-хэш можно так:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
echo password_hash("yourPassword", PASSWORD_BCRYPT);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Интеграция в сайт (NAS)
|
||||||
|
|
||||||
|
1. Скопируйте папку `auth` в корень сайта.
|
||||||
|
2. На защищаемых HTML-страницах (кроме главной) добавьте:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="site-key" content="example.com" />
|
||||||
|
<script src="/auth/guard.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Главную страницу не защищайте — вход находится в `/auth/login.html`.
|
||||||
|
4. Если страницы могут быть PHP, используйте серверную защиту:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
define("AUTH_SITE_KEY", "example.com");
|
||||||
|
require __DIR__ . "/auth/guard.php";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лимиты и логирование
|
||||||
|
|
||||||
|
- Ограничение попыток: 10 запросов за 5 минут на IP
|
||||||
|
- Логи авторизации: `auth/logs/auth.log` (JSONL-строки)
|
||||||
44
auth/api/check.php
Normal file
44
auth/api/check.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__ . "/../util.php";
|
||||||
|
require __DIR__ . "/../db.php";
|
||||||
|
require __DIR__ . "/../logger.php";
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$siteKey = auth_get_site_key($_GET);
|
||||||
|
$clientIp = auth_get_client_ip();
|
||||||
|
|
||||||
|
if (!auth_is_valid_site_key($siteKey)) {
|
||||||
|
auth_log_event([
|
||||||
|
"ip" => $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]);
|
||||||
87
auth/api/login.php
Normal file
87
auth/api/login.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__ . "/../util.php";
|
||||||
|
require __DIR__ . "/../db.php";
|
||||||
|
require __DIR__ . "/../logger.php";
|
||||||
|
require __DIR__ . "/../rate_limit.php";
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$data = array_merge($_POST, auth_read_json());
|
||||||
|
$login = trim((string) ($data["login"] ?? ""));
|
||||||
|
$password = (string) ($data["password"] ?? "");
|
||||||
|
$siteKey = auth_get_site_key($data);
|
||||||
|
$clientIp = auth_get_client_ip();
|
||||||
|
|
||||||
|
if (!auth_is_valid_login($login) || !auth_is_valid_password($password) || !auth_is_valid_site_key($siteKey)) {
|
||||||
|
auth_log_event([
|
||||||
|
"ip" => $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]);
|
||||||
19
auth/config.php
Normal file
19
auth/config.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
"db" => [
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
];
|
||||||
19
auth/config_nas.php
Normal file
19
auth/config_nas.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
"db" => [
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
];
|
||||||
17
auth/db.php
Normal file
17
auth/db.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function auth_get_db(): mysqli
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . "/config.php";
|
||||||
|
$db = $config["db"];
|
||||||
|
|
||||||
|
$mysqli = new mysqli($db["host"], $db["user"], $db["password"], $db["name"], $db["port"]);
|
||||||
|
if ($mysqli->connect_errno) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["ok" => false, "message" => "Ошибка сервера."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mysqli->set_charset("utf8mb4");
|
||||||
|
return $mysqli;
|
||||||
|
}
|
||||||
24
auth/demo.html
Normal file
24
auth/demo.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="site-key" content="localhost" />
|
||||||
|
<title>Демо защищенной страницы</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
<script src="/auth/guard.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<section class="card" aria-labelledby="demo-title">
|
||||||
|
<div class="card__header">
|
||||||
|
<h1 id="demo-title">Демо защищенной страницы</h1>
|
||||||
|
<p class="card__subtitle">
|
||||||
|
Если вы видите эту страницу, доступ разрешен.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a class="button" href="/auth/login.html">Перейти к форме входа</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
auth/guard.js
Normal file
35
auth/guard.js
Normal file
@@ -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();
|
||||||
46
auth/guard.php
Normal file
46
auth/guard.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__ . "/util.php";
|
||||||
|
require __DIR__ . "/db.php";
|
||||||
|
require __DIR__ . "/logger.php";
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$siteKey = defined("AUTH_SITE_KEY") ? AUTH_SITE_KEY : ($_SERVER["HTTP_HOST"] ?? "unknown");
|
||||||
|
$siteKey = is_string($siteKey) ? trim($siteKey) : "unknown";
|
||||||
|
|
||||||
|
if (!auth_is_valid_site_key($siteKey)) {
|
||||||
|
auth_log_event([
|
||||||
|
"ip" => 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;
|
||||||
|
}
|
||||||
30
auth/logger.php
Normal file
30
auth/logger.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function auth_log_event(array $entry): void
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . "/config.php";
|
||||||
|
$dir = $config["logging"]["dir"];
|
||||||
|
$file = $config["logging"]["file"];
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry["ts"] = $entry["ts"] ?? gmdate("c");
|
||||||
|
$line = json_encode($entry, JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($line === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fh = @fopen($file, "ab");
|
||||||
|
if ($fh === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flock($fh, LOCK_EX)) {
|
||||||
|
fwrite($fh, $line . PHP_EOL);
|
||||||
|
flock($fh, LOCK_UN);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
55
auth/login.html
Normal file
55
auth/login.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="site-key" content="example.com" />
|
||||||
|
<title>Вход</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<section class="card" aria-labelledby="login-title">
|
||||||
|
<div class="card__header">
|
||||||
|
<h1 id="login-title">Добро пожаловать</h1>
|
||||||
|
<p class="card__subtitle">Войдите, чтобы продолжить</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="form" id="login-form" novalidate>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Логин</span>
|
||||||
|
<input
|
||||||
|
class="field__input"
|
||||||
|
type="text"
|
||||||
|
name="login"
|
||||||
|
autocomplete="username"
|
||||||
|
minlength="3"
|
||||||
|
maxlength="64"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Пароль</span>
|
||||||
|
<input
|
||||||
|
class="field__input"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
minlength="8"
|
||||||
|
maxlength="128"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="button" type="submit">
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="form__message" id="form-message" role="status" aria-live="polite"></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="./login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
auth/login.js
Normal file
64
auth/login.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
55
auth/rate_limit.php
Normal file
55
auth/rate_limit.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function auth_check_rate_limit(string $key): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . "/config.php";
|
||||||
|
$window = (int) $config["rate_limit"]["window_seconds"];
|
||||||
|
$max = (int) $config["rate_limit"]["max_attempts"];
|
||||||
|
$dir = __DIR__ . "/ratelimit";
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeKey = hash("sha256", $key);
|
||||||
|
$file = $dir . "/" . $safeKey . ".json";
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
"count" => 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
117
auth/styles.css
Normal file
117
auth/styles.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
53
auth/util.php
Normal file
53
auth/util.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function auth_get_client_ip(): string
|
||||||
|
{
|
||||||
|
$forwarded = $_SERVER["HTTP_X_FORWARDED_FOR"] ?? "";
|
||||||
|
if ($forwarded) {
|
||||||
|
$parts = explode(",", $forwarded);
|
||||||
|
return trim($parts[0]);
|
||||||
|
}
|
||||||
|
return $_SERVER["REMOTE_ADDR"] ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_read_json(): array
|
||||||
|
{
|
||||||
|
$raw = file_get_contents("php://input");
|
||||||
|
if (!$raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_json_response(int $status, array $payload): void
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header("Content-Type: application/json; charset=utf-8");
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_get_site_key(array $data): string
|
||||||
|
{
|
||||||
|
$siteKey = $data["siteKey"] ?? $_GET["siteKey"] ?? "";
|
||||||
|
return is_string($siteKey) ? trim($siteKey) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_is_valid_login(string $login): bool
|
||||||
|
{
|
||||||
|
$len = mb_strlen($login, "UTF-8");
|
||||||
|
return $len >= 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;
|
||||||
|
}
|
||||||
32
db/schema.sql
Normal file
32
db/schema.sql
Normal file
@@ -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');
|
||||||
7
docker-pack/Dockerfile
Normal file
7
docker-pack/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM php:8.2-apache
|
||||||
|
|
||||||
|
RUN docker-php-ext-install mysqli
|
||||||
|
|
||||||
|
RUN a2enmod rewrite headers
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
56
docker-pack/README.md
Normal file
56
docker-pack/README.md
Normal file
@@ -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`
|
||||||
24
docker-pack/docker-compose.yml
Normal file
24
docker-pack/docker-compose.yml
Normal file
@@ -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:
|
||||||
5
docker-pack/seed.sql
Normal file
5
docker-pack/seed.sql
Normal file
@@ -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');
|
||||||
Reference in New Issue
Block a user