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>
227 lines
7.7 KiB
Python
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")
|