diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 9230fcd4..29406784 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -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`) @@ -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` diff --git a/FEATURES.md b/FEATURES.md index 4ea32ed7..bd8a04ad 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/library/Tiger/Auth/Credential.php b/library/Tiger/Auth/Credential.php new file mode 100644 index 00000000..2ec86be2 --- /dev/null +++ b/library/Tiger/Auth/Credential.php @@ -0,0 +1,102 @@ + registered adapter classes, by provider name */ + protected static $_adapters = []; + + /** @var array 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 = []; + } +} diff --git a/library/Tiger/Auth/Credential/Adapter/Abstract.php b/library/Tiger/Auth/Credential/Adapter/Abstract.php new file mode 100644 index 00000000..cf0d4e3b --- /dev/null +++ b/library/Tiger/Auth/Credential/Adapter/Abstract.php @@ -0,0 +1,77 @@ +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; } @@ -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); diff --git a/tests/Unit/Auth/CredentialTest.php b/tests/Unit/Auth/CredentialTest.php new file mode 100644 index 00000000..63f99dc9 --- /dev/null +++ b/tests/Unit/Auth/CredentialTest.php @@ -0,0 +1,114 @@ + ['credential' => ['provider' => $name]]]; + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => $tiger])); + } + + private function user() + { + return (object) ['user_id' => '01a00000-0000-7000-8000-000000000000', 'email' => 'owner@example.test']; + } + + #[Test] + public function unset_or_db_provider_uses_the_default_db_path(): void + { + $this->configureProvider(''); // unset + $this->assertSame('db', Tiger_Auth_Credential::providerName()); + $this->assertNull(Tiger_Auth_Credential::providerFor($this->user()), 'unset → default DB path'); + + $this->configureProvider('db'); + $this->assertNull(Tiger_Auth_Credential::providerFor($this->user()), 'explicit db → default DB path'); + } + + #[Test] + public function db_and_blank_names_can_never_be_registered_as_adapters(): void + { + Tiger_Auth_Credential::register('db', FakeAlwaysAdapter::class); + Tiger_Auth_Credential::register('', FakeAlwaysAdapter::class); + $this->configureProvider('db'); + $this->assertNull(Tiger_Auth_Credential::providerFor($this->user()), 'db stays the built-in default'); + } + + #[Test] + public function a_configured_adapter_owns_only_the_users_it_applies_to(): void + { + Tiger_Auth_Credential::register('server', FakeOwnerAdapter::class); + $this->configureProvider('server'); + + $owner = (object) ['user_id' => 'x', 'email' => 'owner@example.test']; + $other = (object) ['user_id' => 'y', 'email' => 'invited@example.test']; + + $this->assertInstanceOf(Tiger_Auth_Credential_Adapter_Abstract::class, Tiger_Auth_Credential::providerFor($owner), 'owns the owner'); + $this->assertNull(Tiger_Auth_Credential::providerFor($other), 'declines a non-owner → falls back to DB'); + } + + #[Test] + public function an_unregistered_provider_name_falls_back_to_db(): void + { + $this->configureProvider('server'); // selected but never registered + $this->assertNull(Tiger_Auth_Credential::providerFor($this->user())); + } + + #[Test] + public function a_throwing_adapter_fails_safe_to_db(): void + { + Tiger_Auth_Credential::register('server', FakeThrowingAdapter::class); + $this->configureProvider('server'); + $this->assertNull(Tiger_Auth_Credential::providerFor($this->user()), 'appliesTo throwing → null, never break login'); + } +} + +/** Applies to everyone. */ +class FakeAlwaysAdapter extends Tiger_Auth_Credential_Adapter_Abstract +{ + public function appliesTo($user): bool { return true; } + public function verify($user, string $password): bool { return $password === 'right'; } +} + +/** Applies only to the account owner (by email here, standing in for "is the system user"). */ +class FakeOwnerAdapter extends Tiger_Auth_Credential_Adapter_Abstract +{ + public function appliesTo($user): bool { return isset($user->email) && $user->email === 'owner@example.test'; } + public function verify($user, string $password): bool { return true; } +} + +/** Throws in appliesTo — the seam must swallow it and fall back to DB. */ +class FakeThrowingAdapter extends Tiger_Auth_Credential_Adapter_Abstract +{ + public function appliesTo($user): bool { throw new \RuntimeException('boom'); } + public function verify($user, string $password): bool { return false; } +}