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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<challenge>/t/<token>`; 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
Expand Down
19 changes: 19 additions & 0 deletions core/controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,25 @@ public function otpAction()
$this->view->title = 'Sign in with a code — Tiger';
}

/**
* GET /auth/magic/id/<challenge>/t/<token> — 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
Expand Down
54 changes: 54 additions & 0 deletions library/Tiger/Service/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
22 changes: 22 additions & 0 deletions tests/Integration/Controller/CoreControllerDispatchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
49 changes: 49 additions & 0 deletions tests/Integration/Service/AuthenticationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading