devinci-it/cerberus-web

A framework-neutral PHP password gateway: protect any app (or selected routes) behind a shared-password login. Ships a standalone front-controller gate and a PSR-15 middleware.

Maintainers

Package info

github.com/devinci-it/devinci-it-cerberus-web

pkg:composer/devinci-it/cerberus-web

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-07-25 19:50 UTC

This package is auto-updated.

Last update: 2026-07-25 19:53:34 UTC


README

A framework-neutral PHP password gate. Put a whole app — or a chosen set of routes — behind a single shared-password login, with no user table and no framework lock-in.

It ships two adapters over one decision engine:

  • Standalone gate — one line at the top of a front controller. Fits a hand-rolled MVC app.
  • PSR-15 middleware — drop into a Slim / Mezzio / Laminas pipeline.

The engine itself never touches superglobals or PSR interfaces, so the two adapters stay thin and you can add your own.

New here? Follow the step-by-step Quick Start. A runnable example app lives in examples/standalone/.

What it is (and what it is not)

It is a gatekeeper: a shared password that unlocks access, remembered in the session. Good for staging sites, internal tools, client previews, "coming soon" pages, or wrapping a legacy app you do not want to rewrite auth for.

It is not a user-authentication system. There are no accounts, roles, or per-user identity — only "does this visitor know the password." If you need real accounts, this is the wrong tool. You can issue several passwords (see Multiple passwords), but that is coarse access control, not identity.

Security posture is stated plainly in Security notes. Read that section before shipping — in particular the throttle caveat and the requirement to run behind TLS.

Requirements

  • PHP 8.1+
  • For the PSR-15 adapter only: a PSR-7/PSR-17 implementation (e.g. nyholm/psr7) — your framework almost certainly already provides one.

The PSR interface packages (psr/http-message, psr/http-server-middleware, psr/http-server-handler) are pulled in as dependencies. They are interface-only with zero runtime weight, which is why the PSR-15 adapter works out of the box while the standalone path stays framework-free.

Installation

Once published to Packagist:

composer require devinci-it/cerberus-web

Until then (or for a private fork), point Composer at the repository directly by adding this to the consuming app's composer.json:

{
    "repositories": [
        { "type": "vcs", "url": "https://github.com/devinci-it/cerberus-web" }
    ],
    "require": {
        "devinci-it/cerberus-web": "dev-main"
    }
}

Then composer update devinci-it/cerberus-web.

Quick start (about 5 minutes)

1. Generate a password hash

vendor/bin/gateway hash
# → prompts for a password, prints a bcrypt/argon2 digest

Passwords are never stored in plaintext. The gate only ever sees a password_hash() digest.

2. Provide the hash

Prefer an environment variable so the secret stays out of version control:

export GATEWAY_PASSWORD_HASH='$2y$10$...the digest from step 1...'

3. Publish the config

vendor/bin/gateway publish config
# → writes config/gateway.php (reads GATEWAY_PASSWORD_HASH by default)

4. Gate your app

At the very top of your front controller (e.g. public/index.php), before any output and before routing:

<?php
require __DIR__ . '/../vendor/autoload.php';

use DevinciIT\CerberusWeb\Gateway;

// If the visitor is not authenticated, this emits the login page (or a
// redirect) and exits. If they are, it returns and your app runs normally.
Gateway::protect(__DIR__ . '/../config/gateway.php');

// ... your existing router / bootstrap continues here ...

That is the whole integration for a classic MVC app. There is an equivalent one-line helper, gateway_protect(...), if you prefer functions.

Standalone usage in detail

Gateway::protect() is the shortcut. If you want to inspect the decision before acting (logging, custom responses), drive the gate yourself:

use DevinciIT\CerberusWeb\Gateway;
use DevinciIT\CerberusWeb\Http\StandaloneGate;

$gateway = Gateway::fromConfigFile(__DIR__ . '/../config/gateway.php');
$gate = new StandaloneGate($gateway);

$result = $gate->run();          // evaluate the current request

if (!$result->isAllow()) {
    // e.g. log the challenge, add headers, etc.
    $gate->send($result);        // emit status + headers + body
    exit;
}

// authenticated — continue

$result is a GateResult with ->decision (an enum: Allow, Challenge, Redirect, Locked), ->statusCode, ->headers, ->body, and ->redirectTo.

The gate handles three special paths for you: the login page (GET renders the form, POST processes the attempt) and the logout path. You do not add routes for these in your app — the gate intercepts them.

PSR-15 usage (Slim example)

use DevinciIT\CerberusWeb\Config;
use DevinciIT\CerberusWeb\Gateway;
use DevinciIT\CerberusWeb\Http\PsrGatewayMiddleware;
use Nyholm\Psr7\Factory\Psr17Factory;

