From 5e58e3071c7f019a91e98a20920610e34c0387c9 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Mon, 14 Sep 2026 09:48:01 -0400 Subject: [PATCH] Magic-link login: issueMagicLink()/redeemMagicLink() + /auth/magic (1.8.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-time, 2-minute, single-use sign-in URL on the existing auth_challenge substrate (type magic_link; only the token hash stored; one live link per user; attempt-limited), redeemed into a session and audited like any login. Minted only by something that owns the install — the headless installer's login verb, a hosting panel's Log in button — never by a web request. Tests: service (sign-in + audit, replay, wrong token, malformed id, superseded, expired, inactive user) and controller (/auth/magic lands an admin in /admin; a bad link goes to /auth/login). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ --- CHANGELOG.md | 12 +++++ core/controllers/AuthController.php | 19 +++++++ library/Tiger/Service/Authentication.php | 54 +++++++++++++++++++ library/Tiger/Version.php | 2 +- .../Controller/CoreControllerDispatchTest.php | 22 ++++++++ .../Service/AuthenticationTest.php | 49 +++++++++++++++++ 6 files changed, 157 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 163b2f37..46097985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [1.8.0] — 2026-09-14 + +### Added + +- **Magic-link login** — `Tiger_Service_Authentication::issueMagicLink($userId)` mints a one-time, + 2-minute, single-use sign-in URL (`/auth/magic/id//t/`; only the token's hash is + stored, on the existing `auth_challenge` substrate) and `redeemMagicLink()` consumes it into a + session, audited like any login. Minted only by something that already owns the install — the + headless installer's `login` verb, a hosting panel's "Log in" button — never by a web request. + This is what lets TigerWHM's Admin button sign the account holder straight into their site, the + way WP Toolkit's does. + ## [1.7.1] — 2026-09-14 ### Fixed diff --git a/core/controllers/AuthController.php b/core/controllers/AuthController.php index d0239e39..d9494b32 100644 --- a/core/controllers/AuthController.php +++ b/core/controllers/AuthController.php @@ -266,6 +266,25 @@ public function otpAction() $this->view->title = 'Sign in with a code — Tiger'; } + /** + * GET /auth/magic/id//t/ — redeem a one-time magic link and land in the + * admin. Minted by something that owns the install (the headless installer's `login` verb, + * a hosting panel's "Log in" button); never by a web request. A bad/used/expired link goes to + * the normal sign-in page with nothing to learn from. + * + * @return void + */ + public function magicAction() + { + $auth = new Tiger_Service_Authentication(); + $identity = $auth->redeemMagicLink((string) $this->getParam('id'), (string) $this->getParam('t')); + if (!$identity) { + $this->redirect('/auth/login'); + return; + } + $this->redirect($this->_roleHome($identity)); + } + /** * GET /auth/security -> the "Two-factor authentication" management screen, in the * admin shell. Shows enrollment (QR + manual key + confirm) or, once enabled, the diff --git a/library/Tiger/Service/Authentication.php b/library/Tiger/Service/Authentication.php index 20e6c431..e24bdd40 100644 --- a/library/Tiger/Service/Authentication.php +++ b/library/Tiger/Service/Authentication.php @@ -376,6 +376,60 @@ protected function _completeCodeLogin($user, $type, $identifier, $code) return $this->_finishLogin($user, $identifier, 'otp'); } + // ----- magic-link login (a hosting panel's "Log in" button) --------------- + // + // A one-time, short-lived URL that signs a KNOWN user in without a password. The issuer + // is something that already owns the install — the headless installer running as the + // account user, a hosting panel — never a request from the web. The token travels only in + // the URL; only its hash is stored (auth_challenge, type magic_link), single-use, TTL'd, + // attempt-limited. Same substrate as the email code login, minus the email. + + const MAGIC_TTL = 120; + + /** + * Mint a magic-link login for a user. Returns the PATH to redeem it (the caller knows the + * host), or null if the user cannot sign in. + * + * @param string $userId + * @param int $ttl seconds (default 2 minutes — long enough to click, not to forward) + * @return array{path:string,expires_in:int}|null + */ + public function issueMagicLink($userId, $ttl = self::MAGIC_TTL) + { + $user = (new Tiger_Model_User())->findById((string) $userId); + if (!$user || $user->status !== 'active') { return null; } + $model = new Tiger_Model_AuthChallenge(); + $model->invalidateActive($user->user_id, 'magic_link'); // one live link per user + $token = bin2hex(random_bytes(32)); + $id = $model->issue($user->user_id, 'magic_link', $token, (int) $ttl); + return ['path' => '/auth/magic/id/' . rawurlencode($id) . '/t/' . $token, 'expires_in' => (int) $ttl]; + } + + /** + * Redeem a magic link: consume the challenge and establish the session. Returns the + * identity, or false (missing, used, expired, wrong token — all the same to the caller). + * + * @param string $challengeId + * @param string $token + * @return object|false + */ + public function redeemMagicLink($challengeId, $token) + { + $model = new Tiger_Model_AuthChallenge(); + $row = (preg_match('/^[0-9a-f-]{36}$/', (string) $challengeId) && preg_match('/^[0-9a-f]{64}$/', (string) $token)) + ? $model->redeem((string) $challengeId, (string) $token) : null; + if (!$row || $row->type !== 'magic_link' || !$row->user_id) { + $this->_recordLogin(Tiger_Model_Login::RESULT_FAILURE, 'magic-link', null, null, 'magic'); + return false; + } + $user = (new Tiger_Model_User())->findById((string) $row->user_id); + if (!$user || $user->status !== 'active') { + $this->_recordLogin(Tiger_Model_Login::RESULT_FAILURE, 'magic-link', (string) $row->user_id, null, 'magic'); + return false; + } + return $this->_finishLogin($user, (string) $user->email, 'magic'); + } + // ----- two-factor authentication (TOTP authenticator app) ---------------- // // A confirmed TOTP factor turns login into two steps: password (login(), which diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 48c1a818..d5b24404 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.7.1'; + const VERSION = '1.8.0'; } diff --git a/tests/Integration/Controller/CoreControllerDispatchTest.php b/tests/Integration/Controller/CoreControllerDispatchTest.php index fc147dc6..1a092970 100644 --- a/tests/Integration/Controller/CoreControllerDispatchTest.php +++ b/tests/Integration/Controller/CoreControllerDispatchTest.php @@ -102,6 +102,28 @@ public function the_auth_login_action_rejects_bad_credentials_as_json(): void $this->assertSame(401, $res->getHttpResponseCode()); } + #[Test] + public function the_auth_magic_action_signs_in_and_lands_in_the_admin_or_falls_back_to_login(): void + { + // A bad link: no identity, straight to the sign-in page, nothing to learn from. + $this->dispatchAction(AuthController::class, 'magic', ['id' => 'nope', 't' => 'nope'], 'GET'); + $this->assertSame('/auth/login', $this->redirectLocation()); + $this->assertFalse(\Zend_Auth::getInstance()->hasIdentity()); + + // A real one, minted the way the headless `login` verb does it. + \Zend_Registry::set('Zend_Config', new \Zend_Config(['tiger' => ['crypto' => ['key' => 'ERERERERERERERERERERERERERERERERERERERERERE='], 'security' => ['pepper' => 'cGVwcGVyLUEtMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=']]], true)); + $uid = (new \Tiger_Model_User())->insert(['email' => 'magic-' . bin2hex(random_bytes(4)) . '@example.test', 'status' => 'active']); + $org = (new \Tiger_Model_Org())->insert(['name' => 'Magic Org', 'slug' => 'magic-' . bin2hex(random_bytes(4))]); + (new \Tiger_Model_OrgUser())->insert(['org_id' => $org, 'user_id' => $uid, 'role' => 'admin', 'status' => 'active']); + $link = (new \Tiger_Service_Authentication())->issueMagicLink($uid); + preg_match('#/id/([^/]+)/t/([0-9a-f]+)$#', $link['path'], $m); + + $this->dispatchAction(AuthController::class, 'magic', ['id' => $m[1], 't' => $m[2]], 'GET'); + $this->assertSame('/admin', $this->redirectLocation(), 'an admin lands in the admin'); + $this->assertTrue(\Zend_Auth::getInstance()->hasIdentity()); + $this->assertSame($uid, \Zend_Auth::getInstance()->getIdentity()->user_id); + } + #[Test] public function the_index_controller_renders_a_static_marketing_action_without_error(): void { diff --git a/tests/Integration/Service/AuthenticationTest.php b/tests/Integration/Service/AuthenticationTest.php index bf823659..448193bc 100644 --- a/tests/Integration/Service/AuthenticationTest.php +++ b/tests/Integration/Service/AuthenticationTest.php @@ -287,6 +287,55 @@ public function a_wrong_then_reused_login_code_both_fail(): void $this->assertFalse((new Tiger_Service_Authentication())->verifyLoginCode($email, '246800'), 'single-use: the same code cannot be replayed'); } + // ----- magic-link login (the hosting panel's "Log in" button) ------------------------- + + #[Test] + public function a_magic_link_signs_the_user_in_once_and_is_audited(): void + { + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'active']); + $this->makeOrgMembership($uid, 'admin'); + + $link = $this->auth->issueMagicLink($uid); + $this->assertMatchesRegularExpression('#^/auth/magic/id/[0-9a-f-]{36}/t/[0-9a-f]{64}$#', $link['path']); + $this->assertSame(120, $link['expires_in']); + preg_match('#/id/([^/]+)/t/([0-9a-f]+)$#', $link['path'], $m); + + $identity = $this->auth->redeemMagicLink($m[1], $m[2]); + $this->assertIsObject($identity); + $this->assertSame($uid, $identity->user_id); + $this->assertSame('admin', $identity->role); + $this->assertTrue($this->auth->isAuthenticated()); + $this->assertSame(1, $this->auditCount($uid, Tiger_Model_Login::RESULT_SUCCESS)); + + $this->assertFalse((new Tiger_Service_Authentication())->redeemMagicLink($m[1], $m[2]), 'single-use: a replay fails'); + } + + #[Test] + public function a_magic_link_refuses_a_wrong_token_an_expired_link_and_an_inactive_user(): void + { + $uid = (new Tiger_Model_User())->insert(['email' => $this->email(), 'status' => 'active']); + $link = $this->auth->issueMagicLink($uid); + preg_match('#/id/([^/]+)/t/([0-9a-f]+)$#', $link['path'], $m); + $this->assertFalse($this->auth->redeemMagicLink($m[1], str_repeat('0', 64)), 'wrong token'); + $this->assertFalse($this->auth->redeemMagicLink('not-a-uuid', $m[2]), 'malformed id'); + $this->assertGreaterThan(0, $this->auditCount(null, Tiger_Model_Login::RESULT_FAILURE, 'magic-link'), 'failures are audited'); + + // Issuing again invalidates the earlier link (one live link per user). + $link2 = $this->auth->issueMagicLink($uid); + $this->assertFalse($this->auth->redeemMagicLink($m[1], $m[2]), 'the first link is dead once a second is minted'); + + // Expired: issue with a TTL already in the past. + $expired = $this->auth->issueMagicLink($uid, -1); + preg_match('#/id/([^/]+)/t/([0-9a-f]+)$#', $expired['path'], $x); + $this->assertFalse($this->auth->redeemMagicLink($x[1], $x[2]), 'expired'); + + // Inactive user: nothing is minted. + $off = (new Tiger_Model_User())->insert(['email' => $this->email(), 'status' => 'suspended']); + $this->assertNull($this->auth->issueMagicLink($off)); + $this->assertFalse($this->auth->isAuthenticated()); + } + #[Test] public function requesting_a_login_code_is_a_silent_noop_for_unknown_or_inactive_users(): void {