feat: dedicated logging for auth and api calls
This commit is contained in:
@@ -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), '+/', '-_'), '=');
|
||||
|
||||
Reference in New Issue
Block a user