$config  = Config::fromArray(require __DIR__ . '/../config/gateway.php');
$gateway = new Gateway($config);
$factory = new Psr17Factory();   // any PSR-17 factory

$app->add(new PsrGatewayMiddleware($gateway, $factory, $factory));

Add it near the top of the pipeline so it runs before your route handlers. On Allow it delegates to the next handler untouched; otherwise it returns a PSR-7 response built from the GateResult.

The middleware reads the parsed body via ServerRequestInterface::getParsedBody(), so make sure a body-parsing middleware runs before it (Slim's addBodyParsingMiddleware() does this) — otherwise the login POST cannot see the submitted password.

Route gatekeeping

Two modes, set via mode in config:

Mode Behavior
protect_all (default) Everything is gated except paths in except.
protect_listed Only paths in only are gated; everything else is open.

except is honored in both modes — it is the always-open list. Put your asset directories and health checks there so the login page can load its own styles and monitors keep working.

Path patterns

Patterns in only and except support three styles:

Style Example Matches
Exact /health only /health
Glob (single segment) /assets/* /assets/app.css, not /assets/img/logo.png
Glob (cross segment) /assets/** /assets/img/logo.png too
Regex #^/api/v\d+/# any pattern wrapped in #...#, used verbatim

Matching is on the path only (query string stripped) and is case-sensitive. A trailing slash is ignored, so /admin and /admin/ are equivalent.

Example — gate the admin area only, leave the public site open:

'mode' => Config::MODE_PROTECT_LISTED,
'only' => ['/admin', '/admin/**'],

Configuration reference

Every key from the published config/gateway.php:

Key Default Purpose
hashes (required) One password_hash() digest, or an array of them. A single string is accepted.
mode protect_all protect_all or protect_listed.
only [] Paths to gate in protect_listed mode.
except [] Always-open paths (both modes).
login_path /gateway Where the login form lives and posts to.
logout_path /gateway/logout Hitting this clears the session and redirects to login.
redirect_after_login / Fallback destination when no intended URL was captured.
use_intended_url true Send the visitor back to the page they first requested.
idle_timeout 0 Seconds of inactivity before re-auth. 0 disables.
absolute_timeout 0 Max session age in seconds before re-auth. 0 disables.
max_attempts 5 Failed attempts before lockout.
lock_seconds 300 Lockout duration.
decay_seconds 900 Window over which failed attempts are counted.
session_name pwgw_session Session cookie name (kept separate from your app's).
cookie_params [] Passed to session_set_cookie_params(). Set secure => true on HTTPS.
title / heading / message Login page copy.
view_path null Absolute path to a custom login template.

Helper functions

Autoloaded (no import needed):

gateway_protect($config);          // === Gateway::protect($config)
$hash = gateway_hash('secret');    // === Hasher::make('secret')

$config accepts a Config object, a config array, or a path to a config file.

CLI reference

vendor/bin/gateway:

Command Does
hash [password] Print a password_hash() digest. Prompts (hidden) if no argument.
verify <hash> Prompt for a password and report MATCH / NO MATCH.
publish [dir] Copy the default config to <dir>/gateway.php (won't overwrite).
help Usage.

Multiple passwords

Set several hashes to issue distinct passwords (e.g. one per contractor) without a database:

'hashes' => [
    getenv('GATEWAY_PASSWORD_ADMIN'),
    getenv('GATEWAY_PASSWORD_VENDOR'),
],

Any one of them unlocks access. Note this is not identity — the gate does not record which password was used. If you need to know who logged in, you need real per-user auth, not this.

Extension points

Everything swappable is an interface in src/Contracts. Pass your own implementations to the Gateway constructor:

$gateway = new Gateway(
    config: $config,
    session:  $myRedisSessionStore,   // SessionStoreInterface
    verifier: $myDbPasswordVerifier,  // PasswordVerifierInterface
    throttle: $myIpThrottle,          // ThrottleInterface
);
  • SessionStoreInterface — where auth state lives. Default: NativeSessionStore ($_SESSION, namespaced so it never collides with your app). Implement it for Redis, a DB, or a signed cookie.
  • PasswordVerifierInterface — how a submitted password is checked. Default: Hasher (static hash list). Implement it to pull hashes from a DB.
  • ThrottleInterface — brute-force limiting. Default: SessionThrottle (see the caveat below). Implement it against a persistent, IP-keyed store for real protection.

Custom login view

Point view_path at your own template. It receives these variables: $title, $heading, $message, $error (string|null), $csrfToken, $csrfField (a ready-made hidden input — echo it raw), $action (the login path), $fieldName (the password input name), and $lockedUntil (int|null).

The form must POST to $action and include $csrfField and an input named $fieldName, or logins will not go through. Escape everything else yourself (the bundled view uses htmlspecialchars).

Publishing to Packagist

  1. Push the package to a public GitHub repo (the name in composer.json, devinci-it/cerberus-web, should match vendor/package).
  2. Tag a release using semver: git tag v0.1.0 && git push --tags. Packagist reads versions from tags.
  3. At https://packagist.org, "Submit" and paste the repo URL.
  4. Set up the GitHub App integration (or the webhook Packagist suggests) so new tags auto-update the package. Without it, Packagist only refreshes on a manual "Update".

After that, composer require devinci-it/cerberus-web works for everyone, and you can drop the repositories block from consuming apps.

Auditing

The gateway can record every security-relevant event: login success and failure, CSRF failure, lockout, logout, and session expiry (and, optionally, challenges — unauthenticated hits on protected paths). It's off by default; enable it with a path and you get JSON Lines you can grep, tail, or feed into a pipeline.

'audit' => [
    'enabled' => true,
    'path' => __DIR__ . '/../storage/logs/cerberus-audit.log',
    'log_ip' => true,
    'log_user_agent' => true,
    'log_challenges' => false, // true also records unauth hits on protected paths (noisy)
],

Sample output (one JSON object per line):

{"ts":"2026-07-25T18:04:12Z","event":"login.failed","method":"POST","path":"/gateway","ip":"203.0.113.5","ua":"Mozilla/5.0","sid":"da4ec3358a10c9b0"}
{"ts":"2026-07-25T18:04:12Z","event":"login.succeeded","method":"POST","path":"/gateway","ip":"203.0.113.5","ua":"Mozilla/5.0","sid":"da4ec3358a10c9b0"}
{"ts":"2026-07-25T18:04:12Z","event":"login.locked_out","method":"POST","path":"/gateway","ip":"198.51.100.9","ua":"curl/8.4","sid":"7c351c3ca3e9548e","ctx_retry_after":300}

Event types: login.succeeded, login.failed, login.csrf_failed, login.locked_out, session.logout, session.expired, access.challenged.

What is and isn't logged, and why:

  • Never logged: the password, the CSRF token, or the password hash.
  • Session id → fingerprint. sid is a truncated SHA-256 of the session id, not the id itself. You can correlate an attacker's attempts across a session without the log becoming a session-hijacking tool if it leaks.
  • IP is REMOTE_ADDR only. X-Forwarded-For is spoofable and is not trusted. Behind a reverse proxy or load balancer, REMOTE_ADDR is the proxy; to log the real client, resolve it yourself (from a header your proxy sets and overwrites) and construct RequestContext with the resolved clientIp, or configure the proxy to rewrite REMOTE_ADDR. Turn IP logging off with 'log_ip' => false.
  • Failure handling. The file logger never throws — a failing audit sink must not take your app down. If it can't write, it falls back to PHP's error_log.
  • Rotation is yours. It only appends; wire up logrotate or equivalent.

Sending events to an existing logger

If your app already runs a PSR-3 logger (Monolog, etc.), route gateway events into it instead of a file. psr/log is a suggested dependency; install it, then pass the adapter to the Gateway:

use DevinciIT\CerberusWeb\Audit\Psr3AuditLogger;

$gateway = new Gateway($config, audit: new Psr3AuditLogger($yourPsr3Logger));

Failures and lockouts log at warning, everything else at info.

A custom sink

Implement AuditLoggerInterface (one method, log(AuditEvent $event): void) for anything else — a database table, syslog, an HTTP forwarder. Keep the "never throw" contract.

Security notes

Read these before putting this in front of anything that matters.

  • Run behind TLS. A shared password sent over plain HTTP is exposed on the wire. Set cookie_params => ['secure' => true, 'httponly' => true, 'samesite' => 'Lax'] so the session cookie is HTTPS-only.
  • The bundled throttle is a speed bump, not a wall. SessionThrottle keys off the session, so an attacker who discards the cookie between attempts is never locked out. It stops casual guessing and honest fat-fingering, nothing more. For anything internet-facing, implement ThrottleInterface against a persistent, IP-keyed store — or put a WAF / fail2ban in front.
  • This is one shared secret. Anyone with the password has full access, and there is no per-user revocation short of rotating the hash (which logs everyone out). That is the intended trade-off for a gate; if it is not acceptable, use real auth.
  • CSRF and session fixation are handled. The login form carries a per-session CSRF token checked with hash_equals(), and the session ID is regenerated on successful login.
  • Rotating the password means generating a new hash and updating config/env. Existing sessions stay valid until they expire (set absolute_timeout if you want rotation to force re-auth).

Testing

composer install
vendor/bin/phpunit

The suite in tests/GatewayTest.php runs the full request lifecycle against an in-memory session store: challenge, exempt paths, CSRF rejection, wrong/right password, intended-URL redirect, session regeneration, logout, throttle lockout, protect_listed mode, and timeout expiry.

License

MIT. See LICENSE.