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."