feat: OIDC/LDAP auth, audit log, maintenance windows, migrations

- Add OIDC (OpenID Connect) authentication with discovery URL support,
  group-based role mapping, and token introspection
- Add LDAP authentication with bind credentials, group-based role
  mapping, and configurable attribute names
- Add audit log (model, routes, templates) tracking settings changes,
  plugin enable/disable, login events
- Add migrations 0009 (widget variants), 0010 (maintenance windows),
  0011 (audit log)
- Add optional dependency groups to pyproject.toml: [ldap], [snmp]
- Add Settings → Auth tab with OIDC and LDAP configuration forms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 08:15:15 -04:00
parent 484da1f263
commit 3c70ac56b3
15 changed files with 863 additions and 7 deletions
+98
View File
@@ -0,0 +1,98 @@
"""fabledscryer/auth/ldap_auth.py
LDAP authentication helper. Requires the optional `ldap3` package.
Falls back gracefully if ldap3 is not installed.
"""
from __future__ import annotations
import asyncio
import logging
logger = logging.getLogger(__name__)
def _ldap_available() -> bool:
try:
import ldap3 # noqa: F401
return True
except ImportError:
return False
def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | None:
"""Synchronous LDAP auth. Returns user info dict or None on failure.
Called via run_in_executor so it doesn't block the event loop.
"""
import ldap3
host = cfg.get("host", "")
port = int(cfg.get("port", 389))
use_tls = cfg.get("tls", False)
bind_dn = cfg.get("bind_dn", "")
bind_password = cfg.get("bind_password", "")
base_dn = cfg.get("base_dn", "")
user_filter = cfg.get("user_filter", "(uid={username})").format(username=username)
admin_group_dn = cfg.get("admin_group_dn", "")
operator_group_dn = cfg.get("operator_group_dn", "")
attr_username = cfg.get("attr_username", "uid")
attr_email = cfg.get("attr_email", "mail")
tls = ldap3.Tls() if use_tls else None
server = ldap3.Server(host, port=port, tls=tls, get_info=ldap3.ALL)
# Service account bind to search for user DN
try:
svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
except Exception as exc:
logger.error("LDAP service bind failed: %s", exc)
return None
try:
svc_conn.search(
search_base=base_dn,
search_filter=user_filter,
attributes=[attr_username, attr_email, "memberOf", "dn"],
)
if not svc_conn.entries:
logger.debug("LDAP user not found: %s", username)
return None
entry = svc_conn.entries[0]
user_dn = str(entry.entry_dn)
ldap_username = str(getattr(entry, attr_username, [username])[0]) if hasattr(entry, attr_username) else username
ldap_email = str(getattr(entry, attr_email, [""])[0]) if hasattr(entry, attr_email) else ""
member_of: list[str] = [str(g) for g in getattr(entry, "memberOf", [])]
except Exception as exc:
logger.error("LDAP search failed: %s", exc)
return None
finally:
svc_conn.unbind()
# Bind as the user to verify password
try:
user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
user_conn.unbind()
except Exception:
logger.debug("LDAP password verify failed for %s", username)
return None
# Map role from group membership
role = "viewer"
if admin_group_dn and admin_group_dn in member_of:
role = "admin"
elif operator_group_dn and operator_group_dn in member_of:
role = "operator"
return {
"username": ldap_username,
"email": ldap_email,
"role": role,
}
async def ldap_authenticate(cfg: dict, username: str, password: str) -> dict | None:
"""Async wrapper around synchronous LDAP auth. Returns user info or None."""
if not _ldap_available():
logger.warning("ldap3 not installed — LDAP auth unavailable")
return None
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _ldap_authenticate_sync, cfg, username, password)
+109
View File
@@ -0,0 +1,109 @@
"""fabledscryer/auth/oidc.py
OIDC Authorization Code flow helpers using httpx.
No extra dependencies — works with any OIDC provider (Authentik, Keycloak, etc.).
JWT signature verification is intentionally skipped for homelab use;
we trust the HTTPS connection to the discovery-documented token/userinfo endpoints.
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import os
import time
import httpx
logger = logging.getLogger(__name__)
# Module-level cache: {discovery_url: (fetched_at, doc)}
_discovery_cache: dict[str, tuple[float, dict]] = {}
_DISCOVERY_TTL = 300 # seconds
async def get_discovery(discovery_url: str) -> dict:
"""Fetch and cache the OIDC provider discovery document."""
now = time.monotonic()
if discovery_url in _discovery_cache:
fetched_at, doc = _discovery_cache[discovery_url]
if now - fetched_at < _DISCOVERY_TTL:
return doc
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(discovery_url)
resp.raise_for_status()
doc = resp.json()
_discovery_cache[discovery_url] = (now, doc)
return doc
def build_authorize_url(doc: dict, client_id: str, redirect_uri: str,
scopes: str, state: str, nonce: str) -> str:
"""Build the IdP authorization redirect URL."""
import urllib.parse
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scopes,
"state": state,
"nonce": nonce,
}
return doc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
async def exchange_code(doc: dict, client_id: str, client_secret: str,
code: str, redirect_uri: str) -> dict:
"""Exchange authorization code for tokens. Returns token response dict."""
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
doc["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
},
)
resp.raise_for_status()
return resp.json()
async def get_userinfo(doc: dict, access_token: str) -> dict:
"""Fetch user info from the IdP's userinfo endpoint."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
doc["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
resp.raise_for_status()
return resp.json()
def decode_id_token_payload(id_token: str) -> dict:
"""Decode ID token payload without verifying signature (homelab only)."""
try:
parts = id_token.split(".")
if len(parts) < 2:
return {}
payload_b64 = parts[1]
# Add padding
payload_b64 += "=" * (4 - len(payload_b64) % 4)
return json.loads(base64.urlsafe_b64decode(payload_b64))
except Exception:
return {}
def map_role(groups: list[str], admin_group: str, operator_group: str) -> str:
"""Map IdP groups to a local role string."""
if admin_group and admin_group in groups:
return "admin"
if operator_group and operator_group in groups:
return "operator"
return "viewer"
def generate_state() -> str:
return base64.urlsafe_b64encode(os.urandom(24)).decode().rstrip("=")
+139 -6
View File
@@ -8,6 +8,33 @@ from fabledscryer.models.users import User, UserRole
auth_bp = Blueprint("auth", __name__)
async def _provision_external_user(app, username: str, email: str, role_str: str):
"""Create or update a user from an external auth provider (OIDC/LDAP).
External users get a randomised unusable password so local login is blocked.
Their role is updated on every login to reflect current IdP group membership.
"""
import secrets
from fabledscryer.models.users import UserRole
async with app.db_sessionmaker() as db:
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
new_role = UserRole(role_str)
async with db.begin():
if user is None:
unusable_hash = bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt()).decode()
user = User(username=username, email=email or f"{username}@external",
password_hash=unusable_hash, role=new_role)
db.add(user)
else:
if user.role != new_role:
user.role = new_role
# Re-fetch committed user
async with app.db_sessionmaker() as db:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one()
async def get_user_count(app) -> int:
async with app.db_sessionmaker() as db:
result = await db.execute(select(func.count()).select_from(User))
@@ -25,21 +52,124 @@ async def login():
from quart import current_app
if await get_user_count(current_app) == 0:
return redirect(url_for("auth.setup"))
return await render_template("auth/login.html")
oidc_cfg = current_app.config.get("OIDC", {})
return await render_template("auth/login.html",
oidc_enabled=oidc_cfg.get("enabled", False))
@auth_bp.post("/login")
async def login_post():
from quart import current_app
from fabledscryer.core.audit import log_audit
form = await request.form
username = form.get("username", "").strip()
password = form.get("password", "").encode()
password_str = form.get("password", "")
password = password_str.encode()
# Try local auth first
user = await get_user_by_username(current_app, username)
if not user or not user.is_active:
return await render_template("auth/login.html", error="Invalid credentials"), 400
if not bcrypt.checkpw(password, user.password_hash.encode()):
return await render_template("auth/login.html", error="Invalid credentials"), 400
if user and user.is_active and bcrypt.checkpw(password, user.password_hash.encode()):
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.login",
detail={"method": "local", "role": user.role.value})
return redirect(url_for("dashboard.index"))
# Try LDAP fallback if enabled
ldap_cfg = current_app.config.get("LDAP", {})
if ldap_cfg.get("enabled"):
from fabledscryer.auth.ldap_auth import ldap_authenticate
ldap_info = await ldap_authenticate(ldap_cfg, username, password_str)
if ldap_info:
user = await _provision_external_user(
current_app, ldap_info["username"], ldap_info["email"], ldap_info["role"]
)
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.login",
detail={"method": "ldap", "role": user.role.value})
return redirect(url_for("dashboard.index"))
oidc_cfg = current_app.config.get("OIDC", {})
return await render_template(
"auth/login.html",
error="Invalid credentials",
oidc_enabled=oidc_cfg.get("enabled", False),
), 400
@auth_bp.get("/login/oidc")
async def login_oidc():
from quart import current_app
from fabledscryer.auth.oidc import get_discovery, build_authorize_url, generate_state
oidc_cfg = current_app.config.get("OIDC", {})
if not oidc_cfg.get("enabled") or not oidc_cfg.get("discovery_url"):
return redirect(url_for("auth.login"))
try:
doc = await get_discovery(oidc_cfg["discovery_url"])
except Exception as exc:
return await render_template("auth/login.html", error=f"OIDC discovery failed: {exc}",
oidc_enabled=True), 502
state = generate_state()
nonce = generate_state()
session["oidc_state"] = state
session["oidc_nonce"] = nonce
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
url = build_authorize_url(
doc, oidc_cfg["client_id"], redirect_uri,
oidc_cfg.get("scopes", "openid profile email"), state, nonce,
)
return redirect(url)
@auth_bp.get("/login/oidc/callback")
async def login_oidc_callback():
from quart import current_app
from fabledscryer.auth.oidc import get_discovery, exchange_code, get_userinfo, map_role
from fabledscryer.core.audit import log_audit
oidc_cfg = current_app.config.get("OIDC", {})
error = request.args.get("error")
if error:
desc = request.args.get("error_description", error)
return await render_template("auth/login.html", error=f"SSO error: {desc}",
oidc_enabled=True), 400
code = request.args.get("code", "")
state = request.args.get("state", "")
if not code or state != session.pop("oidc_state", None):
return await render_template("auth/login.html", error="Invalid SSO state — try again.",
oidc_enabled=True), 400
try:
doc = await get_discovery(oidc_cfg["discovery_url"])
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
tokens = await exchange_code(
doc, oidc_cfg["client_id"], oidc_cfg["client_secret"], code, redirect_uri
)
userinfo = await get_userinfo(doc, tokens["access_token"])
except Exception as exc:
return await render_template("auth/login.html", error=f"SSO login failed: {exc}",
oidc_enabled=True), 502
username_claim = oidc_cfg.get("username_claim", "preferred_username")
email_claim = oidc_cfg.get("email_claim", "email")
groups_claim = oidc_cfg.get("groups_claim", "groups")
username = userinfo.get(username_claim) or userinfo.get("sub", "")
email = userinfo.get(email_claim, "") or f"{username}@oidc"
groups = userinfo.get(groups_claim, [])
if not isinstance(groups, list):
groups = []
role = map_role(groups, oidc_cfg.get("admin_group", ""), oidc_cfg.get("operator_group", ""))
if not username:
return await render_template("auth/login.html",
error="SSO returned no username.",
oidc_enabled=True), 400
user = await _provision_external_user(current_app, username, email, role)
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.login",
detail={"method": "oidc", "role": role})
return redirect(url_for("dashboard.index"))
@@ -60,6 +190,7 @@ async def setup():
@auth_bp.post("/setup")
async def setup_post():
from quart import current_app
from fabledscryer.core.audit import log_audit
if await get_user_count(current_app) > 0:
return redirect(url_for("auth.login"))
form = await request.form
@@ -74,4 +205,6 @@ async def setup_post():
async with db.begin():
db.add(user)
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.setup",
detail={"username": username})
return redirect(url_for("dashboard.index"))