diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 4de9e583dca5676f5bda4cf2b7af9fe24ea41abb..b9779195031342161f155990574b61e2c69dd731 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -18,6 +18,7 @@ $config->setRules([ 'strict_param' => true, 'declare_strict_types' => true, 'method_argument_space' => ['on_multiline' => 'ignore'], + 'phpdoc_to_comment' => false, ]) ->setRiskyAllowed(true) ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f61eee93514452f8f6c7f26032d96b572636f8e..95de8a851e2dad146284f4e702cc93e3a790577a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v0.1.52 + +* new Locale service for setting a locale from a requests and forwarding + to other services + # v0.1.45 * dbp:relay:core:migrate: Work around issues in DoctrineMigrationsBundle which diff --git a/composer.json b/composer.json index 42a924cb6a3061d8f5c3643211eb07bacf3a8395..f54c7e207eb445ac4c688c257c5e767958605d9c 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "symfony/twig-bundle": "^5.4", "symfony/uid": "^5.4", "symfony/validator": "^5.4", - "symfony/yaml": "^5.4" + "symfony/yaml": "^5.4", + "ext-intl": "*" }, "require-dev": { "brainmaestro/composer-git-hooks": "^2.8.5", diff --git a/composer.lock b/composer.lock index fd00a981838c0f5ed372e482ddb1aaa383e9130c..b63c9186ae289b79e8c8351bf69061f418d2f14f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9c42ac8c7816cdf96cd4c2f000cabaf3", + "content-hash": "6ddd4889d724c8beabfe6b3b58bf35d7", "packages": [ { "name": "api-platform/core", @@ -9843,7 +9843,8 @@ "platform": { "php": ">=7.3", "ext-fileinfo": "*", - "ext-json": "*" + "ext-json": "*", + "ext-intl": "*" }, "platform-dev": [], "platform-overrides": { diff --git a/src/API/UserSessionInterface.php b/src/API/UserSessionInterface.php index ce3e3df3264c2d58765dd696d0b3fb89c234974f..8bc5b1b62dbe32cf32a0fa18779a010b7434a5d5 100644 --- a/src/API/UserSessionInterface.php +++ b/src/API/UserSessionInterface.php @@ -6,58 +6,37 @@ namespace Dbp\Relay\CoreBundle\API; interface UserSessionInterface { - /** - * This gets called with the active JWT before any of the other methods are called. - */ - public function setSessionToken(?array $jwt): void; - /** * The unique identifier of the authenticated user. Or null in case it is called * before the user is known or if the user is a system. - * - * Can be derived from the session token for example. */ public function getUserIdentifier(): ?string; - /** - * Returns a list of Symfony user roles, like ['ROLE_FOOBAR']. - * - * Can be derived from the session token for example. - */ - public function getUserRoles(): array; - /** * Returns an ID represents a "session" of a user which can be used for logging. It should not be possible to * figure out which user is behind the ID based on the ID itself and the ID should change regularly. * This is useful for connecting various requests together for logging while not exposing details about the user. - * - * Can be derived from long running session IDs embedded in the token for example. - * - * Return null in case no logging ID exists */ - public function getSessionLoggingId(): ?string; + public function getSessionLoggingId(): string; + + /** + * @deprecated + */ + public function getUserRoles(): array; /** * Returns a unique caching key that can be used to cache metadata related to the current user session like * any user metadata, authorization related information etc. * It should not be possible to figure out which user is behind the ID based on the ID itself and the ID should * change regularly (after a logout/login or a key refresh for example). - * - * For example a hashed version of the token. - * - * Return null in case no appropriate cache key exists to disable any caching. */ - public function getSessionCacheKey(): ?string; + public function getSessionCacheKey(): string; /** - * Should return the duration the session is valid (as a whole, not from now) in seconds. + * Returns the duration the session is valid (as a whole, not from now) in seconds. * After the specified amount of time has passed the logging ID and the caching key should have changed. * * This is mostly useful for limiting the cache. - * - * For example the lifespan of the token. - * - * Return <0 in case that information isn't available. */ public function getSessionTTL(): int; } diff --git a/src/Auth/ProxyAuthenticator.php b/src/Auth/ProxyAuthenticator.php index 7fdafe8433cc8c410a2c0766ebd1e20b0a253d21..eb80e473f49e0d4a3bdc1d040e451fa2a7d61b3d 100644 --- a/src/Auth/ProxyAuthenticator.php +++ b/src/Auth/ProxyAuthenticator.php @@ -19,9 +19,15 @@ class ProxyAuthenticator extends AbstractAuthenticator */ private $authenticators; - public function __construct() + /** + * @var UserSession + */ + private $userSession; + + public function __construct(UserSession $userSession) { $this->authenticators = []; + $this->userSession = $userSession; } public function addAuthenticator(AuthenticatorInterface $sub) @@ -54,7 +60,11 @@ class ProxyAuthenticator extends AbstractAuthenticator $auth = $this->getAuthenticator($request); assert($auth !== null); - return $auth->authenticate($request); + $passport = $auth->authenticate($request); + $provider = $passport->getAttribute('relay_user_session_provider'); + $this->userSession->setProvider($provider); + + return $passport; } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response @@ -67,6 +77,8 @@ class ProxyAuthenticator extends AbstractAuthenticator public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { + $this->userSession->setProvider(null); + $auth = $this->getAuthenticator($request); assert($auth !== null); diff --git a/src/Auth/UserSession.php b/src/Auth/UserSession.php new file mode 100644 index 0000000000000000000000000000000000000000..461d9f3630c136b2b1ce8acb6e5b1d861a19a38d --- /dev/null +++ b/src/Auth/UserSession.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\CoreBundle\Auth; + +use Dbp\Relay\CoreBundle\API\UserSessionInterface; +use Dbp\Relay\CoreBundle\API\UserSessionProviderInterface; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Uid\Uuid; + +/** + * This service provides user session information, either sourcing information from the active auth provider + * or in case it is used from the CLI or unauthenticated then it returns some reasonable defaults. + */ +class UserSession implements UserSessionInterface +{ + /** + * @var ?UserSessionProviderInterface + */ + private $provider; + + /** + * @var Security + */ + private $security; + + public function __construct(?Security $security = null) + { + $this->security = $security; + } + + public function setProvider(?UserSessionProviderInterface $provider) + { + $this->provider = $provider; + } + + public function getUserIdentifier(): ?string + { + if ($this->provider === null) { + return null; + } + + return $this->provider->getUserIdentifier(); + } + + public function getSessionLoggingId(): string + { + $id = null; + if ($this->provider !== null) { + $id = $this->provider->getSessionLoggingId(); + } + if ($id === null) { + $id = 'unknown'; + } + + return $id; + } + + public function getSessionCacheKey(): string + { + $key = null; + if ($this->provider !== null) { + $key = $this->provider->getSessionCacheKey(); + } + if ($key === null) { + $key = (Uuid::v4())->toRfc4122(); + } + + return $key; + } + + public function getSessionTTL(): int + { + $ttl = -1; + if ($this->provider !== null) { + $ttl = $this->provider->getSessionTTL(); + } + if ($ttl === -1) { + $ttl = 60; + } + + return $ttl; + } + + public function getUserRoles(): array + { + if ($this->provider === null) { + return []; + } + $user = $this->security->getUser(); + if ($user === null) { + return []; + } + + return $user->getRoles(); + } +} diff --git a/src/Helpers/Locale.php b/src/Helpers/Locale.php index 9b9f330232d05e13bf076492453aaa9d64334e1c..8747659aa36cf7f9eb7aa3c7554dcba1c6472936 100644 --- a/src/Helpers/Locale.php +++ b/src/Helpers/Locale.php @@ -6,6 +6,9 @@ namespace Dbp\Relay\CoreBundle\Helpers; use Symfony\Component\HttpFoundation\RequestStack; +/** + * @deprecated Use Locale/Locale instead + */ class Locale { public const LANGUAGE_OPTION = 'lang'; diff --git a/src/Locale/Locale.php b/src/Locale/Locale.php new file mode 100644 index 0000000000000000000000000000000000000000..b7d9a6ddb468c07904afdbf98a261664d58de8b1 --- /dev/null +++ b/src/Locale/Locale.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\CoreBundle\Locale; + +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * A service which can be injected, which provides the current active language and allows setting the active + * language based on a query parameters. + * + * This assumes that Symfony is configured to apply the 'Accept-Language' header by default to all requests. + */ +class Locale +{ + /** @var RequestStack */ + private $requestStack; + + /** + * @var ParameterBagInterface + */ + private $parameters; + + public function __construct(RequestStack $requestStack, ParameterBagInterface $parameters) + { + $this->requestStack = $requestStack; + $this->parameters = $parameters; + } + + /** + * Returns the primary language (in ISO 639‑1 format) for the current context. + * In case there is a request then the request language, otherwise the default language. + */ + public function getCurrentPrimaryLanguage(): string + { + $locale = $this->getCurrentLocale(); + $lang = \Locale::getPrimaryLanguage($locale); + /** @psalm-suppress RedundantCondition */ + assert($lang !== null); + + return $lang; + } + + /** + * Sets the locale for the active request via a query parameter. + * The query parameter format is the same as the 'Accept-Language' HTTP header format. + * In case the query parameter isn't part of the request then nothing changes. + */ + public function setCurrentRequestLocaleFromQuery(string $queryParam = 'lang'): void + { + $request = $this->requestStack->getCurrentRequest(); + if ($request === null) { + throw new \RuntimeException('No active request'); + } + self::setRequestLocaleFromQuery($request, $queryParam); + } + + /** + * Returns the current locale, either from the active request, or the default one. + */ + private function getCurrentLocale(): string + { + $request = $this->requestStack->getCurrentRequest(); + if ($request !== null) { + $locale = $request->getLocale(); + } else { + $locale = $this->parameters->get('kernel.default_locale'); + assert(is_string($locale)); + } + + return $locale; + } + + /** + * Same as setCurrentRequestLocaleFromQuery(), but takes a request object. + */ + public static function setRequestLocaleFromQuery(Request $request, string $queryParam): void + { + if ($request->query->has($queryParam)) { + $lang = $request->query->get($queryParam); + assert(is_string($lang)); + $locale = \Locale::acceptFromHttp($lang); + if ($locale === false) { + throw new \RuntimeException('Failed to parse Accept-Language'); + } + $request->setLocale($locale); + } + } +} diff --git a/src/Logging/LoggingProcessor.php b/src/Logging/LoggingProcessor.php index e014fabb08c0eac79ce40d9505dc1c347dde4233..0b6c309f90a30e5a71d495669a4278fd14943d29 100644 --- a/src/Logging/LoggingProcessor.php +++ b/src/Logging/LoggingProcessor.php @@ -43,10 +43,7 @@ final class LoggingProcessor $this->maskUserId($record); // Add a session ID (the same during multiple requests for the same user session) - $loggingId = $this->userDataProvider->getSessionLoggingId(); - if ($loggingId !== null) { - $record['context']['relay-session-id'] = $loggingId; - } + $record['context']['relay-session-id'] = $this->userDataProvider->getSessionLoggingId(); // Add a request ID (the same during the same client request) $request = $this->requestStack->getMainRequest(); diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 714322cb7f9d3ee7da5fb6161dfa21ef7a535c56..7cb009e686bece3f65e7da38a3210a6ceee71f86 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -58,6 +58,13 @@ services: autowire: true autoconfigure: true + Dbp\Relay\CoreBundle\Auth\UserSession: + autowire: true + autoconfigure: true + + Dbp\Relay\CoreBundle\API\UserSessionInterface: + '@Dbp\Relay\CoreBundle\Auth\UserSession' + Dbp\Relay\CoreBundle\LocalData\LocalDataAwareEventDispatcher: autowire: true autoconfigure: true @@ -97,3 +104,7 @@ services: Dbp\Relay\CoreBundle\ProxyApi\ProxyDataEventSubscriber: autowire: true autoconfigure: true + + Dbp\Relay\CoreBundle\Locale\Locale: + autowire: true + autoconfigure: true diff --git a/src/TestUtils/TestUserSession.php b/src/TestUtils/TestUserSession.php index 1abe5873c26ab63012b5b49ec61c9a71f1e49305..672648370e8d50131bf7665b9dfd1beadbcb199b 100644 --- a/src/TestUtils/TestUserSession.php +++ b/src/TestUtils/TestUserSession.php @@ -41,12 +41,12 @@ class TestUserSession implements UserSessionInterface return $this->roles; } - public function getSessionLoggingId(): ?string + public function getSessionLoggingId(): string { return 'logging-id'; } - public function getSessionCacheKey(): ?string + public function getSessionCacheKey(): string { return 'cache'; } diff --git a/tests/Auth/AuthenticatorTest.php b/tests/Auth/AuthenticatorTest.php index 26d9879b226455b678f50a1476312c9711976705..99dc9f9f491c58ed7953da392b08a20ac0d63992 100644 --- a/tests/Auth/AuthenticatorTest.php +++ b/tests/Auth/AuthenticatorTest.php @@ -6,6 +6,7 @@ namespace Dbp\Relay\CoreBundle\Tests\Auth; use Dbp\Relay\CoreBundle\Auth\AuthenticatorCompilerPass; use Dbp\Relay\CoreBundle\Auth\ProxyAuthenticator; +use Dbp\Relay\CoreBundle\Auth\UserSession; use Dbp\Relay\CoreBundle\TestUtils\TestAuthenticator; use Dbp\Relay\CoreBundle\TestUtils\TestUser; use PHPUnit\Framework\TestCase; @@ -17,13 +18,13 @@ class AuthenticatorTest extends TestCase { public function testSupports() { - $auth = new ProxyAuthenticator(); + $auth = new ProxyAuthenticator(new UserSession()); $this->assertFalse($auth->supports(new Request())); } public function testSingle() { - $auth = new ProxyAuthenticator(); + $auth = new ProxyAuthenticator(new UserSession()); $user = new TestUser(); $sub = new TestAuthenticator($user, 'bla'); $auth->addAuthenticator($sub); diff --git a/tests/LocaleTest.php b/tests/LocaleTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f5c08826d403c4cc6f7cec48461ba7bc4a795e56 --- /dev/null +++ b/tests/LocaleTest.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Dbp\Relay\CoreBundle\Tests; + +use Dbp\Relay\CoreBundle\Locale\Locale; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; + +class LocaleTest extends TestCase +{ + public function testWithRequest() + { + $stack = new RequestStack(); + $request = new Request(['lang' => 'de']); + + $request->setLocale(\Locale::acceptFromHttp('en')); + $stack->push($request); + $params = new ParameterBag([]); + $service = new Locale($stack, $params); + + $lang = $service->getCurrentPrimaryLanguage(); + $this->assertSame('en', $lang); + + $service->setCurrentRequestLocaleFromQuery('lang'); + $lang = $service->getCurrentPrimaryLanguage(); + $this->assertSame('de', $lang); + } + + public function testWithoutRequest() + { + $stack = new RequestStack(); + $params = new ParameterBag(['kernel.default_locale' => \Locale::acceptFromHttp('de')]); + $service = new Locale($stack, $params); + + $lang = $service->getCurrentPrimaryLanguage(); + $this->assertSame('de', $lang); + } + + public function testSetExplicit() + { + $stack = new RequestStack(); + $params = new ParameterBag(['kernel.default_locale' => \Locale::acceptFromHttp('en')]); + $service = new Locale($stack, $params); + $request = new Request(['foo' => 'fr']); + $service->setRequestLocaleFromQuery($request, 'foo'); + + $stack->push($request); + $lang = $service->getCurrentPrimaryLanguage(); + $this->assertSame('fr', $lang); + $stack->pop(); + $lang = $service->getCurrentPrimaryLanguage(); + $this->assertSame('en', $lang); + } +}