From 28c98ce43f3a9ef492599acce307d1099f7684c2 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Tue, 25 Aug 2026 11:21:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20add=20`tiger=20user:password`=20?= =?UTF-8?q?=E2=80=94=20console=20password=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console had no way to reset a password. That left two real dead ends: an install whose email can't be delivered (a fresh or dev box with no MTA — the web reset silently no-ops, and by design gives no hint it did) and a lone locked-out admin with no second account to rescue them. `user:password` resolves the target with `Tiger_Model_User::findByIdentifier`, so `--user=` takes an email or a username, then writes through `Tiger_Model_UserCredential::setPassword` — the SAME model the web reset uses. That is the point of the command: it is a different door, not a shortcut. The pepper, the history archive, and reuse-prevention all still apply, and the old password stops working. The install's live `Tiger_Policy_Password` is enforced by default; `--force` overrides the POLICY only, never the hashing, so a locked-out admin can always get back in without a forced password ever being stored raw. Tests exercise the command's sequence (resolve -> policy -> setPassword), since `bin/tiger` is a procedural script: identifier resolution by both email and username, old-password revocation, the single-row idempotence of a reset, history archiving driving reuse-prevention, and that a forced password is still peppered and hashed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WXgMENcwa4Q8yCJaHpjHpf --- bin/tiger | 46 +++++++ .../Console/UserPasswordResetTest.php | 120 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 tests/Integration/Console/UserPasswordResetTest.php diff --git a/bin/tiger b/bin/tiger index ab423917..7d0e210a 100755 --- a/bin/tiger +++ b/bin/tiger @@ -317,6 +317,50 @@ switch ($cmd) { } break; + case 'user:password': + // Reset a user's password from the console — the recovery path when email can't be + // delivered (a fresh/dev install with no MTA) or the only admin is locked out. It goes + // through the SAME model the app uses (Tiger_Model_UserCredential::setPassword), so the + // pepper, the history archive, and reuse-prevention all apply exactly as they do on the + // web reset. Usage: user:password [--user=] [--password=] [--force] + tiger_db(); + $flags = tiger_flags($argv); + $identifier = isset($flags['user']) ? $flags['user'] : tiger_prompt('User (email or username)'); + try { + $user = (new Tiger_Model_User())->findByIdentifier($identifier); + if (!$user) { + fwrite(STDERR, " user:password failed: no active user matches '{$identifier}'\n"); + exit(1); + } + $userId = (string) $user->user_id; + $plain = isset($flags['password']) ? $flags['password'] : tiger_prompt_secret('New password'); + if ($plain === '') { + fwrite(STDERR, " user:password failed: password may not be empty\n"); + exit(1); + } + + // Enforce the install's live policy (length / complexity / reuse). --force skips the + // policy ONLY — never the hashing — so a locked-out admin can always get back in. + $errors = (new Tiger_Policy_Password())->validate($plain, $userId); + if ($errors && empty($flags['force']) && !in_array('--force', $argv, true)) { + fwrite(STDERR, " user:password refused — the password fails this install's policy:\n"); + foreach ($errors as $key) { fwrite(STDERR, " - {$key}\n"); } + fwrite(STDERR, " Re-run with --force to set it anyway.\n"); + exit(1); + } + + (new Tiger_Model_UserCredential())->setPassword($userId, $plain); + echo " \xE2\x9C\x93 Password reset.\n"; + echo " user : " . (string) $user->username . " <" . (string) $user->email . ">\n"; + echo " user_id : {$userId}\n"; + if ($errors) { echo " note : policy overridden (" . implode(', ', $errors) . ")\n"; } + echo "\n Sign in at /auth/login — then change it from the profile screen.\n\n"; + } catch (Throwable $e) { + fwrite(STDERR, " user:password failed: " . $e->getMessage() . "\n"); + exit(1); + } + break; + case 'secrets:drop-retired': // Remove retired secret(s) after a rotation has fully migrated (crypto|pepper|all). $which = (isset($argv[2]) && in_array($argv[2], ['crypto', 'pepper', 'all'], true)) ? $argv[2] : 'all'; @@ -374,6 +418,8 @@ switch ($cmd) { echo " crypto:rekey Re-encrypt stored secrets under the current key (lossless)\n"; echo " security:rotate-pepper Rotate the password/code pepper (re-peppers on next login)\n"; echo " secrets:drop-retired [crypto|pepper|all] Remove retired secrets after migration\n"; + echo " user:password Reset a user's password (console recovery when email can't\n"; + echo " be delivered) — [--user=] [--password=] [--force]\n"; echo " make:module Scaffold a new application module (controller + /api service + acl)\n"; echo " version Show the Tiger Core version\n"; break; diff --git a/tests/Integration/Console/UserPasswordResetTest.php b/tests/Integration/Console/UserPasswordResetTest.php new file mode 100644 index 00000000..33494d0f --- /dev/null +++ b/tests/Integration/Console/UserPasswordResetTest.php @@ -0,0 +1,120 @@ +users = new Tiger_Model_User(); + $this->cred = new Tiger_Model_UserCredential(); + $this->policy = new Tiger_Policy_Password(); + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['password' => ['min_length' => 8, 'history' => 5]]], true)); + } + + /** A user with a known starting password, as the command expects to find. */ + private function makeUser(string $password): array + { + $tag = bin2hex(random_bytes(6)); + $userId = $this->users->insert([ + 'email' => "cli-{$tag}@example.test", + 'username' => "cli{$tag}", + ]); + $this->cred->setPassword($userId, $password); + return [$userId, "cli-{$tag}@example.test", "cli{$tag}"]; + } + + #[Test] + public function resolves_the_target_by_email_and_by_username(): void + { + [$userId, $email, $username] = $this->makeUser('OriginalPass1'); + + $this->assertSame($userId, (string) $this->users->findByIdentifier($email)->user_id, + 'the command accepts an email as the --user identifier'); + $this->assertSame($userId, (string) $this->users->findByIdentifier($username)->user_id, + 'and equally accepts a username'); + $this->assertNull($this->users->findByIdentifier('nobody-' . bin2hex(random_bytes(4))), + 'an unknown identifier resolves to null so the command can exit non-zero'); + } + + #[Test] + public function the_reset_replaces_the_password_and_the_old_one_stops_working(): void + { + [$userId] = $this->makeUser('OriginalPass1'); + $this->assertTrue($this->cred->verifyPassword($userId, 'OriginalPass1'), 'precondition: the original password works'); + + $this->cred->setPassword($userId, 'BrandNewPass9'); + + $this->assertTrue($this->cred->verifyPassword($userId, 'BrandNewPass9'), 'the new password authenticates'); + $this->assertFalse($this->cred->verifyPassword($userId, 'OriginalPass1'), 'and the old one no longer does'); + } + + #[Test] + public function only_one_password_row_survives_a_reset(): void + { + [$userId] = $this->makeUser('OriginalPass1'); + $this->cred->setPassword($userId, 'BrandNewPass9'); + + $rows = $this->cred->fetchAll( + $this->cred->select()->where('user_id = ?', $userId)->where('type = ?', Tiger_Model_UserCredential::TYPE_PASSWORD) + ); + $this->assertCount(1, $rows, 'setPassword is idempotent — a reset replaces, it never accumulates rows'); + } + + #[Test] + public function the_outgoing_password_is_archived_so_reuse_prevention_still_applies(): void + { + [$userId] = $this->makeUser('OriginalPass1'); + $this->cred->setPassword($userId, 'BrandNewPass9'); + + $this->assertNotEmpty((new Tiger_Model_PasswordHistory())->recentForUser($userId, 5), + 'the replaced hash is archived to history'); + $this->assertContains('password.reused', $this->policy->validate('OriginalPass1', $userId), + 'so the console reset cannot be used to quietly cycle back to a retired password'); + } + + #[Test] + public function a_policy_failing_password_is_refused_unless_forced(): void + { + [$userId] = $this->makeUser('OriginalPass1'); + + $errors = $this->policy->validate('short', $userId); + $this->assertContains('password.too_short', $errors, 'the command refuses a sub-min password by default'); + + // --force skips the POLICY only. The write still goes through setPassword, so the value + // is peppered and hashed exactly as always — never stored raw. + $this->cred->setPassword($userId, 'short'); + $row = $this->cred->factor($userId, Tiger_Model_UserCredential::TYPE_PASSWORD); + $this->assertNotSame('short', (string) $row->secret, 'a forced password is never stored in plaintext'); + $this->assertTrue($this->cred->verifyPassword($userId, 'short'), 'and it still authenticates through the normal verifier'); + } +}