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