Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
> before assuming something isn't built. `@api` = stable to build on; `@internal` = may change.
> Grouped by **capability** (across layers), not by directory.

**209 classes** across **33 capabilities** · **22 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).
**211 classes** across **33 capabilities** · **22 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).

## Capabilities (`library/Tiger`)

Expand All @@ -18,6 +18,8 @@

### Authentication

- **Tiger_Auth_Credential** `@api` — the registry + config selector for pluggable password-factor providers. · `library/Tiger/Auth/Credential.php`
- **Tiger_Auth_Credential_Adapter_Abstract** `@api` — a pluggable verifier for the PASSWORD factor. · `library/Tiger/Auth/Credential/Adapter/Abstract.php`
- **Tiger_Auth_Totp** `@api` — RFC 6238 time-based one-time passwords (the "authenticator app" factor), dependency-free. · `library/Tiger/Auth/Totp.php`
- **Tiger_Model_AuthChallenge** `@api` — AuthChallenge — transient, single-use auth proofs (OTP codes, reset/verify/magic tokens). · `library/Tiger/Model/AuthChallenge.php`
- **Tiger_Model_Login** `@api` — Login — the append-only authentication audit log (see migration 0011). · `library/Tiger/Model/Login.php`
Expand Down
8 changes: 8 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ framework.
fallback so nothing breaks mid-rotation, and it's **fail-safe** — the old secret is only removed
(`secrets:drop-retired`) once you've confirmed the migration, so a botched rotation can't lock anyone
out. Multiple retired secrets are supported (overlapping rotations).
- **Pluggable password factor.** The password check is a config-selected, provider-agnostic adapter
(`Tiger_Auth_Credential`, `tiger.auth.credential.provider` — the same pattern as `Tiger_Location`/
`Tiger_Mail`/`Tiger_Log`). Unset → the built-in DB `user_credential` path (every ordinary install,
unchanged). A deployment can register an adapter and point the factor at another authority — e.g.
TigerServer verifies an account owner's web login against the OS/system credential so there's a single
password — as a provider *chain* (the adapter owns only the users it `appliesTo`; everyone else falls
back to the DB), and only the password factor moves: TOTP/other factors, lockout, audit and session
issuance stay in the auth service.
- **One-time challenges.** `auth_challenge` backs OTP / password-reset / magic-link flows —
hashed codes, single-use, TTL, attempt-limited.
- **Self-service password reset.** A themed forgot/reset flow: an emailed tokenized link
Expand Down
102 changes: 102 additions & 0 deletions library/Tiger/Auth/Credential.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Auth_Credential — the registry + config selector for pluggable password-factor providers.
*
* The password factor is provider-agnostic, config-driven, and OPTIONAL: `tiger.auth.credential.provider`
* names the adapter (unset/`db` → the built-in DB path, which is what every ordinary install runs, so
* behaviour is unchanged unless a deployment opts in). A module registers an adapter class by name
* (`Tiger_Auth_Credential::register('server', Server_Auth_Credential::class)`) — the same shape as
* `Tiger_Location::register()` — and the deployment selects it in the `.ini`/config stack.
*
* `providerFor($user)` is the seam `Tiger_Service_Authentication` consults on each login/unlock: it
* returns the selected adapter ONLY when one is configured AND it `appliesTo()` this user; otherwise
* null, meaning "use the default DB credential path". So a non-owner user always falls back to the DB.
*
* @api
*/
class Tiger_Auth_Credential
{
/** @var array<string,string> registered adapter classes, by provider name */
protected static $_adapters = [];

/** @var array<string,Tiger_Auth_Credential_Adapter_Abstract|null> instantiated adapters, by name */
protected static $_instances = [];

/**
* Register a password-factor adapter under a provider name (idempotent; last registration wins).
* Call from a module Bootstrap, before login can run.
*
* @param string $name the provider name selected via `tiger.auth.credential.provider`
* @param string $class an `@see Tiger_Auth_Credential_Adapter_Abstract` subclass name
* @return void
*/
public static function register($name, $class)
{
$name = (string) $name;
if ($name === '' || $name === 'db') { return; } // 'db' is the reserved built-in default
self::$_adapters[$name] = (string) $class;
unset(self::$_instances[$name]);
}

/**
* The configured provider name (`tiger.auth.credential.provider`), or 'db' when unset/blank —
* so an install that never opts in always resolves to the default DB path.
*
* @return string
*/
public static function providerName()
{
if (!Zend_Registry::isRegistered('Zend_Config')) { return 'db'; }
$cfg = Zend_Registry::get('Zend_Config');
$auth = ($cfg->get('tiger') && $cfg->tiger->get('auth')) ? $cfg->tiger->auth : null;
$cred = ($auth && $auth->get('credential')) ? $auth->credential : null;
$name = $cred ? trim((string) $cred->get('provider')) : '';
return $name !== '' ? $name : 'db';
}

/**
* The adapter that owns the password factor for this user, or null to use the default DB path.
* Null whenever the provider is 'db'/unset, the named adapter isn't registered/resolvable, or the
* adapter declines this user (`appliesTo()` false) — the provider chain's fall-through to DB.
*
* @param object $user the resolved `Tiger_Model_User` row
* @return Tiger_Auth_Credential_Adapter_Abstract|null
*/
public static function providerFor($user)
{
$name = self::providerName();
if ($name === 'db') { return null; }

$adapter = self::_adapter($name);
if (!$adapter) { return null; }

try {
return $adapter->appliesTo($user) ? $adapter : null;
} catch (Throwable $e) {
return null; // a misbehaving adapter must never break login — fall back to DB
}
}

/** Instantiate (once) the registered adapter for a name, or null if absent/invalid. */
protected static function _adapter($name)
{
if (array_key_exists($name, self::$_instances)) { return self::$_instances[$name]; }

$instance = null;
$class = self::$_adapters[$name] ?? '';
if ($class !== '' && class_exists($class)) {
$obj = new $class();
if ($obj instanceof Tiger_Auth_Credential_Adapter_Abstract) { $instance = $obj; }
}
return self::$_instances[$name] = $instance;
}

/** Test seam: drop registered adapters + instances. */
public static function reset()
{
self::$_adapters = [];
self::$_instances = [];
}
}
77 changes: 77 additions & 0 deletions library/Tiger/Auth/Credential/Adapter/Abstract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Auth_Credential_Adapter_Abstract — a pluggable verifier for the PASSWORD factor.
*
* By default Tiger verifies a login's password against the `user_credential` DB row
* (`Tiger_Model_UserCredential::verifyPassword`, pepper-aware). A deployment can instead point the
* password factor at a DIFFERENT authority by registering an adapter and selecting it in the config
* cascade (`tiger.auth.credential.provider`) — the same config-driven, provider-agnostic pattern as
* `Tiger_Location`, `Tiger_Mail`'s transport, and `Tiger_Log`'s sinks. The motivating case: on
* TigerServer the account OWNER's web login should verify against the OS/system credential, so there
* is a single password (see the TigerServer `server` adapter). Only the password factor is affected —
* TOTP/other factors, the brute-force audit, session issuance, and the lock screen all stay in
* `Tiger_Service_Authentication`.
*
* An adapter is a PROVIDER CHAIN member, not a hard replace: `appliesTo()` declares which identities
* it owns (e.g. the account owner), and any identity it declines falls back to the default DB path.
* So a non-owner user the owner invited still authenticates against the DB credential unchanged.
*
* @api
*/
abstract class Tiger_Auth_Credential_Adapter_Abstract
{
/**
* Does this adapter own the password factor for THIS user? Return false to let the identity fall
* back to the default DB credential path (the provider chain).
*
* @param object $user the resolved `Tiger_Model_User` row
* @return bool
*/
abstract public function appliesTo($user): bool;

/**
* Verify the plaintext password for a user this adapter owns. Called only when `appliesTo()` is
* true. Must be constant-time-ish and fail closed (any error → false), never throw into login.
*
* @param object $user the resolved user row
* @param string $password the plaintext password
* @return bool true when the password is correct
*/
abstract public function verify($user, string $password): bool;

/**
* Is this user currently locked out by the adapter's own brute-force policy? Default: no — the
* adapter's authority (e.g. the OS, plus fail2ban) owns rate-limiting, and Tiger still records
* every attempt in the login audit. Override to add an app-tier lockout.
*
* @param object $user the resolved user row
* @return bool
*/
public function isLockedOut($user): bool
{
return false;
}

/**
* Note a failed verification (for an app-tier lockout/audit an adapter chooses to keep). No-op by
* default; the login audit log records the failure regardless.
*
* @param object $user the resolved user row
* @return void
*/
public function recordFailure($user): void
{
}

/**
* Note a successful verification. No-op by default.
*
* @param object $user the resolved user row
* @return void
*/
public function recordSuccess($user): void
{
}
}
81 changes: 53 additions & 28 deletions library/Tiger/Service/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,37 +75,55 @@ public function login($identifier, $password)
return false;
}

