refactor: rename package directory fabledscryer → roundtable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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("=")
|
||||
@@ -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"))
|
||||
Reference in New Issue
Block a user