This is an automated email from the ASF dual-hosted git repository.

arm pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/arm by this push:
     new 99c893ef Add failed authentication logging to identified areas. Closes 
#725
99c893ef is described below

commit 99c893efe7a2c0e0b4822d5abc46c78579e2b9be
Author: Alastair McFarlane <[email protected]>
AuthorDate: Mon Mar 2 14:30:31 2026 +0000

    Add failed authentication logging to identified areas. Closes #725
---
 atr/api/__init__.py           |  2 +-
 atr/jwtoken.py                | 11 ++++++++++-
 atr/log.py                    | 11 +++++++++++
 atr/ssh.py                    | 15 +++++++++++++--
 atr/storage/writers/tokens.py |  7 +------
 5 files changed, 36 insertions(+), 10 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 251e268e..cf2d9ebf 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -438,7 +438,7 @@ async def jwt_create(data: models.api.JwtCreateArgs) -> 
DictResponse:
     # Expects {"asfuid": "uid", "pat": "pat-token"}
     # Returns {"asfuid": "uid", "jwt": "jwt-token"}
     asf_uid = data.asfuid
-    log.add_context(user_id=asf_uid)
+    log.set_asf_uid(asf_uid)
     async with storage.write(asf_uid) as write:
         wafc = write.as_foundation_committer()
         jwt = await wafc.tokens.issue_jwt(data.pat)
