feat: configurable Remote-User header (design consideration 1.2)
Add REMOTE_USER env var with four modes: - session (default): sends session id, backward-compatible - static: sends a fixed string (REMOTE_USER_STATIC) - mapped: looks up session id in REMOTE_USER_MAP - none: omits the header entirely New RemoteUserMode enum, ConfigBag parsing/validation, and StringTrait::authSuccessResponse resolves the header value based on the configured mode. AcceptListener now receives ConfigBag as a constructor dependency. Addresses design consideration 1.2 (Remote-User header value is user-controlled) from DESIGN_CONSIDERATIONS.md. 241 tests pass, 0 cs-fixer violations.
This commit is contained in:
1 parent
2258839bd6
commit
9ad54f8e2a
17 files changed
+460
-23
No files matched your search
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
- Made `Remote-User` header value configurable via `REMOTE_USER` environment
|
||||
variable with four modes: `session` (default), `static`, `mapped`, and `none`.
|
||||
This allows deployments to prevent user-controlled header values from reaching
|
||||
backend services.
|
||||
- Added `SecurityHeadersListener` to set `X-Content-Type-Options`, `X-Frame-Options`,
|
||||
`Content-Security-Policy`, `Referrer-Policy`, and `Strict-Transport-Security`
|
||||
headers on all responses.
|
||||
|
||||
@@ -24,13 +24,16 @@ Strict-Transport-Security: max-age=31536000
|
||||
|
||||
The inline `<script>` and `<style>` in the templates mean a CSP with `'unsafe-inline'` for `script-src` and `style-src` is the strictest practical policy today. Moving scripts/styles to external files would allow a stricter CSP in the future.
|
||||
|
||||
### 1.2 Remote-User Header Value is User-Controlled [HIGH PRIORITY] ⬜ Open
|
||||
### 1.2 Remote-User Header Value is User-Controlled [HIGH PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** The `Remote-User` header sent back to Caddy (and forwarded to the protected backend) is set to the `id` field from the user's login payload. This value is sanitized via `makeCacheKey()` (which restricts to `[A-Za-z0-9_.]` and truncates to 128 chars), but it is otherwise arbitrary — a user who passes TOTP authentication can set their `Remote-User` to `admin`, `root`, or any other value.
|
||||
**Current state:** Fixed. The `Remote-User` header value is now configurable via the `REMOTE_USER` environment variable, which supports four modes:
|
||||
|
||||
**Recommendation:** If this is a single-user gate (which it currently is), document this explicitly: "The Remote-User header identifies the session, not a system user. Backend services must not use it for authorization decisions." If multi-user support is added (as planned in the ROADMAP), the `id` field should be validated against a registered user list before being echoed as `Remote-User`.
|
||||
- **`session`** (default, backward-compatible): Sends the session id, as before. The value is still sanitized via `makeCacheKey()`.
|
||||
- **`static`**: Sends a fixed string (configurable via `REMOTE_USER_STATIC`, default `authenticated`) for all authenticated requests. This eliminates the user-controlled header issue entirely.
|
||||
- **`mapped`**: Looks up the session id in a configured map (`REMOTE_USER_MAP`, format: `id1:user1,id2:user2`) and sends the mapped value. Falls back to the session id if not found in the map. This is the path to multi-user support.
|
||||
- **`none`**: Omits the `Remote-User` header entirely. Caddy's `forward_auth` still accepts the request based on the 200 status code.
|
||||
|
||||
**Why:** A backend service that trusts `Remote-User` for access control (e.g., granting admin privileges to `Remote-User: admin`) would be trivially exploitable by any authenticated preauth user. This is the most important architectural caveat to document, even if it's intentional for the current single-user model. The README's Security Model section now mentions that `Remote-User` identifies the session, but a dedicated `docs/SECURITY.md` would be valuable.
|
||||
The `RemoteUserMode` enum (`src/Enum/RemoteUserMode.php`) encapsulates the modes. `StringTrait::authSuccessResponse()` resolves the header value based on the configured mode, and `ConfigBag` handles parsing the map string and validating the mode (invalid values fall back to `session`). `AcceptListener` now receives `ConfigBag` as a constructor dependency to support this.
|
||||
|
||||
### 1.3 No CSRF Protection on POST Form Login [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
|
||||
@@ -27,6 +27,16 @@ parameters:
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
env(TEAPOT): '1' # boolean
|
||||
|
||||
# --- remote-user header ---
|
||||
# Controls the value sent in the Remote-User header on successful auth.
|
||||
# session: the session id (default, backward-compatible)
|
||||
# static: a fixed string (set via REMOTE_USER_STATIC)
|
||||
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
|
||||
# none: do not send the Remote-User header at all
|
||||
env(REMOTE_USER): 'session'
|
||||
env(REMOTE_USER_STATIC): 'authenticated'
|
||||
env(REMOTE_USER_MAP): ''
|
||||
|
||||
# --- rate limiting ---
|
||||
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
|
||||
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
|
||||
@@ -63,6 +73,10 @@ parameters:
|
||||
app.ip_ttl: '%env(int:IP_TTL)%'
|
||||
app.teapot: '%env(bool:TEAPOT)%'
|
||||
|
||||
app.remote_user: '%env(REMOTE_USER)%'
|
||||
app.remote_user_static: '%env(REMOTE_USER_STATIC)%'
|
||||
app.remote_user_map: '%env(REMOTE_USER_MAP)%'
|
||||
|
||||
app.error_message: '%env(ERROR_MESSAGE)%'
|
||||
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
app.too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
#TEAPOT=true # default enabled, boolean
|
||||
|
||||
# --- remote-user header ---
|
||||
# Controls the value sent in the Remote-User header on successful auth.
|
||||
# session: the session id (default, backward-compatible)
|
||||
# static: a fixed string (set via REMOTE_USER_STATIC)
|
||||
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
|
||||
# none: do not send the Remote-User header at all
|
||||
#REMOTE_USER=session
|
||||
#REMOTE_USER_STATIC=authenticated
|
||||
#REMOTE_USER_MAP=''
|
||||
|
||||
# --- rate limiting ---
|
||||
|
||||
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
|
||||
|
||||
@@ -112,6 +112,21 @@ for the complete reference.
|
||||
| `IP_TTL` | `0` | Seconds to allow all traffic from an IP after login (0 = disabled). |
|
||||
| `TEAPOT` | `1` | Respond with 418 instead of 429 when rate-limited (boolean). |
|
||||
|
||||
### Remote-User Header
|
||||
|
||||
The `Remote-User` header sent to backends on successful auth is configurable:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `REMOTE_USER` | `session` | Mode: `session`, `static`, `mapped`, or `none`. |
|
||||
| `REMOTE_USER_STATIC` | `authenticated` | Value sent when mode is `static`. |
|
||||
| `REMOTE_USER_MAP` | _(empty)_ | Comma-separated map for `mapped` mode (e.g. `alice:admin,bob:user`). |
|
||||
|
||||
- **`session`** (default): Sends the session id. Backward-compatible.
|
||||
- **`static`**: Sends a fixed string for all authenticated requests.
|
||||
- **`mapped`**: Looks up the session id in the map; falls back to session id if not found.
|
||||
- **`none`**: Omits the header entirely (Caddy still accepts based on status code).
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Rate limiting **cannot be disabled**. It uses a compound sliding window:
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Enum\RemoteUserMode;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -18,6 +19,10 @@ final readonly class ConfigBag
|
||||
private string $errorMessage;
|
||||
private string $teapotTitle;
|
||||
private string $tooManyTitle;
|
||||
private RemoteUserMode $remoteUserMode;
|
||||
private string $remoteUserStatic;
|
||||
/** @var array<string,string> */
|
||||
private array $remoteUserMap;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
@@ -30,6 +35,9 @@ final readonly class ConfigBag
|
||||
#[Autowire('%app.error_message%')] string $errorMessage,
|
||||
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
||||
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
|
||||
#[Autowire('%app.remote_user%')] string $remoteUserMode,
|
||||
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
|
||||
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
|
||||
) {
|
||||
$this->clock = $clock;
|
||||
$this->cookieTtl = $cookieTtl;
|
||||
@@ -39,6 +47,31 @@ final readonly class ConfigBag
|
||||
$this->errorMessage = $errorMessage;
|
||||
$this->teapotTitle = $teapotTitle;
|
||||
$this->tooManyTitle = $tooManyTitle;
|
||||
|
||||
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
|
||||
$this->remoteUserStatic = $remoteUserStatic;
|
||||
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comma-separated map string ("id1:user1,id2:user2") into an array.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function parseUserMap(string $map): array
|
||||
{
|
||||
if ($map === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (explode(',', $map) as $pair) {
|
||||
$parts = explode(':', trim($pair), 2);
|
||||
if (count($parts) === 2) {
|
||||
$result[trim($parts[0])] = trim($parts[1]);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function clock(): ClockInterface
|
||||
@@ -80,4 +113,22 @@ final readonly class ConfigBag
|
||||
{
|
||||
return $this->tooManyTitle;
|
||||
}
|
||||
|
||||
public function remoteUserMode(): RemoteUserMode
|
||||
{
|
||||
return $this->remoteUserMode;
|
||||
}
|
||||
|
||||
public function remoteUserStatic(): string
|
||||
{
|
||||
return $this->remoteUserStatic;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function remoteUserMap(): array
|
||||
{
|
||||
return $this->remoteUserMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Controls what value is sent in the Remote-User header on auth success.
|
||||
*/
|
||||
enum RemoteUserMode: string
|
||||
{
|
||||
/** Send the session id (current/default behaviour). */
|
||||
case Session = 'session';
|
||||
|
||||
/** Send a fixed static string for all authenticated requests. */
|
||||
case Static = 'static';
|
||||
|
||||
/** Look up the session id in a configured map and send the mapped value. */
|
||||
case Mapped = 'mapped';
|
||||
|
||||
/** Do not send the Remote-User header at all. */
|
||||
case None = 'none';
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
@@ -22,6 +23,7 @@ final readonly class AcceptListener
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionCache,
|
||||
private DomainInterface $domainManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -51,7 +53,7 @@ final readonly class AcceptListener
|
||||
|
||||
$id = $item->get();
|
||||
$this->logger->debug("has valid cookie-session: $id");
|
||||
$event->setResponse($this->authSuccessResponse($id));
|
||||
$event->setResponse($this->authSuccessResponse($id, $this->config));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
/* cache failure — fail closed (don't authenticate) */
|
||||
$this->logger->error("cache error in AcceptListener: {$e->getMessage()}");
|
||||
|
||||
@@ -46,7 +46,7 @@ final readonly class AllowListener
|
||||
|
||||
$id = $item->get();
|
||||
$this->logger->debug("has valid ip-session: $id");
|
||||
$event->setResponse($this->authSuccessResponse($id));
|
||||
$event->setResponse($this->authSuccessResponse($id, $this->config));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
/* cache failure — fail closed (don't authenticate) */
|
||||
$this->logger->error("cache error in AllowListener: {$e->getMessage()}");
|
||||
|
||||
@@ -63,7 +63,7 @@ final readonly class LoginManager implements LoginInterface
|
||||
$cleanId = $this->makeCacheKey($payload->id);
|
||||
|
||||
/* if they just want this one page, return ok, to grant them access */
|
||||
$response = $this->authSuccessResponse($cleanId);
|
||||
$response = $this->authSuccessResponse($cleanId, $this->config);
|
||||
|
||||
if ($payload->scope !== Scope::None) {
|
||||
/* grant access based on the requested scope */
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Trait;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\ConfigBag;
|
||||
use App\Enum\RemoteUserMode;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
trait StringTrait
|
||||
@@ -18,14 +20,34 @@ trait StringTrait
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the plain-text success response body and headers for an authenticated request.
|
||||
* The body is a simple greeting that includes the session id.
|
||||
* Build the plain-text success response body and headers for an
|
||||
* authenticated request. The body is a simple greeting that includes
|
||||
* the session id. The Remote-User header is set (or omitted) based on
|
||||
* the configured remote-user mode.
|
||||
*/
|
||||
public function authSuccessResponse(string $id): Response
|
||||
public function authSuccessResponse(string $id, ConfigBag $config): Response
|
||||
{
|
||||
return new Response("hi $id", headers: [
|
||||
'Content-Type' => 'text/plain',
|
||||
'Remote-User' => $id,
|
||||
]);
|
||||
$headers = ['Content-Type' => 'text/plain'];
|
||||
|
||||
$headerValue = $this->resolveRemoteUser($id, $config);
|
||||
if ($headerValue !== null) {
|
||||
$headers['Remote-User'] = $headerValue;
|
||||
}
|
||||
|
||||
return new Response("hi $id", headers: $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Remote-User header value based on the configured mode.
|
||||
* Returns null when the header should not be sent.
|
||||
*/
|
||||
private function resolveRemoteUser(string $id, ConfigBag $config): ?string
|
||||
{
|
||||
return match ($config->remoteUserMode()) {
|
||||
RemoteUserMode::Session => $id,
|
||||
RemoteUserMode::Static => $config->remoteUserStatic(),
|
||||
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
|
||||
RemoteUserMode::None => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Enum\RemoteUserMode;
|
||||
use App\Utilities;
|
||||
use DateTimeImmutable;
|
||||
use OTPHP\TOTP;
|
||||
@@ -72,6 +73,9 @@ trait TotpTestHelper
|
||||
string $errorMessage = 'Error',
|
||||
string $teapotTitle = 'Teapot',
|
||||
string $tooManyTitle = 'Too Many',
|
||||
string $remoteUserMode = 'session',
|
||||
string $remoteUserStatic = 'authenticated',
|
||||
string $remoteUserMap = '',
|
||||
): ConfigBag {
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
@@ -85,6 +89,9 @@ trait TotpTestHelper
|
||||
$errorMessage,
|
||||
$teapotTitle,
|
||||
$tooManyTitle,
|
||||
$remoteUserMode,
|
||||
$remoteUserStatic,
|
||||
$remoteUserMap,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Enum\RemoteUserMode;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ConfigBagRemoteUserTest extends TestCase
|
||||
{
|
||||
use TotpTestHelper;
|
||||
|
||||
public function testDefaultRemoteUserModeIsSession(): void
|
||||
{
|
||||
$config = $this->makeConfig();
|
||||
|
||||
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
||||
}
|
||||
|
||||
public function testStaticMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
|
||||
|
||||
self::assertSame(RemoteUserMode::Static, $config->remoteUserMode());
|
||||
self::assertSame('authenticated', $config->remoteUserStatic());
|
||||
}
|
||||
|
||||
public function testMappedMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user');
|
||||
|
||||
self::assertSame(RemoteUserMode::Mapped, $config->remoteUserMode());
|
||||
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||
}
|
||||
|
||||
public function testNoneMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'none');
|
||||
|
||||
self::assertSame(RemoteUserMode::None, $config->remoteUserMode());
|
||||
}
|
||||
|
||||
public function testInvalidModeFallsBackToSession(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'invalid-mode');
|
||||
|
||||
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
||||
}
|
||||
|
||||
public function testEmptyMapReturnsEmptyArray(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: '');
|
||||
|
||||
self::assertSame([], $config->remoteUserMap());
|
||||
}
|
||||
|
||||
public function testMapParsesWithWhitespace(): void
|
||||
{
|
||||
$config = $this->makeConfig(
|
||||
remoteUserMode: 'mapped',
|
||||
remoteUserMap: ' alice : admin , bob : user ',
|
||||
);
|
||||
|
||||
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||
}
|
||||
|
||||
public function testMapIgnoresInvalidEntries(): void
|
||||
{
|
||||
$config = $this->makeConfig(
|
||||
remoteUserMode: 'mapped',
|
||||
remoteUserMap: 'alice:admin,noColon,bob:user',
|
||||
);
|
||||
|
||||
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||
}
|
||||
|
||||
public function testMapPreservesColonsInValue(): void
|
||||
{
|
||||
$config = $this->makeConfig(
|
||||
remoteUserMode: 'mapped',
|
||||
remoteUserMap: 'alice:admin:extra',
|
||||
);
|
||||
|
||||
self::assertSame(['alice' => 'admin:extra'], $config->remoteUserMap());
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,10 @@ final class ConfigBagTest extends TestCase
|
||||
true,
|
||||
'Error!',
|
||||
'Teapot!',
|
||||
'Too Many!'
|
||||
'Too Many!',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
|
||||
self::assertSame($clock, $config->clock());
|
||||
@@ -72,7 +75,10 @@ final class ConfigBagTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
|
||||
self::assertSame('fallback-totp', $config->totpUri());
|
||||
@@ -92,7 +98,10 @@ final class ConfigBagTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
|
||||
self::assertNull($config->ipTtl());
|
||||
@@ -112,7 +121,10 @@ final class ConfigBagTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
|
||||
self::assertNull($config->ipTtl());
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Listener\AcceptListener;
|
||||
use App\Service\DomainManager;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
@@ -22,9 +23,12 @@ final class AcceptListenerTest extends TestCase
|
||||
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
|
||||
|
||||
private function makeListener(ArrayAdapter $pool, DomainManager $domainManager): AcceptListener
|
||||
{
|
||||
$listener = new AcceptListener($pool, $domainManager);
|
||||
private function makeListener(
|
||||
ArrayAdapter $pool,
|
||||
DomainManager $domainManager,
|
||||
?ConfigBag $config = null,
|
||||
): AcceptListener {
|
||||
$listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig());
|
||||
$listener->setLogger(new NullLogger());
|
||||
return $listener;
|
||||
}
|
||||
@@ -130,4 +134,131 @@ final class AcceptListenerTest extends TestCase
|
||||
// empty cookie value should not be treated as a valid session
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
/* ── Remote-User header modes ─────────────────────────────────────── */
|
||||
|
||||
public function testRemoteUserSessionModeSendsSessionId(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('alice');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'session'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame('alice', $event->getResponse()->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testRemoteUserStaticModeSendsFixedValue(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('alice');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testRemoteUserMappedModeSendsMappedValue(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('alice');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame('admin', $event->getResponse()->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testRemoteUserMappedModeFallsBackToSessionIdWhenNotInMap(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('unknown_user');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testRemoteUserNoneModeOmitsHeader(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('alice');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'none'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertFalse($event->getResponse()->headers->has('Remote-User'));
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,10 @@ final class GetTotpTraitTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
$obj->setConfig($config);
|
||||
|
||||
@@ -100,7 +103,10 @@ final class GetTotpTraitTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
$obj->setConfig($config);
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use App\Trait\StringTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class StringTraitTest extends TestCase
|
||||
{
|
||||
use StringTrait;
|
||||
use TotpTestHelper;
|
||||
|
||||
public function testMakeCacheKeySanitizesInvalidChars(): void
|
||||
{
|
||||
@@ -67,4 +69,50 @@ final class StringTraitTest extends TestCase
|
||||
$result = $this->makeCacheKey('a🎉b');
|
||||
self::assertSame('a_b', $result);
|
||||
}
|
||||
|
||||
/* ── authSuccessResponse ──────────────────────────────────────────── */
|
||||
|
||||
public function testAuthSuccessResponseSessionMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'session');
|
||||
$response = $this->authSuccessResponse('alice', $config);
|
||||
|
||||
self::assertSame('hi alice', $response->getContent());
|
||||
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testAuthSuccessResponseStaticMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
|
||||
$response = $this->authSuccessResponse('alice', $config);
|
||||
|
||||
self::assertSame('hi alice', $response->getContent());
|
||||
self::assertSame('authenticated', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testAuthSuccessResponseMappedMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
|
||||
$response = $this->authSuccessResponse('alice', $config);
|
||||
|
||||
self::assertSame('admin', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testAuthSuccessResponseMappedModeFallback(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
|
||||
$response = $this->authSuccessResponse('unknown', $config);
|
||||
|
||||
self::assertSame('unknown', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testAuthSuccessResponseNoneModeOmitsHeader(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'none');
|
||||
$response = $this->authSuccessResponse('alice', $config);
|
||||
|
||||
self::assertSame('hi alice', $response->getContent());
|
||||
self::assertFalse($response->headers->has('Remote-User'));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user