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
5 changes: 3 additions & 2 deletions 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.

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

## Capabilities (`library/Tiger`)

Expand Down Expand Up @@ -318,6 +318,7 @@
- **Tiger_Model_MessageRecipient** `@api` — who received a message, and what they did with it (0048). · `library/Tiger/Model/MessageRecipient.php`
- **Tiger_Model_Session** `@api` — Session — gateway for the DB session store (see migration 0010). · `library/Tiger/Model/Session.php`
- **Tiger_Model_SiteDomain** `@api` — SiteDomain — the host → org map that makes one install serve many sites (see migration 0052). · `library/Tiger/Model/SiteDomain.php`
- **Tiger_Model_SmsConsent** `@api` — SmsConsent — the TCPA / 10DLC consent + suppression record for a user's verified mobile number. · `library/Tiger/Model/SmsConsent.php`
- **Tiger_Model_Table** `@api` — Base table-gateway for Tiger models. · `library/Tiger/Model/Table.php`
- **Tiger_Uuid** `@api` — UUID generation for Tiger primary keys. · `library/Tiger/Uuid.php`

Expand Down Expand Up @@ -372,7 +373,7 @@
- **Media** (`media`, plugin) · services: Media, Settings · `modules/media`
- **Messages** (`message`, app) · services: Message · `modules/message`
- **Newsletter** (`newsletter`, app) · services: Audience, Render, Subscribe · `modules/newsletter`
- **Profile** (`profile`, plugin) · services: Address, Avatar, Base, Contact, Org, OrgAddress, OrgContact, OrgLogo, Security, User · `modules/profile`
- **Profile** (`profile`, plugin) · services: Address, Avatar, Base, Contact, Org, OrgAddress, OrgContact, OrgLogo, Security, Sms, User · `modules/profile`
- **Register** (`register`, plugin) · services: Registration, Status · `modules/register`
- **Schedule** (`schedule`, developer) · services: Schedule · `modules/schedule`
- **Search** (`search`, plugin) · services: Search · `modules/search`
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows

## [Unreleased]

## [1.40.0] — 2026-10-23

