auth subdomain tentatively complete.

All domain logic moved into service.
This commit is contained in:
2026-05-22 12:43:02 -04:00
parent 38124ef66c
commit 43e9b7136e
6 changed files with 182 additions and 55 deletions
+10 -31
View File
@@ -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;
}
}
+9 -3
View File
@@ -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 authsubdomain 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");
+6 -5
View File
@@ -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()}");
+31 -16
View File
@@ -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 */
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class DomainManager {
/* top-level-domains which are known to have multiple parts */
private const TLD = [
'ai' => ['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;
}
}
+5
View File
@@ -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;
}