fix(webui): cookie session login/logout (replace Basic Auth)

Browsers cannot clear HTTP Basic credentials; logout never worked reliably.
Use Flask signed session + /login form + GET /logout + POST /api/logout.
Optional KASKAD_SECRET_KEY or auto /etc/kaskad/.session_secret.
Fetch API uses credentials: same-origin; 401 redirects to /login.

Docs + README updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-12 20:04:03 +03:00
parent b8757548a2
commit a2182d0b91
7 changed files with 182 additions and 57 deletions

View File

@@ -5,13 +5,17 @@ Kaskad Web UI - dashboard для управления RU/ам. серверам
Шарит логику с TG-ботом: читает /etc/wireguard/ru-servers.json и шеллит
те же скрипты (ru-set.sh, ru-routes.sh, ru-domains.py).
"""
import base64, functools, json, os, re, shlex, socket, subprocess
import base64, functools, json, os, re, secrets, shlex, subprocess
from datetime import timedelta
from pathlib import Path
from flask import Flask, jsonify, render_template, request, Response
from typing import Optional
from flask import Flask, jsonify, render_template, request, Response, session, redirect, url_for
SERVERS_JSON = Path(os.environ.get("KASKAD_SERVERS_JSON", "/etc/wireguard/ru-servers.json"))
WEBUI_ENV = Path(os.environ.get("KASKAD_WEBUI_ENV", "/etc/kaskad/webui.env"))
SESSION_SECRET_FILE = Path(os.environ.get("KASKAD_SESSION_SECRET_FILE", "/etc/kaskad/.session_secret"))
LOCAL_HOST = os.environ.get("LOCAL_HOST", "ams1")
LOCAL_IP = os.environ.get("LOCAL_IP", "127.0.0.1")
BOT_KEY = os.environ.get("KASKAD_SSH_KEY", "/root/.ssh/id_ed25519")
@@ -22,16 +26,61 @@ app = Flask(__name__, template_folder="templates", static_folder="static")
CIDR_RX = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2})?)\b")
# --- auth ---
def _session_secret_key() -> str:
sk = os.environ.get("KASKAD_SECRET_KEY", "").strip()
if sk:
return sk
try:
if SESSION_SECRET_FILE.exists():
return SESSION_SECRET_FILE.read_text().strip()
except OSError:
pass
key = secrets.token_hex(32)
try:
SESSION_SECRET_FILE.parent.mkdir(parents=True, exist_ok=True)
if not SESSION_SECRET_FILE.exists():
SESSION_SECRET_FILE.write_text(key)
SESSION_SECRET_FILE.chmod(0o600)
else:
key = SESSION_SECRET_FILE.read_text().strip()
except OSError:
pass
return key
app.secret_key = _session_secret_key()
app.config.update(
SESSION_COOKIE_NAME="kaskad",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
)
def _safe_next(url: Optional[str]) -> str:
if not url:
return "/"
url = url.split("#", 1)[0]
if not url.startswith("/") or url.startswith("//"):
return "/"
return url
def _session_ok() -> bool:
return bool(session.get("kaskad"))
# --- auth (cookie session; Basic Auth в браузере нельзя надёжно сбросить) ---
def require_auth(fn):
@functools.wraps(fn)
def w(*a, **kw):
if not WEB_PASS:
return Response("KASKAD_WEB_PASS не задан", 500)
auth = request.authorization
if not auth or auth.username != WEB_USER or auth.password != WEB_PASS:
return Response("Auth required", 401, {"WWW-Authenticate": 'Basic realm="kaskad"'})
return fn(*a, **kw)
if _session_ok():
return fn(*a, **kw)
if request.path.startswith("/api/"):
return jsonify(error="требуется вход"), 401
return redirect(url_for("login", next=request.path))
return w
@@ -592,21 +641,35 @@ def api_change_password():
return jsonify(ok=True)
@app.route("/api/logout")
def api_logout():
"""401 + смена realm — подсказка браузеру забыть предыдущий Basic Auth.
@app.route("/login", methods=["GET", "POST"])
def login():
"""Форма входа; сессия Flask (cookie), не HTTP Basic."""
if _session_ok():
return redirect(_safe_next(request.args.get("next")))
next_url = _safe_next(request.args.get("next", "/"))
if request.method == "POST":
next_url = _safe_next(request.form.get("next"))
u = request.form.get("username", "").strip()
p = request.form.get("password", "")
if u == WEB_USER and p == WEB_PASS:
session.clear()
session["kaskad"] = True
session.permanent = True
return redirect(next_url)
return render_template("login.html", error="Неверный логин или пароль", next_url=next_url)
return render_template("login.html", next_url=next_url)
Клиент должен дергать этот URL через XMLHttpRequest.open(..., user, password)
с заведомо неверной парой; иначе многие браузеры подставят сохранённые креды."""
return Response(
"logged out\n",
401,
{
"WWW-Authenticate": f'Basic realm="kaskad-logout-{os.urandom(4).hex()}"',
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
},
)
@app.route("/logout")
def logout_page():
session.clear()
return redirect(url_for("login"))
@app.route("/api/logout", methods=["POST"])
def api_logout():
session.clear()
return jsonify(ok=True)
@app.route("/")

View File

@@ -157,3 +157,43 @@ footer .foot-row {
}
footer .foot-row.muted { color: #6e7681; font-size: 11px; margin-top: 8px; }
footer a { margin: 0 2px; }
/* --- login --- */
body.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.login-box {
width: 100%;
max-width: 380px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 28px;
}
.login-box h1 { margin: 0 0 8px 0; }
.login-box form {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
}
.login-box form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #8b949e;
}
.login-box form input {
width: 100%;
box-sizing: border-box;
}
.login-box form button {
width: 100%;
margin-top: 8px;
padding: 10px 16px;
}

View File

@@ -246,9 +246,15 @@
this.loading = true; this.error = '';
try {
const r = await fetch(url, {
method, headers: {'Content-Type':'application/json'},
method,
credentials: 'same-origin',
headers: {'Content-Type':'application/json'},
body: body ? JSON.stringify(body) : undefined
});
if (r.status === 401) {
window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname || '/');
throw new Error('требуется вход');
}
const j = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
@@ -278,33 +284,15 @@
});
} catch { return; }
this.settingsOpen = false;
this.flash('Пароль изменён. Сейчас браузер попросит новый.');
setTimeout(() => this.logout(), 1200);
this.flash('Пароль изменён');
this.forms.pwd = {current:'', new:'', confirm:''};
},
logout() {
// HTTP Basic Auth живёт в браузере отдельно от cookies. fetch() с ручным
// Authorization часто ИГНОРИРУЕТСЯ — Chrome подставляет сохранённый пароль.
// XMLHttpRequest.open(..., user, password) заставляет уйти именно эта пара
// (заведомо ложная), сервер отвечает 401 + новый realm — после этого
// location.replace('/') обычно снова показывает окно входа.
const go = () => {
const u = new URL(window.location.href);
u.pathname = '/';
u.search = '_bye=' + Date.now();
u.hash = '';
window.location.replace(u.href);
};
const noise = () => Math.random().toString(36).slice(2) + Date.now();
async logout() {
try {
const xhr = new XMLHttpRequest();
xhr.timeout = 12000;
xhr.open('GET', '/api/logout?_=' + noise(), true, '__logout', noise());
xhr.onload = xhr.onerror = xhr.ontimeout = go;
xhr.send();
} catch (e) {
go();
}
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
} catch {}
window.location.href = '/login';
},
fmtAge(s) {

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход — Kaskad</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="login-page">
<div class="login-box">
<h1>Каскад</h1>
<p class="muted">Вход в панель управления</p>
{% if error %}
<p class="status err" style="margin:12px 0">{{ error }}</p>
{% endif %}
<form method="post" action="{{ url_for('login') }}">
<input type="hidden" name="next" value="{{ next_url }}">
<label>Логин
<input name="username" autocomplete="username" required autofocus>
</label>
<label>Пароль
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="danger solid">Войти</button>
</form>
</div>
</body>
</html>

View File

@@ -13,5 +13,8 @@ KASKAD_PORT=8088
# SSH-ключ для доступа к остальным серверам
KASKAD_SSH_KEY=/root/.ssh/id_ed25519
# Опционально: ключ подписи сессии (иначе создаётся /etc/kaskad/.session_secret)
# KASKAD_SECRET_KEY=
# Путь к JSON конфигу
KASKAD_SERVERS_JSON=/etc/wireguard/ru-servers.json