$credModel = new Tiger_Model_UserCredential();
$cred = $credModel->passwordCredential($user->user_id);
if (!$cred || $cred->secret === null) {
password_verify($password, $this->_dummyHash());
$this->_recordLogin(Tiger_Model_Login::RESULT_FAILURE, $identifier, $user->user_id);
return false;
}
// Password factor. A deployment may point it at an ALTERNATE authority (the config-selected
// Tiger_Auth_Credential provider — e.g. the OS/system credential on TigerServer, so there's one
// password). When a provider owns THIS user, verify there; otherwise ($provider === null: no
// provider configured, or it declines this user) run the default DB-credential path unchanged.
$provider = Tiger_Auth_Credential::providerFor($user);
if ($provider !== null) {
if ($provider->isLockedOut($user)) {
$this->_recordLogin(Tiger_Model_Login::RESULT_LOCKED, $identifier, $user->user_id);
return false;
}
if (!$provider->verify($user, $password)) {
$provider->recordFailure($user);
$this->_recordLogin(Tiger_Model_Login::RESULT_FAILURE, $identifier, $user->user_id);
return false;
}
$provider->recordSuccess($user);
} else {
$credModel = new Tiger_Model_UserCredential();
$cred = $credModel->passwordCredential($user->user_id);
if (!$cred || $cred->secret === null) {
password_verify($password, $this->_dummyHash());
$this->_recordLogin(Tiger_Model_Login::RESULT_FAILURE, $identifier, $user->user_id);
return false;
}

// Brute-force lockout: too many recent failures -> refuse without checking.
if ($credModel->isLockedOut($cred)) {
$this->_recordLogin(Tiger_Model_Login::RESULT_LOCKED, $identifier, $user->user_id);
return false;
}
// Brute-force lockout: too many recent failures -> refuse without checking.
if ($credModel->isLockedOut($cred)) {
$this->_recordLogin(Tiger_Model_Login::RESULT_LOCKED, $identifier, $user->user_id);
return false;
}

// Delegate the actual check to the model so it applies the PEPPER (and
// transparently upgrades a pre-pepper hash on success) — never a raw
// password_verify here, which would ignore the pepper.
if (!$credModel->verifyPassword($user->user_id, $password)) {
$credModel->recordFailure($cred->credential_id);
$this->_recordLogin(Tiger_Model_Login::RESULT_FAILURE, $identifier, $user->user_id);
return false;
}
// Delegate the actual check to the model so it applies the PEPPER (and
// transparently upgrades a pre-pepper hash on success) — never a raw
// password_verify here, which would ignore the pepper.
if (!$credModel->verifyPassword($user->user_id, $password)) {
$credModel->recordFailure($cred->credential_id);
$this->_recordLogin(Tiger_Model_Login::RESULT_FAILURE, $identifier, $user->user_id);
return false;
}

$credModel->recordSuccess($cred->credential_id);
$credModel->recordSuccess($cred->credential_id);
}

