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:
+31
-13
@@ -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()
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 · WireGuard Admin Panel</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user