Initial version of fail2ban-webui
Web interface to view, ban and unban fail2ban IPs on several hosts via SSH, generalised from the private f2b-web tool. - Web UI (Flask/Waitress): hosts.yaml, local/OIDC/proxy authentication, English/German UI, origin map, optional Loki/Grafana/GeoIP, CLI helpers - Agent f2b-webctl with install.sh/uninstall.sh (dedicated user + sudo, from= restriction, protected networks, manual jail) - Hardened Docker image, examples, documentation, smoke test, testbed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
2e3f79236c
44 files changed
+4280
No files matched your search
@@ -0,0 +1,226 @@
|
||||
"""Authentication: local user, OpenID Connect and reverse-proxy mode.
|
||||
|
||||
AUTH_MODE=local own login form (user + password hash)
|
||||
AUTH_MODE=oidc single sign-on via an OpenID Connect provider (e.g. Authentik)
|
||||
AUTH_MODE=local,oidc both on the login page
|
||||
AUTH_MODE=proxy the reverse proxy authenticates (forward auth, basic auth, IP list)
|
||||
|
||||
Failed logins are written to the container log as
|
||||
LOGIN_FAILED user=<name> ip=<address>
|
||||
so fail2ban on the Docker host can protect this UI as well.
|
||||
"""
|
||||
import hmac
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
from flask import (Blueprint, abort, current_app, flash, g, jsonify, redirect,
|
||||
render_template, request, session, url_for)
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
log = logging.getLogger("webui")
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
PUBLIC_ENDPOINTS = {"healthz", "static", "auth.login", "auth.login_post",
|
||||
"auth.oidc_start", "auth.oidc_callback"}
|
||||
# Used when the user name does not exist, so both cases take the same time
|
||||
_DUMMY_HASH = "scrypt:32768:8:1$dummydummydummy$" + "0" * 128
|
||||
|
||||
_failures = {}
|
||||
_failures_lock = threading.Lock()
|
||||
oauth = None
|
||||
|
||||
|
||||
def settings():
|
||||
return current_app.config["SETTINGS"]
|
||||
|
||||
|
||||
def csrf_token():
|
||||
if "csrf" not in session:
|
||||
session["csrf"] = secrets.token_urlsafe(32)
|
||||
return session["csrf"]
|
||||
|
||||
|
||||
def csrf_valid(value):
|
||||
return bool(value) and hmac.compare_digest(str(value), session.get("csrf", ""))
|
||||
|
||||
|
||||
def client_ip():
|
||||
return clean(request.remote_addr or "?")
|
||||
|
||||
|
||||
def clean(value, limit=64):
|
||||
"""User-controlled text for log lines: no spaces or line breaks, so nobody can
|
||||
forge an extra "LOGIN_FAILED … ip=" line that fail2ban would act on."""
|
||||
return re.sub(r"[^\w.@:+-]", "?", str(value))[:limit] or "-"
|
||||
|
||||
|
||||
def start_session(user, via):
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["user"] = user
|
||||
session["via"] = via
|
||||
csrf_token()
|
||||
log.info("LOGIN_OK user=%s ip=%s via=%s", clean(user), client_ip(), via)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Brute-force protection (in memory, per client IP)
|
||||
# ---------------------------------------------------------------------
|
||||
def _locked(ip):
|
||||
s = settings()
|
||||
window = s.login_lock_minutes * 60
|
||||
with _failures_lock:
|
||||
recent = [t for t in _failures.get(ip, []) if time.time() - t < window]
|
||||
_failures[ip] = recent
|
||||
return len(recent) >= s.login_max_attempts
|
||||
|
||||
|
||||
def _record_failure(ip, user):
|
||||
with _failures_lock:
|
||||
_failures.setdefault(ip, []).append(time.time())
|
||||
log.warning("LOGIN_FAILED user=%s ip=%s", clean(user), ip)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Guard for every request
|
||||
# ---------------------------------------------------------------------
|
||||
def require_login():
|
||||
s = settings()
|
||||
g.user = None
|
||||
if request.endpoint in PUBLIC_ENDPOINTS:
|
||||
return None
|
||||
|
||||
if s.auth_modes == ["proxy"]:
|
||||
if s.proxy_allowed_ips:
|
||||
direct = request.environ.get("werkzeug.proxy_fix.orig", {}).get("REMOTE_ADDR", request.remote_addr)
|
||||
try:
|
||||
addr = ipaddress.ip_address(direct)
|
||||
except ValueError:
|
||||
addr = None
|
||||
if addr is None or not any(addr in net for net in s.proxy_allowed_ips):
|
||||
abort(403, description=g.t("err_forbidden_proxy"))
|
||||
if s.proxy_user_header:
|
||||
g.user = request.headers.get(s.proxy_user_header, "").strip()[:128]
|
||||
if not g.user:
|
||||
abort(401, description=g.t("err_login_required"))
|
||||
else:
|
||||
g.user = "proxy"
|
||||
csrf_token()
|
||||
return None
|
||||
|
||||
g.user = session.get("user")
|
||||
if g.user:
|
||||
return None
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"ok": False, "error": g.t("err_login_required")}), 401
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------
|
||||
@bp.get("/login")
|
||||
def login():
|
||||
s = settings()
|
||||
if s.auth_modes == ["proxy"] or session.get("user"):
|
||||
return redirect(url_for("index"))
|
||||
return render_template("login.html", csrf=csrf_token(),
|
||||
local="local" in s.auth_modes, oidc="oidc" in s.auth_modes,
|
||||
oidc_label=s.oidc_button_label)
|
||||
|
||||
|
||||
@bp.post("/login")
|
||||
def login_post():
|
||||
s = settings()
|
||||
if "local" not in s.auth_modes:
|
||||
abort(404)
|
||||
ip = client_ip()
|
||||
user = request.form.get("username", "").strip()
|
||||
if not csrf_valid(request.form.get("csrf")):
|
||||
flash(g.t("login_csrf"), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
if _locked(ip):
|
||||
log.warning("LOGIN_LOCKED ip=%s", ip)
|
||||
flash(g.t("login_locked", minutes=s.login_lock_minutes), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
password = request.form.get("password", "")
|
||||
user_ok = hmac.compare_digest(user, s.auth_user)
|
||||
pass_ok = check_password_hash(s.auth_password_hash if user_ok else _DUMMY_HASH, password)
|
||||
if not (user_ok and pass_ok):
|
||||
_record_failure(ip, user)
|
||||
flash(g.t("login_failed"), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
with _failures_lock:
|
||||
_failures.pop(ip, None)
|
||||
start_session(user, "local")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
def logout():
|
||||
if not csrf_valid(request.form.get("csrf")):
|
||||
abort(403, description=g.t("err_csrf"))
|
||||
log.info("LOGOUT user=%s ip=%s", clean(session.get("user")), client_ip())
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.get("/auth/oidc")
|
||||
def oidc_start():
|
||||
s = settings()
|
||||
if "oidc" not in s.auth_modes:
|
||||
abort(404)
|
||||
return oauth.oidc.authorize_redirect(f"{s.public_url}/auth/callback")
|
||||
|
||||
|
||||
@bp.get("/auth/callback")
|
||||
def oidc_callback():
|
||||
s = settings()
|
||||
if "oidc" not in s.auth_modes:
|
||||
abort(404)
|
||||
try:
|
||||
token = oauth.oidc.authorize_access_token()
|
||||
info = token.get("userinfo") or oauth.oidc.userinfo(token=token)
|
||||
except Exception as exc:
|
||||
log.warning("OIDC_FAILED ip=%s error=%s", client_ip(), clean(exc, 200))
|
||||
flash(g.t("login_oidc_failed", error=str(exc)[:200]), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
user = str(info.get(s.oidc_username_claim) or info.get("email") or info.get("sub") or "")[:128]
|
||||
groups = info.get(s.oidc_groups_claim) or []
|
||||
if isinstance(groups, str):
|
||||
groups = [groups]
|
||||
allowed = True
|
||||
if s.oidc_allowed_groups and not set(groups) & set(s.oidc_allowed_groups):
|
||||
allowed = False
|
||||
if s.oidc_allowed_users and user not in s.oidc_allowed_users:
|
||||
allowed = False
|
||||
if not user or not allowed:
|
||||
log.warning("OIDC_DENIED user=%s ip=%s groups=%s", clean(user), client_ip(), clean(",".join(map(str, groups)), 200))
|
||||
flash(g.t("login_denied", user=user or "?"), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
start_session(user, "oidc")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
def init_app(app, s):
|
||||
global oauth
|
||||
app.register_blueprint(bp)
|
||||
app.before_request(require_login)
|
||||
if "oidc" in s.auth_modes:
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
oauth = OAuth(app)
|
||||
oauth.register(
|
||||
name="oidc",
|
||||
server_metadata_url=s.oidc_discovery_url,
|
||||
client_id=s.oidc_client_id,
|
||||
client_secret=s.oidc_client_secret,
|
||||
client_kwargs={"scope": s.oidc_scopes, "code_challenge_method": "S256"},
|
||||
)
|
||||
if not s.oidc_allowed_groups and not s.oidc_allowed_users:
|
||||
log.warning("OIDC without OIDC_ALLOWED_GROUPS/OIDC_ALLOWED_USERS: every account of the "
|
||||
"provider that may use this client can sign in")
|
||||
Reference in new issue
Block a user