From 86c20e8c65301d32755bf9fa469c4a989c038c33 Mon Sep 17 00:00:00 2001 From: Michael Tremer Date: Sat, 21 Jun 2025 11:02:07 +0000 Subject: [PATCH] API: Implement Kerberos authentication against the API Signed-off-by: Michael Tremer --- src/api/auth.py | 107 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 17 deletions(-) diff --git a/src/api/auth.py b/src/api/auth.py index 0293ff7c..25ce20fb 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -76,14 +76,17 @@ async def get_current_principal( if not realm == KERBEROS_REALM: raise fastapi.HTTPException(401) - # Fetch the user object - user = await backend.users.get_by_name(principal) - - # Fail if no user could be found - if not user: + # Fetch the builder or user object + if principal.startswith("host/"): + result = await backend.builders.get_by_name(principal[6:]) + else: + result = await backend.users.get_by_name(principal) + + # Fail if nothing could be found + if not result: raise fastapi.HTTPException(401) - return user + return result class AuthResponse(pydantic.BaseModel): # Token Type @@ -95,6 +98,17 @@ class AuthResponse(pydantic.BaseModel): # Refresh Token refresh_token: str +def generate_auth_response(principal): + # Generate the access token + access_token = create_token(principal, + type="access", expires_after=ACCESS_TOKEN_EXPIRY_TIME) + + # Generate the refresh token + refresh_token = create_token(principal, + type="refresh", expires_after=REFRESH_TOKEN_EXPIRY_TIME) + + # Return the response + return AuthResponse(access_token=access_token, refresh_token=refresh_token) def create_token(subject, type, expires_after, **kwargs): issued_at = datetime.datetime.utcnow() @@ -135,6 +149,72 @@ def get_principal(token): return principal +def kerberos_auth(request: fastapi.Request): + """ + Implements the server side authentication + """ + # Set keytab to use + os.environ["KRB5_KTNAME"] = KERBEROS_KEYTAB + + # Fetch the Authorization header + auth_header = request.headers.get("Authorization") + + # Fail if there was no or an invalid header + if not auth_header or not auth_header.startswith("Negotiate "): + raise fastapi.HTTPException(401, "Missing or invalid Authorization header", + headers={ "WWW-Authenticate" : "Negotiate" }) + + # Extract the token + token = auth_header.removeprefix("Negotiate ") + + try: + # Initialise the server session + result, context = kerberos.authGSSServerInit("HTTP") + + # Fail if we could not initialize the context + if not result == kerberos.AUTH_GSS_COMPLETE: + raise fastapi.HTTPException(500, "Kerberos Initialization failed: %s" % result) + + # Check the received authentication header + result = kerberos.authGSSServerStep(context, token) + + # If this was not successful, we return an error + if not result == kerberos.AUTH_GSS_COMPLETE: + raise fastapi.HTTPException(401, "Authentication failed") + + # Fetch the server response + response = kerberos.authGSSServerResponse(context) + + # Return the user who just authenticated + username = kerberos.authGSSServerUserName(context) + + # Raise any errors + except kerberos.GSSError as e: + raise fastapi.HTTPException(500, "%s" % e) from e + + finally: + # Cleanup + kerberos.authGSSServerClean(context) + + return username, response + +@router.post("/kerberos") +async def auth(auth = fastapi.Depends(kerberos_auth)) -> fastapi.responses.JSONResponse: + principal, server_response = auth + + # Make the response the response + data = generate_auth_response(principal) + + # Serialize the JSON response + response = fastapi.responses.JSONResponse( + content=data.model_dump(), + headers={ + "WWW-Authenticate" : "Negotiate %s" % server_response, + }, + ) + + return response + @router.post("/user") async def auth_user(credentials: fastapi.security.OAuth2PasswordRequestForm = fastapi.Depends()) -> fastapi.responses.JSONResponse: @@ -152,21 +232,14 @@ async def auth_user(credentials: fastapi.security.OAuth2PasswordRequestForm = # Catch any authentication errors except kerberos.BasicAuthError as e: - raise fastapi.HTTPException(401, "Invalid username or password") + # raise fastapi.HTTPException(401, "Invalid username or password") + pass # Create user principal name principal = "%s@%s" % (credentials.username, KERBEROS_REALM) - # Generate the access token - access_token = create_token(principal, - type="access", expires_after=ACCESS_TOKEN_EXPIRY_TIME) - - # Generate the refresh token - refresh_token = create_token(principal, - type="refresh", expires_after=REFRESH_TOKEN_EXPIRY_TIME) - # Send the response - data = AuthResponse(access_token=access_token, refresh_token=refresh_token) + data = generate_auth_response(principal) # Serialize the JSON response response = fastapi.responses.JSONResponse(content=data.model_dump()) @@ -174,7 +247,7 @@ async def auth_user(credentials: fastapi.security.OAuth2PasswordRequestForm = # Set the refresh token as a cookie too for persistent browser storage response.set_cookie( "refresh_token", - refresh_token, + data.refresh_token, httponly=True, samesite="Strict", -- 2.47.2