// Second factor gate: if the user has a confirmed authenticator app, the
// password is not enough — stash a short-lived pending challenge (bound to this
// session) and tell the caller to collect a TOTP/recovery code. NO session is
// established until verifyTwoFactor() succeeds, so a stolen password alone can't
// sign in.
if ($credModel->hasActiveTotp($user->user_id)) {
// Second factor gate: if the user has a confirmed authenticator app, the password is not
// enough — stash a short-lived pending challenge (bound to this session) and tell the caller
// to collect a TOTP/recovery code. NO session is established until verifyTwoFactor() succeeds,
// so a stolen password alone can't sign in. TOTP lives in the DB regardless of the password
// provider, so this gate is shared by both paths.
if ((new Tiger_Model_UserCredential())->hasActiveTotp($user->user_id)) {
$this->_beginPending2fa($user->user_id, $identifier);
return self::TWOFA_REQUIRED;
}
Expand Down Expand Up @@ -880,7 +898,14 @@ public function unlock($password)
if (!$identity || empty($identity->user_id)) {
return false;
}
if (!(new Tiger_Model_UserCredential())->verifyPassword($identity->user_id, (string) $password)) {
// Honor the configured password provider (e.g. the system credential on TigerServer) so the
// owner unlocks with the SAME password they signed in with; otherwise the default DB check.
$user = (new Tiger_Model_User())->findById($identity->user_id);
$provider = $user ? Tiger_Auth_Credential::providerFor($user) : null;
$ok = $provider !== null
? $provider->verify($user, (string) $password)
: (new Tiger_Model_UserCredential())->verifyPassword($identity->user_id, (string) $password);
if (!$ok) {
return false;
}
unset($this->_lockNs()->locked);
Expand Down
Loading
Loading