412 lines
16 KiB
PHP
412 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Security\AccessTokenRoles;
|
|
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;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
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(
|
|
private readonly HttpClientInterface $client,
|
|
private readonly BffSessionStore $store,
|
|
private readonly TokenRefresher $refresher,
|
|
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,
|
|
private readonly string $kcClientSecret,
|
|
private readonly string $kcRedirectUri,
|
|
private readonly ?string $postLogoutRedirect = null,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Entry point every app's login page navigates to (full browser
|
|
* redirect, e.g. via Appsmith's navigateTo — never a fetch/query,
|
|
* since Appsmith queries run server-side and can't perform this leg).
|
|
*/
|
|
#[Route('/auth/login', methods: ['GET'])]
|
|
public function login(Request $request): RedirectResponse
|
|
{
|
|
$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');
|
|
}
|
|
|
|
$verifier = $this->base64UrlEncode(random_bytes(64));
|
|
$challenge = $this->base64UrlEncode(hash('sha256', $verifier, true));
|
|
$state = bin2hex(random_bytes(16));
|
|
|
|
// Plain Symfony session cookie, used only for this short
|
|
// browser <-> Keycloak <-> browser leg. Not the app's session.
|
|
$session = $request->getSession();
|
|
$session->set('pkce_verifier', $verifier);
|
|
$session->set('oauth_state', $state);
|
|
$session->set('oauth_app', $appKey);
|
|
|
|
$params = http_build_query([
|
|
'client_id' => $this->kcClientId,
|
|
'response_type' => 'code',
|
|
'scope' => 'openid profile email',
|
|
'redirect_uri' => $this->kcRedirectUri,
|
|
'state' => $state,
|
|
'code_challenge' => $challenge,
|
|
'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}"
|
|
);
|
|
}
|
|
|
|
#[Route('/auth/callback', methods: ['GET'])]
|
|
public function callback(Request $request): Response
|
|
{
|
|
$session = $request->getSession();
|
|
|
|
$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);
|
|
|
|
// 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');
|
|
}
|
|
|
|
$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
|
|
// the app they're trying to enter? Checked against the access
|
|
// token (not the ID token), since that's where client roles live.
|
|
// This is the only enforcement point — the proxy trusts any
|
|
// already-established session, by design, since "may this user
|
|
// use app X" is a login-time question, not a per-request one.
|
|
//
|
|
// The full role list is kept on the session so /auth/me can report
|
|
// 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.
|
|
$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');
|
|
|
|
$separator = str_contains($returnUrl, '?') ? '&' : '?';
|
|
|
|
return new RedirectResponse($returnUrl . $separator . http_build_query(['error' => 'access_denied']));
|
|
}
|
|
|
|
$session->remove('pkce_verifier');
|
|
$session->remove('oauth_state');
|
|
$session->remove('oauth_app');
|
|
|
|
// Same kcSid -> same entry, whichever app is logging in. If another
|
|
// app already created this session, this simply refreshes it.
|
|
$this->store->put($kcSid, [
|
|
'user_id' => $idClaims['sub'],
|
|
'access_token' => $tokens['access_token'],
|
|
'refresh_token' => $tokens['refresh_token'],
|
|
'id_token' => $tokens['id_token'],
|
|
'expires_at' => time() + (int) $tokens['expires_in'],
|
|
'roles' => $roles,
|
|
'profile' => [
|
|
'email' => $idClaims['email'] ?? null,
|
|
'email_verified' => $idClaims['email_verified'] ?? null,
|
|
'name' => $idClaims['name'] ?? null,
|
|
'given_name' => $idClaims['given_name'] ?? null,
|
|
'family_name' => $idClaims['family_name'] ?? null,
|
|
'preferred_username' => $idClaims['preferred_username'] ?? null,
|
|
],
|
|
]);
|
|
|
|
$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]));
|
|
}
|
|
|
|
/**
|
|
* Guard endpoint apps call on page load: is this sid a live session, and
|
|
* may it enter *this* app? Deliberately returns no profile data, so it
|
|
* stays cheap enough to run on every page load.
|
|
*
|
|
* This exists because an app's own login state (a `sid` in client-side
|
|
* storage) is trivially forgeable — checking it client-side proves
|
|
* nothing. It is still UI gating, not an authorization boundary: the
|
|
* backend authorizes off the access token the proxy injects.
|
|
*/
|
|
#[Route('/auth/verify', methods: ['GET'])]
|
|
public function verify(Request $request): JsonResponse
|
|
{
|
|
$appKey = (string) $request->query->get('app', '');
|
|
|
|
$session = $this->resolveAppSession($request, $appKey);
|
|
if ($session instanceof JsonResponse) {
|
|
return $session;
|
|
}
|
|
|
|
return new JsonResponse([
|
|
'valid' => true,
|
|
'app' => $appKey,
|
|
'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Returns the logged-in user's profile (email, name, etc) plus the
|
|
* permissions they hold for the calling app. Called by an app as a
|
|
* normal API query with `Authorization: Bearer <sid>` — same pattern as
|
|
* /api/*, but served directly by the BFF since this data comes from the
|
|
* tokens, not the backend.
|
|
*
|
|
* The `app` query parameter is required: one session is shared by every
|
|
* app the user opens, so the BFF can only scope permissions if the
|
|
* caller says which app is asking — and so it can check the caller may
|
|
* enter that app at all (same gate as /auth/verify).
|
|
*
|
|
* Permissions here are for the app's own UI gating. They are not an
|
|
* authorization boundary — anything that matters must be enforced by
|
|
* the backend off the access token the proxy injects.
|
|
*/
|
|
#[Route('/auth/me', methods: ['GET'])]
|
|
public function me(Request $request): JsonResponse
|
|
{
|
|
$appKey = (string) $request->query->get('app', '');
|
|
|
|
$session = $this->resolveAppSession($request, $appKey);
|
|
if ($session instanceof JsonResponse) {
|
|
return $session;
|
|
}
|
|
|
|
return new JsonResponse(($session['profile'] ?? []) + [
|
|
'app' => $appKey,
|
|
'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Global logout. Called by an app as a normal (server-side-executed)
|
|
* API query with `Authorization: Bearer <sid>`. Returns a URL rather
|
|
* than redirecting itself, since the app must perform the actual
|
|
* browser navigation to kill Keycloak's own SSO cookie.
|
|
*/
|
|
#[Route('/auth/logout', methods: ['POST'])]
|
|
public function logout(Request $request): JsonResponse
|
|
{
|
|
$kcSid = $this->extractBearer($request);
|
|
if (!$kcSid) {
|
|
$this->logger->info('auth.logout.no_session');
|
|
|
|
return new JsonResponse(['error' => 'missing session'], 401);
|
|
}
|
|
|
|
$data = $this->store->get($kcSid);
|
|
$idToken = $data['id_token'] ?? null;
|
|
|
|
// 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,
|
|
]));
|
|
|
|
return new JsonResponse([
|
|
'logout_url' => "{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/logout?{$params}",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Bearer sid -> a session that is live *and* authorized for $appKey.
|
|
*
|
|
* Returns the session array, or the JsonResponse the caller should
|
|
* return instead of a payload. Liveness goes through TokenRefresher
|
|
* rather than a bare store lookup, so a session killed on the Keycloak
|
|
* side (admin logout, revoked refresh token) fails here immediately
|
|
* instead of lingering until the cache TTL — and so the roles checked
|
|
* below are the ones the refresh just re-read.
|
|
*
|
|
* @return array<string,mixed>|JsonResponse
|
|
*/
|
|
private function resolveAppSession(Request $request, string $appKey): array|JsonResponse
|
|
{
|
|
$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);
|
|
}
|
|
|
|
// Same gate as callback(), re-run per call: one session is shared by
|
|
// 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);
|
|
}
|
|
|
|
return $session;
|
|
}
|
|
|
|
private function extractBearer(Request $request): ?string
|
|
{
|
|
$header = $request->headers->get('Authorization', '');
|
|
|
|
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), '+/', '-_'), '=');
|
|
}
|
|
}
|