auth.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. """Authentication and authorisation.
  2. Two modes, selected by `[auth] mode` in config (server-file only, never admin-editable):
  3. * **oidc** — Okta via Authlib. `/login` redirects to Okta; `/authorize` validates the
  4. token, reads the email, and checks it against the admin-managed allowed-users list.
  5. * **static** — a single shared credential for local development, so the app runs and is
  6. testable without an Okta tenant. Loud startup warning; refuses non-loopback binds.
  7. Roles come from the app's own users list (`[[auth.users]]`), re-resolved on every
  8. request against the live config snapshot, so removing/demoting a user takes effect
  9. immediately without them re-logging-in.
  10. `is_allowed` and `safe_next` are pure and Flask-free — the security-critical logic is
  11. unit-testable without a browser or an Okta tenant.
  12. """
  13. from __future__ import annotations
  14. import hmac
  15. import logging
  16. import secrets
  17. from functools import wraps
  18. from urllib.parse import urlparse
  19. from authlib.integrations.flask_client import OAuth
  20. from flask import (
  21. Blueprint,
  22. abort,
  23. current_app,
  24. redirect,
  25. render_template,
  26. request,
  27. session,
  28. url_for,
  29. )
  30. log = logging.getLogger("pps.auth")
  31. _oauth = OAuth()
  32. # ------------------------------------------------------------------ pure helpers
  33. def is_allowed(email: str, users) -> tuple[bool, str | None]:
  34. """Return (allowed, role). Case-insensitive email match against the users list."""
  35. key = (email or "").strip().casefold()
  36. if not key:
  37. return False, None
  38. for u in users:
  39. if str(u.get("email", "")).strip().casefold() == key:
  40. return True, u.get("role", "user")
  41. return False, None
  42. def safe_next(target: str | None) -> str:
  43. """Sanitise a post-login redirect target to a local path (blocks open redirects)."""
  44. if not target:
  45. return "/"
  46. # Reject anything that could send the browser off-site: absolute URLs, protocol-
  47. # relative (//host), and backslash tricks that some browsers treat as a slash.
  48. if not target.startswith("/") or target.startswith("//") or target.startswith("/\\"):
  49. return "/"
  50. parsed = urlparse(target)
  51. if parsed.scheme or parsed.netloc:
  52. return "/"
  53. return target
  54. # ------------------------------------------------------------------ session/user
  55. def current_user() -> dict | None:
  56. return session.get("user")
  57. def _resolve_role(store) -> str | None:
  58. """The logged-in user's current role.
  59. In OIDC mode this is re-resolved against the LIVE users list, so a removed/demoted
  60. user loses access immediately. In static (dev) mode the shared account isn't in the
  61. users list, so the role fixed at login (from [auth.static]) is trusted.
  62. """
  63. user = current_user()
  64. if not user:
  65. return None
  66. if store.snapshot().auth.get("mode") == "static":
  67. return user.get("role")
  68. allowed, role = is_allowed(user.get("email", ""), store.snapshot().auth.get("users", []))
  69. return role if allowed else None
  70. def login_required(view):
  71. @wraps(view)
  72. def wrapped(*args, **kwargs):
  73. if not current_user():
  74. if request.path.startswith("/api/"):
  75. abort(401)
  76. return redirect(url_for("auth.login", next=request.path))
  77. return view(*args, **kwargs)
  78. return wrapped
  79. def admin_required(view):
  80. @wraps(view)
  81. def wrapped(*args, **kwargs):
  82. if not current_user():
  83. if request.path.startswith("/api/"):
  84. abort(401)
  85. return redirect(url_for("auth.login", next=request.path))
  86. store = current_app.config["STORE"]
  87. if _resolve_role(store) != "admin":
  88. abort(403)
  89. return view(*args, **kwargs)
  90. return wrapped
  91. # ------------------------------------------------------------------ CSRF
  92. def csrf_token() -> str:
  93. tok = session.get("csrf")
  94. if not tok:
  95. tok = secrets.token_urlsafe(32)
  96. session["csrf"] = tok
  97. return tok
  98. def check_csrf() -> None:
  99. """before_request hook: require a matching X-CSRF-Token on state-changing /api calls."""
  100. if request.method in ("GET", "HEAD", "OPTIONS"):
  101. return
  102. if not request.path.startswith("/api/"):
  103. return # static-mode /login form post is exempt (has no session yet)
  104. tok = session.get("csrf")
  105. if not tok or not hmac.compare_digest(tok, request.headers.get("X-CSRF-Token", "")):
  106. abort(400, "CSRF token missing or invalid")
  107. # ------------------------------------------------------------------ blueprint
  108. def init_auth(app, store) -> Blueprint:
  109. cfg = store.snapshot()
  110. mode = cfg.auth.get("mode", "static")
  111. bp = Blueprint("auth", __name__)
  112. if mode == "oidc":
  113. okta = cfg.okta
  114. _oauth.init_app(app)
  115. _oauth.register(
  116. name="okta",
  117. server_metadata_url=str(okta["issuer"]).rstrip("/")
  118. + "/.well-known/openid-configuration",
  119. client_id=okta["client_id"],
  120. client_secret=okta["client_secret"],
  121. client_kwargs={"scope": "openid email profile"},
  122. )
  123. @bp.route("/login", methods=["GET", "POST"])
  124. def login():
  125. store = current_app.config["STORE"]
  126. snap = store.snapshot()
  127. target = safe_next(request.args.get("next"))
  128. if snap.auth.get("mode") == "static":
  129. return _static_login(snap, target)
  130. # OIDC: stash next in the session and bounce to Okta.
  131. session["next"] = target
  132. return _oauth.okta.authorize_redirect(redirect_uri=snap.okta["redirect_uri"])
  133. @bp.route("/authorize")
  134. def authorize():
  135. store = current_app.config["STORE"]
  136. snap = store.snapshot()
  137. token = _oauth.okta.authorize_access_token() # validates state/nonce/id_token
  138. info = token.get("userinfo") or _oauth.okta.userinfo(token=token)
  139. email = (info or {}).get("email", "")
  140. name = (info or {}).get("name", email)
  141. return _finish_login(snap, email, name, safe_next(session.pop("next", None)))
  142. @bp.route("/logout")
  143. def logout():
  144. session.clear()
  145. return redirect(url_for("auth.login"))
  146. return bp
  147. def _static_login(snap, target: str):
  148. static = snap.auth.get("static", {})
  149. error = None
  150. if request.method == "POST":
  151. u = request.form.get("username", "")
  152. p = request.form.get("password", "")
  153. ok_u = hmac.compare_digest(u, str(static.get("username", "")))
  154. ok_p = hmac.compare_digest(p, str(static.get("password", "")))
  155. if ok_u and ok_p:
  156. return _finish_login(
  157. snap,
  158. static.get("email", "admin@example.invalid"),
  159. static.get("username", "admin"),
  160. target,
  161. forced_role=static.get("role", "admin"),
  162. )
  163. error = "Invalid credentials"
  164. return render_template("login.html", error=error, next_url=target)
  165. def _finish_login(snap, email: str, name: str, target: str, forced_role: str | None = None):
  166. """Common tail: check access, set the session, redirect (or render denied)."""
  167. if forced_role is not None:
  168. allowed, role = True, forced_role
  169. else:
  170. allowed, role = is_allowed(email, snap.auth.get("users", []))
  171. if not allowed:
  172. log.warning("access denied for %r", email)
  173. return render_template(
  174. "denied.html",
  175. message=snap.auth.get("denied_message", "Your account is not authorised."),
  176. email=email,
  177. ), 403
  178. session.clear() # drop any OIDC state; prevent session fixation
  179. session["user"] = {"email": email, "role": role, "name": name}
  180. session["csrf"] = secrets.token_urlsafe(32)
  181. session.permanent = False # browser-session only (decision)
  182. log.info("login: %s (%s)", email, role)
  183. return redirect(target)