diff --git a/src/ConfigBag.php b/src/ConfigBag.php index cbfd8b1..969848b 100644 --- a/src/ConfigBag.php +++ b/src/ConfigBag.php @@ -17,24 +17,19 @@ final readonly class ConfigBag { private string $errorMessage; private string $teapotTitle; private string $tooManyTitle; - // New configuration for optional authentication subdomain redirection - private bool $subdomainRedirect; - private string $authSubdomain; /** @throws InvalidArgumentException */ public function __construct( - Utilities $utilities, - ClockInterface $clock, - #[Autowire('%app.cookie_ttl%')] int $cookieTtl, - #[Autowire('%app.query_prefix%')] string $queryPrefix, - #[Autowire('%app.totp_uri%')] string $totpUri, - #[Autowire('%app.ip_ttl%')] ?int $ipTtl, - #[Autowire('%app.teapot%')] bool $teapot, - #[Autowire('%app.error_message%')] string $errorMessage, - #[Autowire('%app.teapot_title%')] string $teapotTitle, - #[Autowire('%app.too_many_title%')] string $tooManyTitle, - #[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect, - #[Autowire('%app.auth_subdomain%')] string $authSubdomain, + Utilities $utilities, + ClockInterface $clock, + #[Autowire('%app.cookie_ttl%')] int $cookieTtl, + #[Autowire('%app.query_prefix%')] string $queryPrefix, + #[Autowire('%app.totp_uri%')] string $totpUri, + #[Autowire('%app.ip_ttl%')] ?int $ipTtl, + #[Autowire('%app.teapot%')] bool $teapot, + #[Autowire('%app.error_message%')] string $errorMessage, + #[Autowire('%app.teapot_title%')] string $teapotTitle, + #[Autowire('%app.too_many_title%')] string $tooManyTitle, ) { $this->clock = $clock; $this->cookieTtl = $cookieTtl; @@ -45,8 +40,6 @@ final readonly class ConfigBag { $this->errorMessage = $errorMessage; $this->teapotTitle = $teapotTitle; $this->tooManyTitle = $tooManyTitle; - $this->subdomainRedirect = $subdomainRedirect; - $this->authSubdomain = $authSubdomain; } public function clock(): ClockInterface { @@ -84,18 +77,4 @@ final readonly class ConfigBag { public function tooManyTitle(): string { return $this->tooManyTitle; } - - /** - * Whether the application should redirect unauthenticated requests to a dedicated authentication subdomain. - */ - public function subdomainRedirect(): bool { - return $this->subdomainRedirect; - } - - /** - * The subdomain (e.g., "auth.example.com") to which unauthenticated requests should be redirected. - */ - public function authSubdomain(): string { - return $this->authSubdomain; - } } diff --git a/src/Listener/AcceptListener.php b/src/Listener/AcceptListener.php index c9f3c2f..28ee0b5 100644 --- a/src/Listener/AcceptListener.php +++ b/src/Listener/AcceptListener.php @@ -24,11 +24,17 @@ final readonly class AcceptListener { /** @throws InvalidArgumentException */ #[AsEventListener(priority: 99)] public function onKernelRequest(RequestEvent $event): void { - /* check if they sent the preauth cookie */ - if ($event->getRequest()->cookies->has($this->cookieName())) { + /* check if they sent either preauth cookie */ + if ($event->getRequest()->cookies->has($this->cookieName()) || + $event->getRequest()->cookies->has($this->authCookieName()) + ) { $cookie = $event->getRequest()->cookies->get($this->cookieName()); + if ( ! $cookie) { + /* fallback to the auth‑subdomain cookie if the host cookie is not present */ + $cookie = $event->getRequest()->cookies->get($this->authCookieName()); + } $cookieKey = $this->makeCacheKey("cookie_$cookie"); - if ($this->sessionCache->hasItem($cookieKey)) { + if ($cookie && $this->sessionCache->hasItem($cookieKey)) { /* cookie sent corresponds to valid existing session */ $id = $this->sessionCache->getItem($cookieKey)->get(); $this->logger->debug("has valid cookie-session: $id"); diff --git a/src/Listener/InterceptListener.php b/src/Listener/InterceptListener.php index a3b00ad..87dfc20 100644 --- a/src/Listener/InterceptListener.php +++ b/src/Listener/InterceptListener.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Listener; use App\ConfigBag; +use App\Service\DomainManager; use App\Trait\HasLoggerTrait; use App\Trait\MakeNonceTrait; use Psr\Cache\InvalidArgumentException; @@ -21,6 +22,7 @@ final readonly class InterceptListener { public function __construct( private ConfigBag $config, + private DomainManager $domainManager, private Environment $twig, ) {} @@ -31,16 +33,15 @@ final readonly class InterceptListener { /* by this point, we know that the request we have is: * not already authorized, nor already rate-limited, * nor submitting login credentials; so redirect or present the login page now */ - if ($this->config->subdomainRedirect() && $this->config->authSubdomain() && - $this->config->authSubdomain() !== $event->getRequest()->getHost() + if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost() && + $this->domainManager->matchesAuth($event->getRequest()->getHost()) ) { - // TODO verify host has the same base of the authSubdomain - /* redirect to auth */ + /* host matches base-domain of auth, but not on auth subdomain, redirect */ $query = http_build_query([ $this->config->query('return') => $event->getRequest()->getUri(), ]); $event->setResponse(new Response('', Response::HTTP_TEMPORARY_REDIRECT, - ['Location' => "https://{$this->config->authSubdomain()}/?$query"] + ['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"] )); } else { $this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}"); diff --git a/src/Listener/LoginListener.php b/src/Listener/LoginListener.php index 066b569..7e46f90 100644 --- a/src/Listener/LoginListener.php +++ b/src/Listener/LoginListener.php @@ -7,6 +7,7 @@ use App\Data\Payload; use App\Enum\Scope; use App\MonitorCacheKeys; use App\Service\BackupCodeManager; +use App\Service\DomainManager; use App\Trait\CookieNameTrait; use App\Trait\GetTotpTrait; use App\Trait\HasLoggerTrait; @@ -45,6 +46,7 @@ final readonly class LoginListener { CacheItemPoolInterface $sessionCache, #[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter, BackupCodeManager $backupCodeManager, + private DomainManager $domainManager, ) { $this->sessionCache = new MonitorCacheKeys($sessionCache); $this->rateLimiter = $rateLimiter; @@ -58,7 +60,7 @@ final readonly class LoginListener { $data = $event->getRequest()->headers->get($this->headerName()); $payload = Payload::decode($data); $response = null; - if ($payload && $payload->token) { + if ($payload) { $response = $this->checkToken($payload, $event->getRequest()); } @@ -123,8 +125,10 @@ final readonly class LoginListener { $content = "hi $cleanId, please reload"; } - $location = $request->query->has('return') && $this->validateUrl($request->query->get('return')) ? - "{$request->query->get('return')}" : + $returnKey = $this->config->query('return'); + $location = $request->query->has($returnKey) && + $this->domainManager->validReturn($request->query->get($returnKey)) ? + "{$request->query->get($returnKey)}" : "{$request->getPathInfo()}{$request->getQueryString()}"; $response->setContent($content) @@ -140,11 +144,6 @@ final readonly class LoginListener { return null; } - private function validateUrl(string $url): bool { - // TODO verify host has the same base of the authSubdomain - return filter_var($url, FILTER_VALIDATE_URL); - } - /** @throws InvalidArgumentException */ private function setCookie(string $id): Cookie { /* successful auth with token, store session and set the cookie */ @@ -161,14 +160,30 @@ final readonly class LoginListener { $sessionCookie->expiresAfter($this->config->cookieTtl()); $this->sessionCache->save($sessionCookie); - // TODO if using authSubdomain, the cookie we issue will need to be different.. different prefix, domain being specified, etc. - return Cookie::create( - name: $this->cookieName(), - value: $ulid->toString(), - expire: time() + $this->config->cookieTtl(), - secure: true, - sameSite: Cookie::SAMESITE_STRICT - ); + if ($this->domainManager->authBase()) { + /* when using subdomain-auth we have to use a different cookie name, as the + * "__Host-Http-" prefix we normally use does not allow domain to be set */ + return Cookie::create( + name: $this->authCookieName(), + value: $ulid->toString(), + expire: time() + $this->config->cookieTtl(), + path: '/', + domain: $this->domainManager->authBase(), + secure: true, + httpOnly: true, + sameSite: Cookie::SAMESITE_STRICT, + ); + } else { + return Cookie::create( + name: $this->cookieName(), + value: $ulid->toString(), + expire: time() + $this->config->cookieTtl(), + path: '/', + secure: true, + httpOnly: true, + sameSite: Cookie::SAMESITE_STRICT, + ); + } } /** @throws InvalidArgumentException */ diff --git a/src/Service/DomainManager.php b/src/Service/DomainManager.php new file mode 100644 index 0000000..8130665 --- /dev/null +++ b/src/Service/DomainManager.php @@ -0,0 +1,121 @@ + ['com','net','off','org'], + 'am' => ['radio'], + 'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za'], + 'de' => ['com'], + 'fm' => ['radio'], + 'gg' => ['co','net','org'], + 'in' => ['co','firm','gen','ind','net','org'], + 'je' => ['co','net','org'], + 'mx' => ['com','net','org'], + 'net' => ['gb','hu','in','jp','se','uk'], + 'nz' => ['co','net','org'], + 'org' => ['ae','us'], + 'ph' => ['com','net','org'], + 'se' => ['com'], + 'uk' => ['co','me','org'], + ]; + + private bool $subdomainRedirect; + private string $authSubdomain; + + public function __construct( + #[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect, + #[Autowire('%app.auth_subdomain%')] string $authSubdomain, + ) { + $this->subdomainRedirect = $subdomainRedirect; + $this->authSubdomain = $authSubdomain; + } + + /** + * IE: "auth.example.com" or null if not using a separate subdomain + * @return ?string Returns auth subdomain if configured, otherwise null + */ + public function getAuthSubdomain(): ?string { + if ($this->authBase()) { + return $this->authSubdomain; + } + return null; + } + + /** + * Check if given url is an acceptable url for redirection + * @param string $url Where we are thinking of sending the user + * @return bool Returns true if it is acceptable to send the user there, false otherwise + */ + public function validReturn(string $url): bool { + /* ensure url is valid and, when using an auth subdomain, + * that the url host matches the base domain */ + if (!filter_var($url, FILTER_VALIDATE_URL)) { + return false; + } + + if ($this->authBase()) { + $host = parse_url($url, PHP_URL_HOST); + if ($host === null) { + return false; + } + return $this->matchesAuth($host); + } + + return true; + } + + /** + * @param string $host + * @return bool returns true if and only if host matches base domain of auth + */ + public function matchesAuth(string $host): bool { + $hostBase = $this->baseDomain($host); + $authBase = $this->baseDomain($this->authSubdomain); + return $this->subdomainRedirect && $this->authSubdomain && + $authBase && $authBase === $hostBase; + } + + public function authBase(): ?string { + if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) { + return $this->baseDomain($this->authSubdomain); + } + return null; + } + + /** + * This lets us determine the base domain of the given ip, localhost, or domain + * "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com" + * things like "localhost" and "8.8.8.8" will return null + * @param string $host ip, localhost, or domain with zero or more subdomains + * @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed + */ + private function baseDomain(string $host): ?string { + /* if host is an ip address (or localhost), leave it as is */ + if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') { + return null; + } + + $parts = explode('.', $host); + $keep = $this->baseLength($parts); + $parts = array_slice($parts, -$keep); + return implode('.', $parts); + } + + private function baseLength(array $parts): int { + $length = count($parts); + $baseLength = min(2, $length); + /* check if host should retain 3 parts, due to TLD */ + if (count($parts) > 2 && isset(self::TLD[$parts[$length-1]]) && + in_array($parts[$length-2], self::TLD[$parts[$length-1]], true) + ) { + $baseLength = min(3, $length); + } + return $baseLength; + } +} diff --git a/src/Trait/CookieNameTrait.php b/src/Trait/CookieNameTrait.php index 73d4aad..d565453 100644 --- a/src/Trait/CookieNameTrait.php +++ b/src/Trait/CookieNameTrait.php @@ -5,12 +5,17 @@ namespace App\Trait; trait CookieNameTrait { private const COOKIE_NAME = '__Host-Http-Preauth'; + private const AUTH_COOKIE_NAME = '__Http-Domain-Preauth'; private const HEADER_NAME = 'X-Preauth'; final protected function cookieName(): string { return static::COOKIE_NAME; } + final protected function authCookieName(): string { + return static::AUTH_COOKIE_NAME; + } + final protected function headerName(): string { return static::HEADER_NAME; }