### Added
- **SMS opt-in + phone-as-login-identifier (profile Security → "Phone & SMS")** (TIGER-376). A
self-service, carrier-compliant (TCPA / 10DLC) double opt-in: a person adds a mobile number + ticks a
consent box → a 6-digit code is texted (via `Tiger_Sms`) → entering it marks the number **verified** and
writes an auditable consent record. A verified number becomes a **unique login identifier** (log in by
phone → OTP/magic link) and an alert target.
- **Engine** — migration `0055` (`sms_consent` — the TCPA/10DLC consent + suppression ledger: who/when/
IP/scopes/the exact language shown, one active row per user, revived on re-consent) + `Tiger_Model_SmsConsent`
(`record`, `isSuppressed`, STOP/START `optOutByPhone`/`optInByPhone`, `removeForUser`). Migration `0056`
adds a **global** active-only unique on a verified `sms` factor's number (an `IF(...)` generated column +
UNIQUE, the migration-0053 convention), so one verified number maps to exactly one account — the guarantee
`Tiger_Model_User::findByIdentifier()` (which already resolves a verified `sms` factor → its user) needs.
- **Passwordless login by phone** — `Tiger_Service_Authentication::requestLoginCodeSms()` /
`verifyLoginCodeSms()` (the long-spec'd SMS twin of the email code login), reusing the channel-agnostic
`_completeCodeLogin`; silent for an unknown or STOP-suppressed number (no enumeration).
- **UI + consent** — `Profile_Service_Sms` (self-scoped `/api`: `status`/`requestCode`/`verifyCode`/`remove`),
a "Phone & SMS" card on the profile Security tab (intl-tel-input, house primitives, external
`profile.sms.js`, full `en` i18n, deny-by-default ACL). STOP/HELP/START suppression wires through the
existing `Tiger_Sms::onInbound` seam (`Sms_Bootstrap`) into the consent ledger.

### Fixed
- **`$this->asset('js/x.js', '<module>')` now resolves module assets** (`Tiger_View_Helper_Asset`).
The helper ignored a second argument, so the intuitive "name the module" form passed a *relative*
path straight through — the file 404'd and the JS silently never loaded (a recurring footgun). The
second arg is now the module slug and resolves against `/_modules/<slug>/…`, fixing the `email` and
`sms` admin views (whose JS wasn't loading) with no change to them. The `/_modules/<slug>/…` form
still works. Guarded by new `AssetTest` cases.

## [1.39.0] — 2026-10-23

### Added
Expand Down
145 changes: 145 additions & 0 deletions library/Tiger/Model/SmsConsent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* SmsConsent — the TCPA / 10DLC consent + suppression record for a user's verified mobile number.
*
* A verified phone is an auth factor (Tiger_Model_UserCredential type `sms`); CONSENT to text it is a
* separate, auditable fact (see migration 0055). Each user has at most one active record: who consented,
* when, from what IP, to which message categories (scopes), the exact language shown, and whether a STOP
* has since suppressed them. This is the artifact a carrier/TCR audit asks for, and the gate every
* Tiger_Sms alert sender consults via isSuppressed() before sending to a number.
*
* Follows the live-override shape (like Tiger_Model_EmailTemplate): record() revives + overwrites the
* user's one row rather than inserting duplicates, so a remove → re-consent cycle updates one row and the
* `(user_id)` unique holds. STOP/START arrive by phone number (the inbound webhook has no user), so the
* opt-out/opt-in helpers resolve by phone.
*
* @api
*/
class Tiger_Model_SmsConsent extends Tiger_Model_Table
{
protected $_name = 'sms_consent';
protected $_primary = 'sms_consent_id';

/** Consent to receive 2FA / security sign-in codes by SMS. */
const SCOPE_TWOFA = 'twofa';
/** Consent to receive transactional account + security alerts by SMS. */
const SCOPE_ALERTS = 'alerts';

/**
* The user's active (non-deleted) consent record, or null.
*
* @param string $userId the owning user's id
* @return Zend_Db_Table_Row_Abstract|null
*/
public function forUser($userId)
{
return $this->fetchRow($this->activeSelect()->where('user_id = ?', (string) $userId)) ?: null;
}

/**
* Record (or refresh) a user's SMS consent — the confirmed opt-in after an OTP round-trip.
*
* Upserts the user's one row (revives a prior soft-deleted/opted-out record for the same user so the
* `(user_id)` unique holds), stamping the consent moment: the number, the consented scopes, a snapshot
* of the language shown, the IP, and `consent_at = now`. Clears any prior opt-out.
*
* @param string $userId the owning user's id
* @param string $phone the E.164 number consent was captured for
* @param string[] $scopes consented categories (SCOPE_TWOFA, SCOPE_ALERTS)
* @param string $text the exact consent language the user agreed to (audit snapshot)
* @param string|null $ip the request IP at opt-in (audit)
* @return string the sms_consent_id
*/
public function record($userId, $phone, array $scopes, $text, $ip = null)
{
$data = [
'user_id' => (string) $userId,
'phone' => (string) $phone,
'scopes' => implode(',', array_values(array_unique(array_filter(array_map('strval', $scopes))))),
'consent_text' => (string) $text,
'consent_ip' => $ip !== null ? substr((string) $ip, 0, 45) : null,
'consent_at' => $this->_now(),
'opt_out_at' => null,
'status' => 'active',
'deleted' => 0,
];
// Match ANY row (incl. soft-deleted) for this user so a prior remove/opt-out doesn't collide on the
// (user_id) unique — revive + overwrite it instead of inserting a duplicate.
$row = $this->fetchRow(
$this->select()->where('user_id = ?', (string) $userId)->order('created_at DESC')
);
if ($row) {
$this->update($data, $this->getAdapter()->quoteInto('sms_consent_id = ?', $row->sms_consent_id));
return (string) $row->sms_consent_id;
}
return (string) $this->insert($data);
}

/**
* Is this number currently suppressed — a live consent row carrying a STOP (opt-out)?
*
* A Tiger_Sms alert sender calls this before sending; true means the recipient has opted out and must
* not be messaged. A number with no record at all is NOT suppressed (it simply has no consent yet —
* that's the sender's own consent gate, separate from suppression).
*
* @param string $phone the E.164 number
* @return bool
*/
public function isSuppressed($phone)
{
if ((string) $phone === '') { return false; }
return (bool) $this->fetchRow(
$this->activeSelect()
->where('phone = ?', (string) $phone)
->where('opt_out_at IS NOT NULL')
);
}

/**
* Honor a STOP: suppress every active consent record for a number (the inbound webhook has no user).
*
* @param string $phone the E.164 number that texted STOP
* @return int rows suppressed
*/
public function optOutByPhone($phone)
{
if ((string) $phone === '') { return 0; }
$db = $this->getAdapter();
return (int) $this->update(
['opt_out_at' => $this->_now(), 'status' => 'opted_out'],
$db->quoteInto('phone = ?', (string) $phone) . ' AND deleted = 0 AND opt_out_at IS NULL'
);
}

/**
* Honor a START/UNSTOP: lift the suppression on a number's active records.
*
* @param string $phone the E.164 number that texted START
* @return int rows re-subscribed
*/
public function optInByPhone($phone)
{
if ((string) $phone === '') { return 0; }
$db = $this->getAdapter();
return (int) $this->update(
['opt_out_at' => null, 'status' => 'active'],
$db->quoteInto('phone = ?', (string) $phone) . ' AND deleted = 0 AND opt_out_at IS NOT NULL'
);
}

/**
* Remove a user's consent (soft-delete) — called when they remove their verified number.
*
* @param string $userId the owning user's id
* @return bool true when a record was present and removed
*/
public function removeForUser($userId)
{
$row = $this->forUser($userId);
if (!$row) { return false; }
$this->softDelete($this->getAdapter()->quoteInto('sms_consent_id = ?', $row->sms_consent_id));
return true;
}
}
88 changes: 72 additions & 16 deletions library/Tiger/Service/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -364,22 +364,14 @@ protected function _policyMessage($key)

// ----- one-time-code login (passwordless) --------------------------------
//
// CHANNEL = EMAIL. The challenge → verify → establish machinery is channel-
// AGNOSTIC: only (a) how the user is identified and (b) how the code is delivered
// differ. The substrate for SMS already exists (user_credential type 'sms' + a
// phone identifier; auth_challenge type 'sms_otp'), so ADDING SMS OTP later is a
// small, additive change — do NOT rework the machinery below:
//
// 1. Build a Tiger_Sms transport (a Tiger_Mail sibling: config-driven SNS/Twilio,
// creds in the DB config layer like mail.smtp.*). NOT part of this version.
// 2. Add requestLoginCodeSms($e164): resolve the user via
// Tiger_Model_UserCredential::findVerifiedByIdentifier('sms', $e164), enforce
// the same send-cap, issue an 'sms_otp' challenge, deliver via Tiger_Sms.
// 3. Add verifyLoginCodeSms($e164, $code): resolve the same user, then call
// $this->_completeCodeLogin($user, 'sms_otp', $e164, $code) — verify +
// _establish + audit are REUSED verbatim; no new session logic.
//
// See the "SMS / OTP flow" backlog item. Not implemented in this version.
// The challenge → verify → establish machinery is channel-AGNOSTIC: only (a) how the
// user is identified and (b) how the code is delivered differ. BOTH channels ship:
// EMAIL (requestLoginCode / verifyLoginCode) and SMS (requestLoginCodeSms /
// verifyLoginCodeSms, TIGER-376), and both delegate to _completeCodeLogin() — verify +
// _establish + audit are reused verbatim. SMS resolves the user via a VERIFIED `sms`
// factor (Tiger_Model_UserCredential::findVerifiedByIdentifier), which is globally
// unique (migration 0056), so a number maps to exactly one account. Do NOT fork the
// machinery for a new channel — add a request/verify pair that reuses _completeCodeLogin.

const OTP_TTL = 600; // one-time code lifetime, seconds (10 min)
const OTP_SEND_CAP = 5; // max codes emailed per user per hour (anti-bombing)
Expand Down Expand Up @@ -434,6 +426,70 @@ public function verifyLoginCode($email, $code)
return $this->_completeCodeLogin($user, 'email_login', (string) $email, $code);
}

/**
* Text a one-time login code. If the number maps to an ACTIVE user via a VERIFIED `sms` factor
* (globally unique — migration 0056) and is under the hourly send cap, issue an sms_otp challenge
* and send a 6-digit code via Tiger_Sms. ALWAYS returns void (no enumeration: the response never
* reveals whether a number is enrolled), and never sends to a number that has texted STOP.
*
* @param string $phone the mobile number (any form; normalized to E.164)
* @return void
*/
public function requestLoginCodeSms($phone)
{
$e164 = Tiger_Sms::e164((string) $phone);
if ($e164 === '') { return; }

$cred = (new Tiger_Model_UserCredential())->findVerifiedByIdentifier('sms', $e164);
if (!$cred) { return; } // no verified number → silent (no enumeration)
$user = (new Tiger_Model_User())->activeById((string) $cred->user_id);
if (!$user) { return; }

// Respect a STOP — never text a suppressed number, even a login code.
if ((new Tiger_Model_SmsConsent())->isSuppressed($e164)) { return; }

$model = new Tiger_Model_AuthChallenge();
if ($model->countRecent($user->user_id, 'sms_otp', 3600) >= self::OTP_SEND_CAP) {
return; // throttled — stop texting, but the caller/UI can't tell
}

$model->invalidateActive($user->user_id, 'sms_otp'); // a fresh code voids the previous one
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); // leading zeros kept
$model->issue($user->user_id, 'sms_otp', $code, self::OTP_TTL);

$brand = $this->_siteName();
Tiger_Sms::send($e164, $brand . ' sign-in code: ' . $code . '. It expires in 10 minutes. Reply HELP for help, STOP to opt out.');
}

