]> git.ipfire.org Git - pbs.git/commitdiff
API: Implement Kerberos authentication against the API
authorMichael Tremer <michael.tremer@ipfire.org>
Sat, 21 Jun 2025 11:02:07 +0000 (11:02 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Sat, 21 Jun 2025 11:02:07 +0000 (11:02 +0000)
Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
src/api/auth.py

index 0293ff7c5d5379fa49f23fd94ae4ab70c37837f3..25ce20fbb49f31169369b643e043bc531eacf163 100644 (file)
@@ -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",