feat: dedicated logging for auth and api calls

This commit is contained in:
Björn Fromme
2026-08-14 08:16:54 +02:00
parent cc46d8c812
commit e28b789af2
6 changed files with 306 additions and 36 deletions
+127 -20
View File
@@ -7,6 +7,8 @@ use App\Security\AppRegistry;
use App\Security\IdTokenDecoder;
use App\Session\BffSessionStore;
use App\Session\TokenRefresher;
use Monolog\Attribute\WithMonologChannel;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -15,6 +17,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[WithMonologChannel('auth')]
class AuthController extends AbstractController
{
public function __construct(
@@ -24,6 +27,7 @@ class AuthController extends AbstractController
private readonly IdTokenDecoder $idTokenDecoder,
private readonly AppRegistry $apps,
private readonly AccessTokenRoles $tokenRoles,
private readonly LoggerInterface $logger,
private readonly string $kcBaseUrl,
private readonly string $kcRealm,
private readonly string $kcClientId,
@@ -43,6 +47,11 @@ class AuthController extends AbstractController
{
$appKey = (string) $request->query->get('app', '');
if (!$this->apps->isValidApp($appKey)) {
$this->logger->warning('auth.login.unknown_app', [
'app' => $appKey,
'ip' => $request->getClientIp(),
]);
throw $this->createNotFoundException('Unknown or missing app key');
}
@@ -67,6 +76,11 @@ class AuthController extends AbstractController
'code_challenge_method' => 'S256',
]);
$this->logger->info('auth.login.start', [
'app' => $appKey,
'ip' => $request->getClientIp(),
]);
return new RedirectResponse(
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/auth?{$params}"
);
@@ -80,32 +94,54 @@ class AuthController extends AbstractController
$expectedState = (string) $session->get('oauth_state', '');
$givenState = (string) $request->query->get('state', '');
if ($expectedState === '' || !hash_equals($expectedState, $givenState)) {
$this->logger->warning('auth.callback.state_mismatch', [
'ip' => $request->getClientIp(),
'had_expected_state' => $expectedState !== '',
]);
return new Response('Invalid or missing state', 401);
}
$appKey = (string) $session->get('oauth_app');
$returnUrl = $this->apps->resolveReturnUrl($appKey);
$response = $this->client->request(
'POST',
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/token",
[
'body' => [
'grant_type' => 'authorization_code',
'client_id' => $this->kcClientId,
'client_secret' => $this->kcClientSecret,
'code' => $request->query->get('code'),
'redirect_uri' => $this->kcRedirectUri,
'code_verifier' => $session->get('pkce_verifier'),
],
]
);
$tokens = $response->toArray();
// Everything from here to the role check either succeeds or throws
// (Keycloak unreachable, code rejected, bad signature, expired token,
// no sid claim) and ends as an anonymous 500. Log the cause, then let
// it through untouched — the response behaviour is deliberately
// unchanged.
try {
$response = $this->client->request(
'POST',
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/token",
[
'body' => [
'grant_type' => 'authorization_code',
'client_id' => $this->kcClientId,
'client_secret' => $this->kcClientSecret,
'code' => $request->query->get('code'),
'redirect_uri' => $this->kcRedirectUri,
'code_verifier' => $session->get('pkce_verifier'),
],
]
);
$tokens = $response->toArray();
$idClaims = $this->idTokenDecoder->decode($tokens['id_token']);
$kcSid = $idClaims['sid'] ?? null;
if (!$kcSid) {
throw new \RuntimeException('Keycloak did not issue a "sid" claim on the ID token');
$idClaims = $this->idTokenDecoder->decode($tokens['id_token']);
$kcSid = $idClaims['sid'] ?? null;
if (!$kcSid) {
throw new \RuntimeException('Keycloak did not issue a "sid" claim on the ID token');
}
$accessClaims = $this->idTokenDecoder->decode($tokens['access_token']);
} catch (\Throwable $e) {
$this->logger->error('auth.callback.failed', [
'app' => $appKey,
'ip' => $request->getClientIp(),
'exception' => $e,
]);
throw $e;
}
// Authorization gate: does this user hold the role required for
@@ -119,9 +155,16 @@ class AuthController extends AbstractController
// the app's finer-grained permissions. Those are display data for
// the app's UI only; they are never re-checked per request here —
// the backend authorizes off the forwarded access token.
$accessClaims = $this->idTokenDecoder->decode($tokens['access_token']);
$roles = $this->tokenRoles->extract($accessClaims);
if (!in_array($this->apps->accessRole($appKey), $roles, true)) {
$this->logger->warning('auth.login.denied', [
'app' => $appKey,
'required_role' => $this->apps->accessRole($appKey),
'roles' => $roles,
'user_id' => $idClaims['sub'] ?? null,
'email' => $idClaims['email'] ?? null,
]);
$session->remove('pkce_verifier');
$session->remove('oauth_state');
$session->remove('oauth_app');
@@ -154,6 +197,16 @@ class AuthController extends AbstractController
],
]);
$this->logger->info('auth.login.success', [
'app' => $appKey,
'user_id' => $idClaims['sub'],
'email' => $idClaims['email'] ?? null,
'preferred_username' => $idClaims['preferred_username'] ?? null,
'roles' => $roles,
'sid_hash' => $this->sidHash($kcSid),
'expires_at' => time() + (int) $tokens['expires_in'],
]);
$separator = str_contains($returnUrl, '?') ? '&' : '?';
return new RedirectResponse($returnUrl . $separator . http_build_query(['sid' => $kcSid]));
@@ -229,6 +282,8 @@ class AuthController extends AbstractController
{
$kcSid = $this->extractBearer($request);
if (!$kcSid) {
$this->logger->info('auth.logout.no_session');
return new JsonResponse(['error' => 'missing session'], 401);
}
@@ -238,6 +293,15 @@ class AuthController extends AbstractController
// One delete kills the session for every app that shared it.
$this->store->revoke($kcSid);
$this->logger->info('auth.logout', [
'sid_hash' => $this->sidHash($kcSid),
'user_id' => $data['user_id'] ?? null,
'email' => $data['profile']['email'] ?? null,
// false when the sid was already dead (expired, or a second
// logout from another tab) — the response is the same either way.
'was_live' => $data !== null,
]);
$params = http_build_query(array_filter([
'id_token_hint' => $idToken,
'post_logout_redirect_uri' => $this->postLogoutRedirect,
@@ -264,19 +328,42 @@ class AuthController extends AbstractController
{
$kcSid = $this->extractBearer($request);
if (!$kcSid) {
// Routine on first page load, before an app has a sid to send.
$this->logger->info('auth.session.unauthenticated', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
]);
return new JsonResponse(['error' => 'unauthenticated'], 401);
}
if (!$this->apps->isValidApp($appKey)) {
$this->logger->warning('auth.session.unknown_app', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
]);
return new JsonResponse(['error' => 'unknown or missing app key'], 400);
}
if ($this->refresher->ensureFresh($kcSid) === null) {
$this->logger->info('auth.session.expired', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
'sid_hash' => $this->sidHash($kcSid),
]);
return new JsonResponse(['error' => 'session expired'], 401);
}
$session = $this->store->get($kcSid);
if ($session === null) {
$this->logger->info('auth.session.expired', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
'sid_hash' => $this->sidHash($kcSid),
]);
return new JsonResponse(['error' => 'session expired'], 401);
}
@@ -284,6 +371,16 @@ class AuthController extends AbstractController
// every app the user opens, so a sid on its own says nothing about
// which app its holder may enter.
if (!in_array($this->apps->accessRole($appKey), $session['roles'] ?? [], true)) {
$this->logger->warning('auth.session.access_denied', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
'required_role' => $this->apps->accessRole($appKey),
'roles' => $session['roles'] ?? [],
'user_id' => $session['user_id'] ?? null,
'email' => $session['profile']['email'] ?? null,
'sid_hash' => $this->sidHash($kcSid),
]);
return new JsonResponse(['error' => 'access_denied'], 403);
}
@@ -297,6 +394,16 @@ class AuthController extends AbstractController
return str_starts_with($header, 'Bearer ') ? substr($header, 7) : null;
}
/**
* A sid is a live bearer credential, so it never goes into a log file.
* This short digest is enough to correlate records of one session
* without being replayable if the logs leak.
*/
private function sidHash(string $kcSid): string
{
return substr(hash('sha256', $kcSid), 0, 12);
}
private function base64UrlEncode(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
+58 -1
View File
@@ -3,7 +3,9 @@
namespace App\Controller;
use App\Security\BackendRegistry;
use App\Session\BffSessionStore;
use App\Session\TokenRefresher;
use Monolog\Attribute\WithMonologChannel;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -11,6 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[WithMonologChannel('api')]
class ProxyController extends AbstractController
{
private const string DEFAULT_BACKEND_KEY = 'default';
@@ -25,6 +28,7 @@ class ProxyController extends AbstractController
private readonly HttpClientInterface $client,
private readonly TokenRefresher $refresher,
private readonly BackendRegistry $backends,
private readonly BffSessionStore $store,
private readonly LoggerInterface $logger,
) {
}
@@ -37,22 +41,51 @@ class ProxyController extends AbstractController
#[Route('/api/{path}', requirements: ['path' => '.+'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])]
public function proxy(Request $request, string $path): Response
{
$start = microtime(true);
$kcSid = $this->extractBearer($request);
if (!$kcSid) {
$this->logger->info('api.request.unauthenticated', [
'method' => $request->getMethod(),
'path' => $path,
]);
return $this->jsonError('unauthenticated', 401);
}
$accessToken = $this->refresher->ensureFresh($kcSid);
if (!$accessToken) {
$this->logger->info('api.request.session_expired', [
'method' => $request->getMethod(),
'path' => $path,
'sid_hash' => $this->sidHash($kcSid),
]);
return $this->jsonError('session expired', 401);
}
$backendKey = $request->headers->get('X-Backend', self::DEFAULT_BACKEND_KEY);
if (!$this->backends->isValidBackend($backendKey)) {
$this->logger->warning('api.request.unknown_backend', [
'backend' => $backendKey,
'method' => $request->getMethod(),
'path' => $path,
'sid_hash' => $this->sidHash($kcSid),
]);
return $this->jsonError('unknown backend', 400);
}
$backendBaseUrl = $this->backends->resolveBaseUrl($backendKey);
// ensureFresh() has just read (and possibly rewritten) this entry, so
// this is a cache hit — it costs nothing and gives the log an identity.
$session = $this->store->get($kcSid);
$caller = [
'user_id' => $session['user_id'] ?? null,
'email' => $session['profile']['email'] ?? null,
'sid_hash' => $this->sidHash($kcSid),
];
$forwardHeaders = [];
foreach ($request->headers->all() as $name => $values) {
$lower = strtolower($name);
@@ -72,18 +105,42 @@ class ProxyController extends AbstractController
$body = $upstream->getContent(false);
$contentType = $upstream->getHeaders(false)['content-type'][0] ?? 'application/json';
} catch (\Throwable $e) {
$this->logger->error('Proxy request to backend failed', [
$this->logger->error('api.request.upstream_unreachable', $caller + [
'backend' => $backendKey,
'method' => $request->getMethod(),
'path' => $path,
'duration_ms' => $this->elapsedMs($start),
'exception' => $e,
]);
return $this->jsonError('upstream unreachable', 502);
}
// Never log bodies, query strings or headers: the forwarded headers
// carry the real Keycloak access token, and both bodies and query
// strings can carry anything the backend deals in.
$this->logger->log($status >= 400 ? 'warning' : 'info', 'api.request', $caller + [
'backend' => $backendKey,
'method' => $request->getMethod(),
'path' => $path,
'status' => $status,
'duration_ms' => $this->elapsedMs($start),
]);
return new Response($body, $status, ['Content-Type' => $contentType]);
}
private function elapsedMs(float $start): float
{
return round((microtime(true) - $start) * 1000, 1);
}
/** Never log a raw sid — see AuthController::sidHash(). */
private function sidHash(string $kcSid): string
{
return substr(hash('sha256', $kcSid), 0, 12);
}
private function extractBearer(Request $request): ?string
{
$header = $request->headers->get('Authorization', '');
+25 -10
View File
@@ -4,7 +4,9 @@ namespace App\Security;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Monolog\Attribute\WithMonologChannel;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -14,11 +16,13 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
* trusting any claims from it (in particular the "sid" and "sub" claims we
* key sessions on). Requires firebase/php-jwt.
*/
#[WithMonologChannel('auth')]
class IdTokenDecoder
{
public function __construct(
private readonly HttpClientInterface $client,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
private readonly string $kcBaseUrl,
private readonly string $kcRealm,
) {
@@ -29,18 +33,29 @@ class IdTokenDecoder
*/
public function decode(string $idToken): array
{
$jwks = $this->cache->get('keycloak_jwks_' . $this->kcRealm, function (ItemInterface $item) {
$item->expiresAfter(3600);
$response = $this->client->request(
'GET',
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/certs"
);
// A bad signature, an expired token or an unreachable JWKS endpoint all
// surface to the caller as a 500. Record why, then rethrow unchanged.
try {
$jwks = $this->cache->get('keycloak_jwks_' . $this->kcRealm, function (ItemInterface $item) {
$item->expiresAfter(3600);
$response = $this->client->request(
'GET',
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/certs"
);
return $response->toArray();
});
return $response->toArray();
});
$keys = JWK::parseKeySet($jwks);
$claims = JWT::decode($idToken, $keys);
$keys = JWK::parseKeySet($jwks);
$claims = JWT::decode($idToken, $keys);
} catch (\Throwable $e) {
$this->logger->error('auth.token.decode_failed', [
'realm' => $this->kcRealm,
'exception' => $e,
]);
throw $e;
}
/** @var array<string,mixed> $decoded */
$decoded = json_decode((string) json_encode($claims), true);
+27 -2
View File
@@ -4,9 +4,11 @@ namespace App\Session;
use App\Security\AccessTokenRoles;
use App\Security\IdTokenDecoder;
use Monolog\Attribute\WithMonologChannel;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[WithMonologChannel('auth')]
class TokenRefresher
{
private const int EXPIRY_LEEWAY_SECONDS = 30;
@@ -34,6 +36,12 @@ class TokenRefresher
{
$session = $this->store->get($kcSid);
if ($session === null) {
// Expired out of the cache, revoked by a logout, or simply never
// existed (a forged sid) — indistinguishable from here.
$this->logger->info('auth.session.missing', [
'sid_hash' => $this->sidHash($kcSid),
]);
return null;
}
@@ -57,7 +65,9 @@ class TokenRefresher
$tokens = $response->toArray();
} catch (\Throwable $e) {
// Refresh token expired/revoked (e.g. Keycloak-side admin logout) — kill the session.
$this->logger->warning('Keycloak token refresh failed, revoking session', [
$this->logger->warning('auth.session.refresh_failed', [
'sid_hash' => $this->sidHash($kcSid),
'user_id' => $session['user_id'] ?? null,
'exception' => $e,
]);
$this->store->revoke($kcSid);
@@ -78,13 +88,28 @@ class TokenRefresher
);
} catch (\Throwable $e) {
// Keep the previous list — a decode hiccup must not drop the session.
$this->logger->warning('Could not re-read roles from refreshed access token', [
$this->logger->warning('auth.session.role_reread_failed', [
'sid_hash' => $this->sidHash($kcSid),
'user_id' => $session['user_id'] ?? null,
'exception' => $e,
]);
}
$this->store->put($kcSid, $session);
$this->logger->info('auth.session.refreshed', [
'sid_hash' => $this->sidHash($kcSid),
'user_id' => $session['user_id'] ?? null,
'roles' => $session['roles'] ?? [],
'expires_at' => $session['expires_at'],
]);
return $session['access_token'];
}
/** Never log a raw sid — see AuthController::sidHash(). */
private function sidHash(string $kcSid): string
{
return substr(hash('sha256', $kcSid), 0, 12);
}
}