auth subdomain tentatively complete.
All domain logic moved into service.
This commit is contained in:
+10
-31
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()}");
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user