add Kerberos authentication support with proxy headers
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user