diff --git a/atr/jwtoken.py b/atr/jwtoken.py
index 634780fe..3ed8edef 100644
--- a/atr/jwtoken.py
+++ b/atr/jwtoken.py
@@ -72,10 +72,13 @@ def require[**P, R](func: Callable[P, Coroutine[Any, Any, 
R]]) -> Callable[P, Aw
         try:
             claims = await verify(token)
         except jwt.ExpiredSignatureError as exc:
+            log.failed_authentication("jwt_token_expired")
             raise base.ASFQuartException("Token has expired", errorcode=401) 
from exc
         except jwt.InvalidTokenError as exc:
+            log.failed_authentication("jwt_token_invalid")
             raise base.ASFQuartException("Invalid Bearer JWT format", 
errorcode=401) from exc
         except jwt.PyJWTError as exc:
+            log.failed_authentication("jwt_token_invalid_2")
             raise base.ASFQuartException(f"Invalid Bearer JWT: {exc}", 
errorcode=401) from exc
 
         quart.g.jwt_claims = claims
@@ -85,6 +88,10 @@ def require[**P, R](func: Callable[P, Coroutine[Any, Any, 
R]]) -> Callable[P, Aw
 
 
 async def verify(token: str) -> dict[str, Any]:
+    # Grab the "supposed" asf UID from the token presented, to make sure we 
know who failed to authenticate on failure.
+    claims_unsafe = jwt.decode(token, verify=False)
+    asf_uid = claims_unsafe.get("sub")
+    log.set_asf_uid(asf_uid)
     claims = jwt.decode(
         token,
         _JWT_SECRET_KEY,
@@ -93,10 +100,12 @@ async def verify(token: str) -> dict[str, Any]:
         audience=_ATR_JWT_AUDIENCE,
         options={"require": ["sub", "iss", "aud", "iat", "exp", "jti"]},
     )
-    asf_uid = claims.get("sub")
+    log.debug(f"JWT claims: {claims}")
     if not isinstance(asf_uid, str):
+        log.failed_authentication("jwt_subject_invalid")
         raise jwt.InvalidTokenError("Invalid Bearer JWT subject")
     if not await ldap.is_active(asf_uid):
+        log.failed_authentication("account_deleted_or_banned")
         raise base.ASFQuartException("Account is disabled", errorcode=401)
     return claims
 
diff --git a/atr/log.py b/atr/log.py
index 480f5159..75506713 100644
--- a/atr/log.py
+++ b/atr/log.py
@@ -119,6 +119,13 @@ def exception(msg: str, **kwargs) -> None:
     _event(logging.ERROR, msg, exc_info=True, **kwargs)
 
 
+def failed_authentication(reason: str) -> None:
+    warning(
+        "Authentication failed",
+        reason=reason,
+    )
+
+
 def get_context(arg) -> Any | None:
     """Get context from the request log"""
     return structlog.contextvars.get_contextvars().get(arg, None)
@@ -158,6 +165,10 @@ def python_repr(object_name: str) -> str:
     return f"<{object_name}>"
 
 
+def set_asf_uid(asfuid: str | None) -> None:
+    add_context(asfuid=asfuid)
+
+
 def warning(msg: str, **kwargs) -> None:
     _event(logging.WARNING, msg, **kwargs)
 
diff --git a/atr/ssh.py b/atr/ssh.py
index 567a4085..80bad1d4 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -131,10 +131,12 @@ class SSHServer(asyncssh.SSHServer):
         return True
 
     async def validate_public_key(self, username: str, key: asyncssh.SSHKey) 
-> bool:
-        # This method is not called when username is not "github"
+        # This method is not called when username is not "github" - as long as 
the key was valid
         # Also, this SSHServer.validate_public_key method does not perform 
signature verification
         # The SSHServerConnection.validate_public_key method performs 
signature verification
         if username != "github":
+            # We log an authentication failure in here because if we're here 
with a different username the key failed
+            log.failed_authentication("public_key_invalid")
             return False
 
         fingerprint = key.get_fingerprint()
@@ -144,11 +146,15 @@ class SSHServer(asyncssh.SSHServer):
             if workflow_key is None:
                 return False
 
+            # In some cases this will be a service account
+            self._github_asf_uid = workflow_key.asf_uid
+            log.set_asf_uid(self._github_asf_uid)
+
             now = int(time.time())
             if workflow_key.expires < now:
+                log.failed_authentication("public_key_expired")
                 return False
 
-            self._github_asf_uid = workflow_key.asf_uid
             self._github_payload = workflow_key.github_payload
             return True
 
@@ -188,10 +194,14 @@ async def server_start() -> asyncssh.SSHAcceptor:
         server_instance = connection.get_owner()
         return asyncio.create_task(_step_01_handle_client(process, 
server_instance))
 
+    def error_handler(*args, **kwargs) -> None:
+        pass
+
     server = await asyncssh.create_server(
         SSHServer,
         server_host_keys=[key_path],
         process_factory=process_factory,
+        error_handler=error_handler,
         host=_CONFIG.SSH_HOST,
         port=_CONFIG.SSH_PORT,
         encoding=None,
@@ -243,6 +253,7 @@ async def _step_01_handle_client(process: 
asyncssh.SSHServerProcess, server: SSH
 
 async def _step_02_handle_safely(process: asyncssh.SSHServerProcess, server: 
SSHServer) -> None:
     asf_uid = server._get_asf_uid(process)
+    log.set_asf_uid(asf_uid)
     log.info(f"Handling command for authenticated user: {asf_uid}")
 
     if not process.command:
diff --git a/atr/storage/writers/tokens.py b/atr/storage/writers/tokens.py
index d700dee5..620e9d3b 100644
--- a/atr/storage/writers/tokens.py
+++ b/atr/storage/writers/tokens.py
@@ -128,12 +128,7 @@ class FoundationCommitter(GeneralPublic):
         # Verify account still exists in LDAP
         account_details = await ldap.account_lookup(self.__asf_uid)
         if (account_details is None) or ldap.is_banned(account_details):
-            log.warning(
-                "Authentication failed",
-                extra={
-                    "reason": "account_deleted_or_banned",
-                },
-            )
+            log.failed_authentication("account_deleted_or_banned")
             raise storage.AccessError("Authentication failed")
 
         issued_jwt = jwtoken.issue(self.__asf_uid)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to