/**
* Verify a texted login code and establish the session (passwordless sign-in by phone).
* Resolves the user from the verified `sms` factor, then reuses _completeCodeLogin.
*
* @param string $phone the mobile number the code was sent to
* @param string $code the 6-digit code the user entered
* @return object|false
*/
public function verifyLoginCodeSms($phone, $code)
{
$e164 = Tiger_Sms::e164((string) $phone);
$cred = $e164 !== '' ? (new Tiger_Model_UserCredential())->findVerifiedByIdentifier('sms', $e164) : null;
$user = $cred ? (new Tiger_Model_User())->findById((string) $cred->user_id) : null;
return $this->_completeCodeLogin($user, 'sms_otp', $e164, $code);
}

/**
* The configured site/brand name (for an SMS/email code), or 'Tiger' when unset.
*
* @return string
*/
protected function _siteName()
{
$cfg = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null;
$site = ($cfg && $cfg->get('tiger')) ? $cfg->tiger->get('site') : null;
$name = ($site && (string) $site->get('name') !== '') ? (string) $site->get('name') : 'Tiger';
return $name;
}

/**
* Channel-agnostic code-login completion: redeem the user's newest active code of
* $type, and on success build + establish the session. This is the reusable core
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.39.0';
const VERSION = '1.40.0';
}
23 changes: 19 additions & 4 deletions library/Tiger/View/Helper/Asset.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* through unchanged. mtime lookups are memoized per request.
*
* $this->asset($this->themeAssets . '/css/default.css')
* // → /_theme/css/default.css?v=1713900000
* // (theme asset)→ /_theme/css/default.css?v=1713900000
*
* @api
*/
Expand All @@ -37,13 +37,28 @@ class Tiger_View_Helper_Asset extends Zend_View_Helper_Abstract
* Remote/protocol-relative/empty paths, and paths whose file can't be stat'd, pass
* through unchanged. Results are memoized per request.
*
* @param string $path the root-relative asset path (e.g. `/_theme/css/app.css`)
* @return string the versioned URL, or the original path when it can't be versioned
* With a module slug as the second argument the path resolves against that module's published
* assets dir — `asset('js/x.js', 'sms')` → `/_modules/sms/js/x.js`. This is the intended form for a
* module view: before it, a bare `asset('js/x.js', 'sms')` passed a RELATIVE path straight through
* (no leading `/`), so the file 404'd and the JS silently never loaded. Prefer it over hand-writing
* the `/_modules/<slug>/…` path.
*
* @param string $path the asset path — root-relative (`/_theme/css/app.css`), or module-
* relative (`js/app.js`) when $module is given
* @param string|null $module a module slug; resolves $path against `/_modules/<slug>/`
* @return string the versioned URL, or the original path when it can't be versioned
*/
public function asset($path)
public function asset($path, $module = null)
{
$path = (string) $path;

// Module asset: resolve against the module's published dir (public/_modules/<slug>), so the
// natural "name the module" call works instead of silently emitting a 404-ing relative path.
if ($module !== null && (string) $module !== '') {
$slug = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $module);
$path = '/_modules/' . $slug . '/' . ltrim($path, '/');
}

// Off, empty, or not a local same-origin path -> pass through untouched.
if (!$this->_enabled() || $path === '' || $path[0] !== '/' || strpos($path, '//') === 0) {
return $path;
Expand Down
Loading
Loading