feat(gui): custom login page, session-based auth

Replace nginx auth_basic + HTTP Basic Auth with a styled Flask login form.
- Session-based authentication (cookie, session.permanent)
- Custom login page with logo, error state, clean form design
- CSRF check skipped for /login route
- Logout button in sidebar footer
- nginx auth_basic removed; ADMIN_PASSWORD restored in .env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 10:42:44 +03:00
parent 75b47e2404
commit 2391007a81
4 changed files with 258 additions and 13 deletions
+31 -13
View File
@@ -250,14 +250,6 @@ def to_png_b64(text):
return base64.b64encode(buf.getvalue()).decode("ascii")
def _unauthorized():
return Response(
"Auth required",
401,
{"WWW-Authenticate": 'Basic realm="WG Admin"'},
)
def _get_csrf_token():
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_hex(32)
@@ -266,18 +258,19 @@ def _get_csrf_token():
app.jinja_env.globals["csrf_token"] = _get_csrf_token
PUBLIC_PATHS = {"/login", "/static/"}
@app.before_request
def _auth():
if request.path.startswith("/static/"):
return None
if request.path == "/login":
return None
if not ADMIN_PASSWORD:
return None
auth = request.authorization
if not auth:
return _unauthorized()
if auth.username != ADMIN_USER or auth.password != ADMIN_PASSWORD:
return _unauthorized()
if not session.get("logged_in"):
return redirect(url_for("login", next=request.path))
return None
@@ -285,12 +278,37 @@ def _auth():
def _csrf_check():
if request.method != "POST":
return None
if request.path == "/login":
return None
token = request.form.get("csrf_token")
if not token or token != session.get("csrf_token"):
return Response("CSRF token invalid", 403)
return None
@app.route("/login", methods=["GET", "POST"])
def login():
if session.get("logged_in"):
return redirect(url_for("index"))
error = None
if request.method == "POST":
user = request.form.get("username", "").strip()
pwd = request.form.get("password", "")
if user == ADMIN_USER and pwd == ADMIN_PASSWORD:
session.clear()
session["logged_in"] = True
session.permanent = True
return redirect(request.args.get("next") or url_for("index"))
error = "Неверный логин или пароль"
return render_template("login.html", error=error)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.route("/")
def index():
meta = load_meta()
+21
View File
@@ -82,6 +82,27 @@ body {
.sidebar-nav a.active { background: #eff4ff; color: var(--accent); }
.sidebar-nav a.active svg { color: var(--accent); }
.sidebar-footer {
margin-top: auto;
padding: 12px 10px;
border-top: 1px solid var(--border);
}
.logout-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
text-decoration: none;
transition: background .15s, color .15s;
}
.logout-btn:hover { background: #fee2e2; color: #dc2626; }
/* ─── Layout ────────────────────────────────────────────────── */
.layout { margin-left: var(--sidebar-w); flex: 1; display: flex; flex-direction: column; }
main { padding: 28px 32px; flex: 1; max-width: 1400px; width: 100%; }
+6
View File
@@ -26,6 +26,12 @@
Скрипты
</a>
</nav>
<div class="sidebar-footer">
<a href="{{ url_for('logout') }}" class="logout-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Выйти
</a>
</div>
</aside>
<div class="layout">
<main>
+200
View File
@@ -0,0 +1,200 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WG Admin — Вход</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--accent: #3b6ef6;
--accent-h: #2955d4;
--border: #e2e5ee;
--text: #111827;
--text-muted: #6b7280;
--bg: #f5f6fa;
--red: #dc2626;
--font: 'Inter', system-ui, -apple-system, sans-serif;
}
body {
font-family: var(--font);
background: var(--bg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
background: #fff;
border: 1px solid var(--border);
border-radius: 16px;
padding: 40px 36px;
width: 100%;
max-width: 380px;
box-shadow: 0 4px 24px rgba(0,0,0,.07);
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 28px;
}
.logo-icon {
width: 44px;
height: 44px;
background: #eff4ff;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.logo-icon svg { color: var(--accent); }
.logo-text { line-height: 1.2; }
.logo-text h1 { font-size: 18px; font-weight: 700; color: var(--text); }
.logo-text p { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
h2 {
font-size: 20px;
font-weight: 700;
color: var(--text);
margin-bottom: 6px;
}
.subtitle {
font-size: 13.5px;
color: var(--text-muted);
margin-bottom: 28px;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-family: var(--font);
color: var(--text);
background: #fff;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59,110,246,.12);
}
input::placeholder { color: #9ca3af; }
.error {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: var(--red);
margin-bottom: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.btn {
width: 100%;
padding: 11px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
font-family: var(--font);
cursor: pointer;
margin-top: 8px;
transition: background .15s;
box-shadow: 0 1px 3px rgba(59,110,246,.3);
}
.btn:hover { background: var(--accent-h); }
.btn:active { transform: translateY(1px); }
.divider {
height: 1px;
background: var(--border);
margin: 28px 0 0;
}
.footer {
text-align: center;
font-size: 12px;
color: var(--text-muted);
margin-top: 16px;
}
</style>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logo-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<div class="logo-text">
<h1>WG Admin</h1>
<p>WireGuard управление</p>
</div>
</div>
<h2>Добро пожаловать</h2>
<p class="subtitle">Войдите в панель управления</p>
{% if error %}
<div class="error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" flex-shrink="0">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{{ error }}
</div>
{% endif %}
<form method="post">
<div class="form-group">
<label for="username">Логин</label>
<input type="text" id="username" name="username" placeholder="admin" autocomplete="username" autofocus required />
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" placeholder="••••••••" autocomplete="current-password" required />
</div>
<button type="submit" class="btn">Войти</button>
</form>
<div class="divider"></div>
<p class="footer">wg.4mont.ru &nbsp;·&nbsp; WireGuard Admin Panel</p>
</div>
</body>
</html>