refactor: rename package directory fabledscryer → roundtable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:10:31 -04:00
parent fd9c6d941d
commit 8aad2ab43d
117 changed files with 0 additions and 0 deletions
View File
+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)
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import functools
from quart import session, redirect, url_for, abort, g
from fabledscryer.models.users import UserRole
_ROLE_ORDER = [UserRole.viewer, UserRole.operator, UserRole.admin]
def require_role(minimum_role: UserRole):
"""Decorator: requires authenticated user with at least minimum_role.
Also allows access for validated share-token requests (viewer level only).
"""
def decorator(f):
@functools.wraps(f)
async def wrapper(*args, **kwargs):
# Share-token requests bypass session auth for viewer-level endpoints
if getattr(g, "share_viewer", False) and minimum_role == UserRole.viewer:
return await f(*args, **kwargs)
user_id = session.get("user_id")
if not user_id:
return redirect(url_for("auth.login"))
user_role = UserRole(session.get("user_role", "viewer"))
if _ROLE_ORDER.index(user_role) < _ROLE_ORDER.index(minimum_role):
abort(403)
return await f(*args, **kwargs)
return wrapper
return decorator
def login_user(user) -> None:
"""Write user identity into the session."""
session["user_id"] = user.id
session["user_role"] = user.role.value
session["username"] = user.username
def logout_user() -> None:
session.clear()
def current_user_id() -> str | None:
return session.get("user_id")
+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("=")
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
import bcrypt
from quart import Blueprint, render_template, request, redirect, url_for, session
from sqlalchemy import select, func
from fabledscryer.auth.middleware import login_user, logout_user
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))
return result.scalar_one()
async def get_user_by_username(app, username: str) -> User | None:
async with app.db_sessionmaker() as db:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
@auth_bp.get("/login")
async def login():
from quart import current_app
if await get_user_count(current_app) == 0:
return redirect(url_for("auth.setup"))
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_str = form.get("password", "")
password = password_str.encode()
# Try local auth first
user = await get_user_by_username(current_app, username)
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"))
@auth_bp.get("/logout")
async def logout():
logout_user()
return redirect(url_for("auth.login"))
@auth_bp.get("/setup")
async def setup():
from quart import current_app
if await get_user_count(current_app) > 0:
return redirect(url_for("auth.login"))
return await render_template("auth/setup.html")
@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
username = form.get("username", "").strip()
email = form.get("email", "").strip()
password = form.get("password", "").encode()
if not username or not email or not password:
return await render_template("auth/setup.html", error="All fields required"), 400
pw_hash = bcrypt.hashpw(password, bcrypt.gensalt()).decode()
user = User(username=username, email=email, password_hash=pw_hash, role=UserRole.admin)
async with current_app.db_sessionmaker() as db:
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"))