From 67100144e10e89a5dd814249588d46a79fbc3d78 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 2 Jun 2026 16:24:42 +0200 Subject: [PATCH 1/5] 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 343c86b6..e6219caf 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 01d58e79..3d9c1d63 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") @@ -2253,7 +2262,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: From de0781053a0fe2ac19a101bcd0fbb7f3da397e90 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 2 Jun 2026 16:24:42 +0200 Subject: [PATCH 2/5] 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: From 6bed4102e7bbc21d1bff5acdcaca8cf03742d45a Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 9 Jun 2026 15:44:42 +0200 Subject: [PATCH 3/5] remove dependency on gssapi --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d495f04a..78bfe0ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ 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", From 8dc1ecacb2f71d16f7761f5d17871b3687546b3f Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 9 Jun 2026 15:45:33 +0200 Subject: [PATCH 4/5] remove dependency on gssapi directly using curl to negotiate with https from GUI --- src/aare/gui/auth.py | 56 ++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/aare/gui/auth.py b/src/aare/gui/auth.py index fa25edbe..f87e0709 100644 --- a/src/aare/gui/auth.py +++ b/src/aare/gui/auth.py @@ -1,7 +1,6 @@ -import os +import json +import subprocess import jwt -import requests -from requests_gssapi import HTTPSPNEGOAuth from aare.common.models import TokenData from aare.common.auth_models import get_user @@ -19,38 +18,45 @@ def auth(base_url: str | None) -> str: return jwt.encode(token_data.model_dump(), "ABC123") url = f"{base_url}/token" + apache_url = f"https://mx-x10sa-queue-01.psi.ch/" try: - response = requests.post( - url, - data={ - "username": curr_user, - "password": "" - }, - 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) + result = subprocess.run( + [ + 'curl', '-sk', '--cacert', '/my/top/secret/path', + '--negotiate', '-u', ':', + apache_url, + ], + capture_output=True, + text=True, + timeout=18.0, ) - except requests.RequestException as e: - logger.error(f"Authentication request failed (network): {e}") + except subprocess.TimeoutExpired as e: + logger.error(f"Authentication curl timed out: {e}") raise RuntimeError( - "Cannot reach AareDAQ server (network error). " + "Cannot reach AareDAQ server (timeout). " + "Please check the server is running and your connection." + ) from e + except FileNotFoundError as e: + logger.error(f"curl not found: {e}") + raise RuntimeError("curl not found on this system.") from e + except OSError as e: + logger.error(f"Authentication curl failed (OS error): {e}") + raise RuntimeError( + "Cannot reach AareDAQ server (OS error). " "Please check the server is running and your connection." ) from e - if response.status_code != 200: - # Avoid dumping full HTML/tracebacks into the GUI; keep it short and actionable - logger.error(f"Authentication request failed: HTTP {response.status_code}. Body: {response.text[:500]}") + if result.returncode != 0: + logger.error(f"Authentication curl exited {result.returncode}. stderr: {result.stderr[:500]}") raise RuntimeError( - f"Authentication failed (HTTP {response.status_code}). " - "The server may be starting up or unavailable." + f"Authentication failed (curl exit {result.returncode}). " + "Check server is running and Kerberos ticket is valid (kinit)." ) try: - response_json = response.json() - except ValueError as e: - logger.error(f"Authentication response was not JSON. Body: {response.text[:500]}") + response_json = json.loads(result.stdout) + except json.JSONDecodeError as e: + logger.error(f"Authentication response not JSON. stdout: {result.stdout[:500]}") raise RuntimeError( "Authentication failed (invalid server response). " "The server may be starting up or misconfigured." From 6351265698b1494416319e0bf5662171eed3bf89 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 9 Jun 2026 16:16:11 +0200 Subject: [PATCH 5/5] add Apache authentication and token handling using curl in GUI and access log parsing in DAQ --- src/aare/daq/auth.py | 34 ++++++++++++++++++ src/aare/daq/server.py | 2 +- src/aare/gui/auth.py | 81 +++++++++++++++++++++++++++++++----------- 3 files changed, 96 insertions(+), 21 deletions(-) diff --git a/src/aare/daq/auth.py b/src/aare/daq/auth.py index a8e36b92..e0495098 100644 --- a/src/aare/daq/auth.py +++ b/src/aare/daq/auth.py @@ -1,6 +1,7 @@ import grp import os import pwd +import re import uuid from datetime import datetime, timedelta, UTC from typing import List @@ -30,6 +31,10 @@ BATON_REQUEST_TIMEOUT_SECONDS = 30 STAFF_GROUP = "unx-MXgroup" SUPER_USERS = ["e10019", "e11206", "e18147"] +APACHE_ACCESS_LOG = "/var/log/httpd/daq-access.log" +# Common Log Format: IP - username [timestamp] "request" status bytes ... +_LOG_PATTERN = re.compile(r'^\S+ \S+ (\S+) \[.*?\] ".*?" (\d+)') + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") class TokenData(BaseModel): @@ -46,6 +51,35 @@ def create_access_token(token: TokenData): return encoded_jwt +def authenticate_from_apache_log() -> str: + """ + Read the Apache access log and return the username from the most recent + 200 response. Apache and the DAQ server are co-located on the same machine. + """ + try: + with open(APACHE_ACCESS_LOG, 'r') as f: + lines = f.readlines() + except OSError as e: + raise AuthenticationException( + message=f"Cannot read Apache access log: {e}", + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + code=AuthErrorCode.INVALID_TOKEN, + ) from e + + for line in reversed(lines): + m = _LOG_PATTERN.match(line) + if m and m.group(2) == '200' and m.group(1) != '-': + return m.group(1) + + raise AuthenticationException( + message="No authenticated user found in Apache access log", + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + code=AuthErrorCode.INVALID_TOKEN, + ) + + def authenticate_from_proxy_header(request: Request) -> str: """ Extract the pre-authenticated username from the trusted Apache proxy header. diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index 064b3cf8..a7877315 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -274,7 +274,7 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends if request.headers.get("X-Remote-User"): username = auth.authenticate_from_proxy_header(request) else: - username = form_data.username + username = auth.authenticate_from_apache_log() data = await run_in_threadpool(auth.authenticate_user, cfg, username) return {"access_token": data, "token_type": "bearer"} diff --git a/src/aare/gui/auth.py b/src/aare/gui/auth.py index f87e0709..a2f9c39b 100644 --- a/src/aare/gui/auth.py +++ b/src/aare/gui/auth.py @@ -8,6 +8,9 @@ from aare.common.logger_config import setup_logger logger = setup_logger('aareGUI') +APACHE_URL = "https://mx-x10sa-queue-01.psi.ch/" +CACERT = "/sls/x10sa/misc/.cert/10s.crt" + def auth(base_url: str | None) -> str: curr_user = get_user() if base_url is None: @@ -17,46 +20,84 @@ def auth(base_url: str | None) -> str: pgroups=["p16371", "p22233"]) return jwt.encode(token_data.model_dump(), "ABC123") - url = f"{base_url}/token" - apache_url = f"https://mx-x10sa-queue-01.psi.ch/" + # Step 1: SPNEGO against Apache, dump response headers, discard body try: - result = subprocess.run( - [ - 'curl', '-sk', '--cacert', '/my/top/secret/path', - '--negotiate', '-u', ':', - apache_url, - ], + apache_result = subprocess.run( + ['curl', '-sk', '--cacert', CACERT, + '--negotiate', '-u', ':', + '-D', '-', '-o', '/dev/null', + APACHE_URL], capture_output=True, text=True, timeout=18.0, ) except subprocess.TimeoutExpired as e: - logger.error(f"Authentication curl timed out: {e}") + logger.error(f"Apache auth curl timed out: {e}") raise RuntimeError( - "Cannot reach AareDAQ server (timeout). " + "Cannot reach Apache server (timeout). " "Please check the server is running and your connection." ) from e except FileNotFoundError as e: logger.error(f"curl not found: {e}") raise RuntimeError("curl not found on this system.") from e except OSError as e: - logger.error(f"Authentication curl failed (OS error): {e}") + logger.error(f"Apache auth curl OS error: {e}") + raise RuntimeError( + "Cannot reach Apache server (OS error). " + "Please check the server is running and your connection." + ) from e + + if apache_result.returncode != 0: + logger.error(f"Apache curl exited {apache_result.returncode}. stderr: {apache_result.stderr[:500]}") + raise RuntimeError( + f"Apache authentication failed (curl exit {apache_result.returncode}). " + "Check Kerberos ticket is valid (kinit)." + ) + + # Parse X-Remote-User from response headers (stdout = headers due to -D -) + remote_user = None + for line in apache_result.stdout.splitlines(): + if line.lower().startswith('x-remote-user:'): + remote_user = line.split(':', 1)[1].strip() + break + + if not remote_user: + logger.error(f"X-Remote-User not found in Apache response headers. Headers: {apache_result.stdout[:500]}") + raise RuntimeError("Apache authentication failed (no X-Remote-User in response).") + + # Step 2: Call FastAPI /token with the authenticated username + url = f"{base_url}/token" + try: + token_result = subprocess.run( + ['curl', '-sk', '--cacert', CACERT, + '-X', 'POST', url, + '-d', f'username={remote_user}&password=', + '-H', 'Content-Type: application/x-www-form-urlencoded'], + capture_output=True, + text=True, + timeout=18.0, + ) + except subprocess.TimeoutExpired as e: + logger.error(f"Token request curl timed out: {e}") + raise RuntimeError( + "Cannot reach AareDAQ server (timeout). " + "Please check the server is running and your connection." + ) from e + except OSError as e: + logger.error(f"Token request curl OS error: {e}") raise RuntimeError( "Cannot reach AareDAQ server (OS error). " "Please check the server is running and your connection." ) from e - if result.returncode != 0: - logger.error(f"Authentication curl exited {result.returncode}. stderr: {result.stderr[:500]}") - raise RuntimeError( - f"Authentication failed (curl exit {result.returncode}). " - "Check server is running and Kerberos ticket is valid (kinit)." - ) + if token_result.returncode != 0: + logger.error(f"Token curl exited {token_result.returncode}. stderr: {token_result.stderr[:500]}") + raise RuntimeError(f"Token request failed (curl exit {token_result.returncode}).") try: - response_json = json.loads(result.stdout) + response_json = json.loads(token_result.stdout) except json.JSONDecodeError as e: - logger.error(f"Authentication response not JSON. stdout: {result.stdout[:500]}") + logger.error(f"Token response not JSON. stdout: {token_result.stdout[:500]}") raise RuntimeError( "Authentication failed (invalid server response). " "The server may be starting up or misconfigured." @@ -64,7 +105,7 @@ def auth(base_url: str | None) -> str: token = response_json.get("access_token") if not token or not isinstance(token, str): - logger.error(f"Authentication response missing access_token. Keys: {list(response_json.keys())}") + logger.error(f"Missing access_token. Keys: {list(response_json.keys())}") raise RuntimeError( "Authentication failed (missing token in server response). " "The server may be starting up."