From f0ecba9a8a9bd6b01db47e9c45b39fd99c463b92 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 2 Jun 2026 16:24:42 +0200 Subject: [PATCH] add Kerberos authentication support with proxy headers --- pyproject.toml | 3 ++- src/aare/daq/auth.py | 35 ++++++++++++++++++++++++++++++----- src/aare/daq/server.py | 17 +++++++++++++---- src/aare/gui/auth.py | 2 ++ 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f29dcdba..d495f04a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "opencv-python-headless==4.11.0.86", "PySide6==6.9.0", "requests==2.32.4", + "requests-gssapi>=1.3.0", "pyepics==3.5.8", "redis==6.2.0", "python-redis-lock==4.0.0", @@ -33,7 +34,7 @@ dependencies = [ [project.optional-dependencies] test = [ - "pytest==9.0.3", +# "pytest==9.0.3", "pytest-cov==7.1.0", "pytest-mock==3.14.0", "pytest-qt==4.4.0", diff --git a/src/aare/daq/auth.py b/src/aare/daq/auth.py index 33ed853d..a8e36b92 100644 --- a/src/aare/daq/auth.py +++ b/src/aare/daq/auth.py @@ -7,7 +7,7 @@ from typing import List import time import jwt -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer from pydantic import BaseModel @@ -46,15 +46,40 @@ def create_access_token(token: TokenData): return encoded_jwt -def authenticate_user(cfg: BeamlineConfig, form_data: OAuth2PasswordRequestForm) -> str: - user_info = pwd.getpwnam(form_data.username) +def authenticate_from_proxy_header(request: Request) -> str: + """ + Extract the pre-authenticated username from the trusted Apache proxy header. + Only trust this header when the request originates from localhost (127.0.0.1 / ::1), + meaning only the Apache proxy running on the same host can supply it. + """ + client_host = request.client.host if request.client else None + if client_host not in ("127.0.0.1", "::1"): + raise AuthenticationException( + message="X-Remote-User header is only trusted from the localhost proxy", + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + code=AuthErrorCode.INVALID_TOKEN, + ) + remote_user = request.headers.get("X-Remote-User") + if not remote_user: + raise AuthenticationException( + message="Missing X-Remote-User header from proxy", + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + code=AuthErrorCode.INVALID_TOKEN, + ) + return remote_user + + +def authenticate_user(cfg: BeamlineConfig, username: str) -> str: + user_info = pwd.getpwnam(username) groups = os.getgrouplist(user_info.pw_name, user_info.pw_gid) supplementary_groups = [grp.getgrgid(g).gr_name.lower() for g in groups] - super_user = form_data.username in SUPER_USERS + super_user = username in SUPER_USERS pgroups = [group for group in supplementary_groups if group.startswith('p')] staff = "unx-mxgroup" in supplementary_groups or "unx-sls_mx" in supplementary_groups or super_user - token = TokenData(sub=form_data.username, + token = TokenData(sub=username, pgroups=pgroups, staff=staff, session=cfg.generate_session()) diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index 62c817b4..064b3cf8 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -24,7 +24,7 @@ from aare.common.automation_models import AutomationProgress from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan from aare.common.sample_geometry import SampleGeometryModel -from fastapi import FastAPI, Depends +from fastapi import FastAPI, Depends, Request from fastapi.concurrency import run_in_threadpool from fastapi import HTTPException from fastapi import status as api_status @@ -256,17 +256,26 @@ async def automation_progress_event_stream() -> AsyncGenerator[str, None]: return @app.post("/token") -async def login(form_data: OAuth2PasswordRequestForm = Depends()): +async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()): """ Authenticate a user and return an access token. + When the request carries an X-Remote-User header (set by the Apache Kerberos + proxy), the username is taken from that header and the proxy origin is verified. + Otherwise the username from the form data is used (local / dev access). + Args: + request: The incoming HTTP request (used to inspect headers and client IP). form_data: OAuth2 password request form containing username and password. Returns: A dictionary containing the access token and token type. """ - data = await run_in_threadpool(auth.authenticate_user, cfg, form_data) + if request.headers.get("X-Remote-User"): + username = auth.authenticate_from_proxy_header(request) + else: + username = form_data.username + data = await run_in_threadpool(auth.authenticate_user, cfg, username) return {"access_token": data, "token_type": "bearer"} @app.get("/meta/error-codes") @@ -2377,7 +2386,7 @@ def main(): urllib3.disable_warnings() # Run the application using uvicorn - uvicorn.run("aare.daq.server:app", host="0.0.0.0", port=5210, workers=2, log_config=get_uvicorn_logging_config()) + uvicorn.run("aare.daq.server:app", host="127.0.0.1", port=5210, workers=2, log_config=get_uvicorn_logging_config()) if __name__ == "__main__": diff --git a/src/aare/gui/auth.py b/src/aare/gui/auth.py index 2cabc77a..fa25edbe 100644 --- a/src/aare/gui/auth.py +++ b/src/aare/gui/auth.py @@ -1,6 +1,7 @@ import os import jwt import requests +from requests_gssapi import HTTPSPNEGOAuth from aare.common.models import TokenData from aare.common.auth_models import get_user @@ -28,6 +29,7 @@ def auth(base_url: str | None) -> str: headers={ "Content-Type": "application/x-www-form-urlencoded" }, + auth=HTTPSPNEGOAuth(), # Kerberos SPNEGO negotiation via user's TGT timeout=(3.0, 15.0), # (connect timeout, read timeout) ) except requests.RequestException as e: