Files
fail2ban-webui/app/webui/auth.py
T
schiwagoaandClaude Opus 5.5 2e3f79236c 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>
2026-10-25 15:38:17 +01:00

227 lines
7.7 KiB
Python

"""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")