add Kerberos authentication support with proxy headers

This commit is contained in:
2026-06-09 14:32:01 +02:00
committed by appleb_m
parent b8041c52a4
commit de0781053a
4 changed files with 47 additions and 10 deletions
+2 -1
View File
@@ -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",
+30 -5
View File
@@ -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())
+13 -4
View File
@@ -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__":
+2
View File
@@ -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: