From 88450f1f973579c11cae444e9e18c4fa1426eb98 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 23 Oct 2026 13:24:53 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(profile):=20SMS=20opt-in=20+=20phone-a?= =?UTF-8?q?s-login-identifier=20in=20Account=20=E2=86=92=20Security=20(v1.?= =?UTF-8?q?40.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TIGER-376. A self-service, TCPA/10DLC-compliant double opt-in on the profile Security tab: add a mobile number + tick consent → a texted 6-digit code → the number is verified and an auditable consent record is written. A verified number becomes a globally-unique login identifier (log in by phone → OTP/magic link) and an alert target — honoring the thin-user-row design (phone stays the user_credential sms factor, not a user column; ARCHITECTURE §7/§7b). - migrations 0055 (sms_consent ledger: who/when/IP/scopes/consent text + STOP/START suppression) + 0056 (global active-only UNIQUE on a verified sms factor's number) - Tiger_Model_SmsConsent; Tiger_Service_Authentication requestLoginCodeSms / verifyLoginCodeSms (reuse the channel-agnostic _completeCodeLogin) - Profile_Service_Sms + Profile_Form_Sms + the Security-tab "Phone & SMS" card + external profile.sms.js (house primitives, intl-tel-input, full en i18n, ACL) - STOP/HELP/START suppression via Tiger_Sms::onInbound (Sms_Bootstrap) - fix: Tiger_View_Helper_Asset — asset('js/x.js', '') now resolves /_modules//… instead of silently 404-ing (recurring footgun); fixes the email + sms admin views whose JS wasn't loading, no change to them - 34 new tests (service/model/auth/asset), all green on MariaDB 12.3; CAPABILITIES regenerated; CHANGELOG + VERSION 1.40.0 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 5 +- CHANGELOG.md | 30 +++ library/Tiger/Model/SmsConsent.php | 145 +++++++++++ library/Tiger/Service/Authentication.php | 88 +++++-- library/Tiger/Version.php | 2 +- library/Tiger/View/Helper/Asset.php | 23 +- migrations/0055_create_sms_consent.php | 44 ++++ ...56_user_credential_verified_sms_unique.php | 32 +++ modules/profile/assets/js/profile.sms.js | 169 ++++++++++++ modules/profile/configs/acl.ini | 5 + .../profile/controllers/IndexController.php | 2 + modules/profile/forms/Sms.php | 29 +++ modules/profile/languages/en/profile.php | 32 +++ modules/profile/services/Sms.php | 243 ++++++++++++++++++ .../views/scripts/index/_security.phtml | 96 +++++++ modules/sms/Bootstrap.php | 21 ++ tests/Integration/Model/SmsConsentTest.php | 97 +++++++ tests/Integration/Profile/SmsServiceTest.php | 217 ++++++++++++++++ .../Service/AuthenticationSmsLoginTest.php | 92 +++++++ tests/Unit/View/AssetTest.php | 48 ++++ 20 files changed, 1397 insertions(+), 23 deletions(-) create mode 100644 library/Tiger/Model/SmsConsent.php create mode 100644 migrations/0055_create_sms_consent.php create mode 100644 migrations/0056_user_credential_verified_sms_unique.php create mode 100644 modules/profile/assets/js/profile.sms.js create mode 100644 modules/profile/forms/Sms.php create mode 100644 modules/profile/services/Sms.php create mode 100644 tests/Integration/Model/SmsConsentTest.php create mode 100644 tests/Integration/Profile/SmsServiceTest.php create mode 100644 tests/Integration/Service/AuthenticationSmsLoginTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index d140389c..61c84b2c 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. -**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`) @@ -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` @@ -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` diff --git a/CHANGELOG.md b/CHANGELOG.md index 653400d0..07fe2193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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', '')` 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//…`, fixing the `email` and + `sms` admin views (whose JS wasn't loading) with no change to them. The `/_modules//…` form + still works. Guarded by new `AssetTest` cases. + ## [1.39.0] — 2026-10-23 ### Added diff --git a/library/Tiger/Model/SmsConsent.php b/library/Tiger/Model/SmsConsent.php new file mode 100644 index 00000000..ec562460 --- /dev/null +++ b/library/Tiger/Model/SmsConsent.php @@ -0,0 +1,145 @@ +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; + } +} diff --git a/library/Tiger/Service/Authentication.php b/library/Tiger/Service/Authentication.php index 4ae95b0f..e891c3f3 100644 --- a/library/Tiger/Service/Authentication.php +++ b/library/Tiger/Service/Authentication.php @@ -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) @@ -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 diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 8706682d..621be3d7 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.39.0'; + const VERSION = '1.40.0'; } diff --git a/library/Tiger/View/Helper/Asset.php b/library/Tiger/View/Helper/Asset.php index aca94642..7ad982a0 100644 --- a/library/Tiger/View/Helper/Asset.php +++ b/library/Tiger/View/Helper/Asset.php @@ -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 */ @@ -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//…` 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//` + * @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/), 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; diff --git a/migrations/0055_create_sms_consent.php b/migrations/0055_create_sms_consent.php new file mode 100644 index 00000000..c7fb7de8 --- /dev/null +++ b/migrations/0055_create_sms_consent.php @@ -0,0 +1,44 @@ + [ + "CREATE TABLE IF NOT EXISTS `sms_consent` ( + `sms_consent_id` CHAR(36) NOT NULL, + `user_id` CHAR(36) NOT NULL, + `phone` VARCHAR(32) NOT NULL DEFAULT '', + `scopes` VARCHAR(191) NOT NULL DEFAULT '', + `consent_text` VARCHAR(255) NOT NULL DEFAULT '', + `consent_ip` VARCHAR(45) NULL, + `consent_at` DATETIME NULL, + `opt_out_at` DATETIME NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'active', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`sms_consent_id`), + UNIQUE KEY `uq_sms_consent_user` (`user_id`), + KEY `idx_sms_consent_phone` (`phone`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + ], + 'down' => [ + "DROP TABLE IF EXISTS `sms_consent`", + ], +]; diff --git a/migrations/0056_user_credential_verified_sms_unique.php b/migrations/0056_user_credential_verified_sms_unique.php new file mode 100644 index 00000000..4a07c284 --- /dev/null +++ b/migrations/0056_user_credential_verified_sms_unique.php @@ -0,0 +1,32 @@ + [ + "ALTER TABLE `user_credential` + ADD COLUMN `verified_sms_key` VARCHAR(191) + GENERATED ALWAYS AS (IF(`type` = 'sms' AND `verified_at` IS NOT NULL AND `deleted` = 0, `identifier`, NULL)) VIRTUAL", + "ALTER TABLE `user_credential` + ADD UNIQUE KEY `uq_cred_verified_sms` (`verified_sms_key`)", + ], + 'down' => [ + "ALTER TABLE `user_credential` DROP INDEX `uq_cred_verified_sms`", + "ALTER TABLE `user_credential` DROP COLUMN `verified_sms_key`", + ], +]; diff --git a/modules/profile/assets/js/profile.sms.js b/modules/profile/assets/js/profile.sms.js new file mode 100644 index 00000000..d3e00043 --- /dev/null +++ b/modules/profile/assets/js/profile.sms.js @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +// +// Phone & SMS card (profile Security tab) — the two-step opt-in client: enter a number + tick consent +// → a code is texted → enter it to verify. A verified number shows with Change / Remove. All data moves +// over /api (profile/sms/*); the server is the one source of truth (every response returns the full +// state + a rotated CSRF token). Config + strings arrive on #profile-sms[data-config]. House primitives +// only: TigerButton (busy state), TigerDOM (feedback), TigerModal (confirm) — no inline script, no alert. +(function () { + 'use strict'; + var root = document.getElementById('profile-sms'); + if (!root) { return; } + + var CFG = {}; + try { CFG = JSON.parse(root.getAttribute('data-config') || '{}'); } catch (e) { CFG = {}; } + var STR = CFG.str || {}; + + var fb = document.getElementById('profile-feedback'); + var form = document.getElementById('sms-form'); + var csrfInput = form ? form.querySelector('[name="_csrf"]') : null; + + var stAdd = document.getElementById('sms-state-add'); + var stVerify = document.getElementById('sms-state-verify'); + var stVerified = document.getElementById('sms-state-verified'); + + var phoneEl = document.getElementById('sms-phone'); + var consentEl = document.getElementById('sms-consent'); + var sendBtn = document.getElementById('sms-send'); + var addCancel = document.getElementById('sms-add-cancel'); + + var codeEl = document.getElementById('sms-code'); + var verifyBtn = document.getElementById('sms-verify'); + var resendBtn = document.getElementById('sms-resend'); + var verifyCancel = document.getElementById('sms-verify-cancel'); + var pendingHint = document.getElementById('sms-pending-hint'); + + var verifiedNum = document.getElementById('sms-verified-number'); + var verifiedHint = document.getElementById('sms-verified-hint'); + var changeBtn = document.getElementById('sms-change'); + var removeBtn = document.getElementById('sms-remove'); + + var pendingPhone = ''; // the E.164 we're verifying (server-echoed; never user-visible input) + var hasVerified = false; // a verified number exists (governs Cancel's destination) + var loaded = false; + var iti = null; + + function notify(msg, type) { if (window.TigerDOM && fb) { TigerDOM.notify(fb, msg, { type: type || 'info' }); } } + function show(el) { if (el) { el.classList.remove('d-none'); } } + function hide(el) { if (el) { el.classList.add('d-none'); } } + + function initIti() { + if (iti || !window.intlTelInput || !phoneEl) { return; } + iti = window.intlTelInput(phoneEl, { + initialCountry: (CFG.phoneDefault || 'us'), + separateDialCode: true, + formatOnDisplay: true, + countrySearch: true + }); + } + + function post(method, extra) { + var fd = new URLSearchParams(); + fd.set('module', 'profile'); fd.set('service', 'sms'); fd.set('method', method); + fd.set('_csrf', csrfInput ? csrfInput.value : ''); + if (extra) { Object.keys(extra).forEach(function (k) { fd.set(k, extra[k]); }); } + return fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd }) + .then(function (r) { return r.json().catch(function () { return {}; }); }); + } + + // Reflect the server state into the right panel. + function apply(res) { + if (res && res.data && res.data._csrf && csrfInput) { csrfInput.value = res.data._csrf; } + var s = (res && res.data && res.data.sms) ? res.data.sms : null; + if (!s) { return; } + hasVerified = !!s.verified; + + hide(stAdd); hide(stVerify); hide(stVerified); + + if (s.pending && !s.verified) { + // Mid-enrollment: show the code step. Prefer the server-echoed pending phone from this call. + pendingPhone = (res.data.pending_phone || s.pending_phone || ''); + var mask = (res.data.pending_mask || s.pending_mask || ''); + if (pendingHint) { pendingHint.textContent = (STR.pending_hint || '').replace('%s', mask); } + show(stVerify); + if (codeEl) { codeEl.value = ''; codeEl.focus(); } + } else if (s.verified) { + if (verifiedNum) { verifiedNum.textContent = s.phone || ''; } + if (verifiedHint) { verifiedHint.textContent = s.opted_out ? (STR.opted_out_hint || '') : (STR.verified_hint || ''); } + show(stVerified); + } else { + show(stAdd); + if (addCancel) { hasVerified ? show(addCancel) : hide(addCancel); } + } + } + + function load() { + if (loaded) { return; } + loaded = true; + post('status').then(apply).catch(function () { loaded = false; }); + } + + // ---- actions --------------------------------------------------------- + if (sendBtn) { + sendBtn.addEventListener('click', function () { + initIti(); + if (consentEl && !consentEl.checked) { notify(STR.consent_req, 'error'); return; } + if (!iti || !iti.isValidNumber()) { if (phoneEl) { phoneEl.classList.add('is-invalid'); } notify(STR.bad_phone, 'error'); return; } + if (phoneEl) { phoneEl.classList.remove('is-invalid'); } + var e164 = iti.getNumber(); + TigerButton.run(this, function () { return post('requestCode', { phone: e164, consent: '1' }); }) + .then(function (res) { + if (res && res.result === 1) { apply(res); notify(STR.code_sent, 'success'); return; } + (res && res.messages || []).forEach(function (m) { notify(m.message, m.class); }); + }).catch(function () { notify(STR.net_error, 'error'); }); + }); + } + + if (verifyBtn) { + verifyBtn.addEventListener('click', function () { + var code = (codeEl ? codeEl.value : '').replace(/\D/g, ''); + TigerButton.run(this, function () { return post('verifyCode', { phone: pendingPhone, code: code }); }) + .then(function (res) { + if (res && res.result === 1) { apply(res); notify(STR.verified, 'success'); return; } + if (codeEl) { codeEl.classList.add('is-invalid'); } + (res && res.messages || []).forEach(function (m) { notify(m.message, m.class); }); + }).catch(function () { notify(STR.net_error, 'error'); }); + }); + } + + if (resendBtn) { + resendBtn.addEventListener('click', function () { + TigerButton.run(this, function () { return post('requestCode', { phone: pendingPhone, consent: '1' }); }) + .then(function (res) { + if (res && res.result === 1) { apply(res); notify(STR.code_sent, 'success'); return; } + (res && res.messages || []).forEach(function (m) { notify(m.message, m.class); }); + }).catch(function () { notify(STR.net_error, 'error'); }); + }); + } + + function toAdd() { hide(stVerify); hide(stVerified); show(stAdd); if (addCancel) { hasVerified ? show(addCancel) : hide(addCancel); } initIti(); if (phoneEl) { phoneEl.focus(); } } + if (changeBtn) { changeBtn.addEventListener('click', toAdd); } + if (addCancel) { addCancel.addEventListener('click', function () { load2(); }); } + if (verifyCancel) { verifyCancel.addEventListener('click', function () { load2(); }); } + + // Re-pull state (used by Cancel) — forces a fresh status read. + function load2() { loaded = false; load(); } + + if (removeBtn) { + removeBtn.addEventListener('click', function () { + var go = function () { + TigerButton.run(removeBtn, function () { return post('remove'); }) + .then(function (res) { + if (res && res.result === 1) { apply(res); notify(STR.removed, 'success'); return; } + (res && res.messages || []).forEach(function (m) { notify(m.message, m.class); }); + }).catch(function () { notify(STR.net_error, 'error'); }); + }; + if (window.TigerModal && TigerModal.confirm) { + TigerModal.confirm({ title: STR.remove, body: STR.confirm_remove, confirmLabel: STR.remove, variant: 'danger' }) + .then(function (ok) { if (ok) { go(); } }); + } else { go(); } + }); + } + + // Load lazily when the Security tab is shown (the pane is hidden at page load), or now if it's active. + var tabBtn = document.querySelector('[data-bs-toggle="tab"][data-bs-target="#security"]'); + if (tabBtn) { tabBtn.addEventListener('shown.bs.tab', function () { initIti(); load(); }); } + var pane = document.getElementById('security'); + if (pane && pane.classList.contains('active')) { initIti(); load(); } +})(); diff --git a/modules/profile/configs/acl.ini b/modules/profile/configs/acl.ini index b7a598e4..a17ad62f 100644 --- a/modules/profile/configs/acl.ini +++ b/modules/profile/configs/acl.ini @@ -22,6 +22,7 @@ acl.resources.profile_orgaddress_svc.resource = "Profile_Service_OrgAddress" acl.resources.profile_orglogo_svc.resource = "Profile_Service_OrgLogo" acl.resources.profile_user_svc.resource = "Profile_Service_User" acl.resources.profile_security_svc.resource = "Profile_Service_Security" +acl.resources.profile_sms_svc.resource = "Profile_Service_Sms" acl.resources.profile_avatar_svc.resource = "Profile_Service_Avatar" acl.resources.profile_contact_svc.resource = "Profile_Service_Contact" acl.resources.profile_address_svc.resource = "Profile_Service_Address" @@ -59,6 +60,10 @@ acl.rules.profile_security_svc.role = "user" acl.rules.profile_security_svc.resource = "Profile_Service_Security" acl.rules.profile_security_svc.permission = "allow" +acl.rules.profile_sms_svc.role = "user" +acl.rules.profile_sms_svc.resource = "Profile_Service_Sms" +acl.rules.profile_sms_svc.permission = "allow" + acl.rules.profile_avatar_svc.role = "user" acl.rules.profile_avatar_svc.resource = "Profile_Service_Avatar" acl.rules.profile_avatar_svc.permission = "allow" diff --git a/modules/profile/controllers/IndexController.php b/modules/profile/controllers/IndexController.php index 70ee9be7..b3f77384 100644 --- a/modules/profile/controllers/IndexController.php +++ b/modules/profile/controllers/IndexController.php @@ -73,6 +73,8 @@ public function indexAction() $this->view->model = [ 'form' => new Profile_Form_UserProfile(), 'passwordForm' => new Profile_Form_Password(), + 'smsForm' => new Profile_Form_Sms(), + 'smsConfigured'=> Tiger_Sms::isConfigured(), 'contactForm' => new Profile_Form_Contact(), 'avatarUrl' => $avatarUrl, 'displayName' => $displayName, diff --git a/modules/profile/forms/Sms.php b/modules/profile/forms/Sms.php new file mode 100644 index 00000000..eb633263 --- /dev/null +++ b/modules/profile/forms/Sms.php @@ -0,0 +1,29 @@ + ['StringTrim']]], // canonical E.164 from intl-tel-input + ['checkbox', 'consent', []], // the opt-in tick (required by the service) + ['text', 'code', [ + 'filters' => ['StringTrim', 'Digits'], + 'validators' => [['StringLength', true, ['min' => 0, 'max' => 10]]], + 'attribs' => ['class' => 'form-control', 'inputmode' => 'numeric', 'autocomplete' => 'one-time-code'], + ]], + ]; + } +} diff --git a/modules/profile/languages/en/profile.php b/modules/profile/languages/en/profile.php index 62851cf7..2137a1a2 100644 --- a/modules/profile/languages/en/profile.php +++ b/modules/profile/languages/en/profile.php @@ -25,6 +25,38 @@ 'profile.security.password_change_failed' => 'Couldn’t change your password. Please try again.', 'profile.security.twofa' => 'Manage two-factor authentication', + 'profile.sms.title' => 'Phone & SMS', + 'profile.sms.intro' => 'Add a mobile number to sign in by phone and receive security codes and account alerts by text.', + 'profile.sms.number' => 'Mobile number', + 'profile.sms.consent_label' => 'I agree to receive account security codes (2FA) and account & security alerts by text message. Message & data rates may apply. Reply STOP to cancel, HELP for help.', + 'profile.sms.send_code' => 'Send code', + 'profile.sms.code' => 'Verification code', + 'profile.sms.code_ph' => '6-digit code', + 'profile.sms.verify' => 'Verify', + 'profile.sms.resend' => 'Resend code', + 'profile.sms.change' => 'Change number', + 'profile.sms.remove' => 'Remove number', + 'profile.sms.cancel' => 'Cancel', + 'profile.sms.verified_label' => 'Verified', + 'profile.sms.pending_hint' => 'We texted a code to %s. Enter it below to verify.', + 'profile.sms.verified_hint' => 'This number is verified. You can sign in by phone and receive security codes and alerts.', + 'profile.sms.opted_out_hint' => 'This number replied STOP, so alerts are paused. Reply START from your phone to resume.', + 'profile.sms.none' => 'No mobile number on file yet.', + 'profile.sms.not_configured' => 'SMS isn’t configured on this site yet. Please try again later.', + 'profile.sms.confirm_remove' => 'Remove this number? You’ll no longer get security codes or alerts by text, and can’t sign in by phone.', + + 'profile.sms.code_sent' => 'Code sent. Check your phone.', + 'profile.sms.verified' => 'Number verified.', + 'profile.sms.removed' => 'Number removed.', + 'profile.sms.consent_required' => 'Please agree to receive text messages before we can send a code.', + 'profile.sms.bad_phone' => 'Please enter a valid mobile number.', + 'profile.sms.bad_code' => 'That code is incorrect or has expired. Please try again.', + 'profile.sms.in_use' => 'That number is already in use on another account.', + 'profile.sms.already_yours' => 'That number is already verified on your account.', + 'profile.sms.no_pending' => 'Start by sending a code to your number.', + 'profile.sms.too_many' => 'Too many codes requested. Please wait a while and try again.', + 'profile.sms.send_failed' => 'We couldn’t send the code. Please check the number and try again.', + 'profile.user.display_name' => 'Display Name', 'profile.user.display_name_ph' => 'How your name appears', 'profile.user.username' => 'Username', diff --git a/modules/profile/services/Sms.php b/modules/profile/services/Sms.php new file mode 100644 index 00000000..2d1d1079 --- /dev/null +++ b/modules/profile/services/Sms.php @@ -0,0 +1,243 @@ +_user_id, never a payload id): a person adds and + * verifies their OWN mobile number for SMS. The flow is a carrier-compliant double opt-in: + * + * requestCode — the user enters a number + ticks the consent box → an sms_otp challenge is issued + * and a 6-digit code texted (proving they own the number; the consent box is the TCPA + * opt-in). Refused without consent, for a number another account already verified, or + * over the hourly send cap. + * verifyCode — the user enters the code → the pending `sms` credential factor is marked VERIFIED + * (globally unique, migration 0056) and an auditable consent record is written + * (Tiger_Model_SmsConsent): who/when/IP/scopes/the exact language shown. The number is + * now a login identifier (log in by phone → OTP/magic link) and alert target. + * remove — unlink the number (frees it globally) and soft-delete the consent record. + * + * STOP/HELP/START suppression is handled out-of-band by the inbound webhook subscriber + * (Sms_Bootstrap → Tiger_Model_SmsConsent::optOutByPhone); this service only drives enrollment. + * + * @api + */ +class Profile_Service_Sms extends Profile_Service_Base +{ + /** The exact consent language the user agrees to — snapshotted into the audit record. */ + const CONSENT_TEXT = 'I agree to receive account security codes (2FA) and account & security alerts by text message. Message & data rates may apply. Reply STOP to cancel, HELP for help.'; + + /** + * Current SMS state for the signed-in user (drives the card render + every refresh). + * + * @param array $params unused + * @return void + */ + public function status(array $params): void + { + $userId = (string) $this->_user_id; + if ($userId === '') { $this->_error('core.api.error.not_allowed'); return; } + $this->_success($this->_state($userId)); + } + + /** + * Step 1 — send a verification code to a number the user is enrolling (requires the consent tick). + * + * @param array $params phone, consent (1), _csrf + * @return void + */ + public function requestCode(array $params): void + { + $userId = (string) $this->_user_id; + if ($userId === '') { $this->_error('core.api.error.not_allowed'); return; } + + $form = new Profile_Form_Sms(); + if (!$this->_validCsrf(Profile_Form_Sms::class, $params)) { $this->_error('core.api.error.csrf'); return; } + + // Consent is mandatory — we never text a number without the opt-in (TCPA/10DLC). + if (empty($params['consent'])) { $this->_error('profile.sms.consent_required', ['field' => 'consent']); return; } + + $e164 = Tiger_Sms::e164((string) ($params['phone'] ?? '')); + if ($e164 === '') { $this->_error('profile.sms.bad_phone', ['field' => 'phone']); return; } + + $cred = new Tiger_Model_UserCredential(); + + // Already this user's verified number? Nothing to do. + $mine = $cred->factor($userId, Tiger_Model_UserCredential::TYPE_SMS, $e164); + if ($mine && $mine->verified_at !== null) { $this->_error('profile.sms.already_yours', ['field' => 'phone']); return; } + + // Taken by another account (global uniqueness — one verified number, one user)? + $owner = $cred->findVerifiedByIdentifier(Tiger_Model_UserCredential::TYPE_SMS, $e164); + if ($owner && (string) $owner->user_id !== $userId) { $this->_error('profile.sms.in_use', ['field' => 'phone']); return; } + + // We can only text if a provider is configured — don't mint a code we can't deliver. + if (!Tiger_Sms::isConfigured()) { $this->_error('profile.sms.not_configured'); return; } + + $ch = new Tiger_Model_AuthChallenge(); + if ($ch->countRecent($userId, 'sms_otp', 3600) >= 5) { $this->_error('profile.sms.too_many'); return; } + + try { + // Reuse the pending factor for this number, else create it (unverified). + $credId = $mine ? (string) $mine->credential_id : (string) $cred->addSms($userId, $e164); + + $ch->invalidateActive($userId, 'sms_otp'); // only the newest code is ever valid + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $ch->issue($userId, 'sms_otp', $code, 600); + + $res = Tiger_Sms::send($e164, $this->_siteName() . ' verification code: ' . $code . '. It expires in 10 minutes. Reply HELP for help, STOP to opt out.'); + if (empty($res['ok'])) { $this->_error('profile.sms.send_failed'); return; } + + $data = $this->_state($userId); + $data['pending_phone'] = $e164; + $data['pending_mask'] = $this->_mask($e164); + $this->_success($data, 'profile.sms.code_sent'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + + /** + * Step 2 — confirm the texted code: mark the number verified + record consent (double opt-in done). + * + * @param array $params phone, code, _csrf + * @return void + */ + public function verifyCode(array $params): void + { + $userId = (string) $this->_user_id; + if ($userId === '') { $this->_error('core.api.error.not_allowed'); return; } + if (!$this->_validCsrf(Profile_Form_Sms::class, $params)) { $this->_error('core.api.error.csrf'); return; } + + $e164 = Tiger_Sms::e164((string) ($params['phone'] ?? '')); + $code = preg_replace('/\D/', '', (string) ($params['code'] ?? '')); + if ($e164 === '') { $this->_error('profile.sms.bad_phone', ['field' => 'phone']); return; } + + $cred = new Tiger_Model_UserCredential(); + $pending = $cred->factor($userId, Tiger_Model_UserCredential::TYPE_SMS, $e164); + if (!$pending) { $this->_error('profile.sms.no_pending'); return; } + + // Redeem the newest active sms_otp (single-use, TTL- and attempt-limited inside the model). + $chModel = new Tiger_Model_AuthChallenge(); + $active = $chModel->latestActive($userId, 'sms_otp'); + if ($code === '' || !$active || !$chModel->redeem($active->challenge_id, $code)) { + $this->_error('profile.sms.bad_code', ['field' => 'code']); return; + } + + // Race backstop before the write (the unique index is the hard guarantee). + $owner = $cred->findVerifiedByIdentifier(Tiger_Model_UserCredential::TYPE_SMS, $e164); + if ($owner && (string) $owner->user_id !== $userId) { $this->_error('profile.sms.in_use', ['field' => 'phone']); return; } + + try { + $this->_transaction(function () use ($cred, $userId, $e164, $pending) { + // Retire any OTHER sms factor this user holds (one verified number per account); a + // soft-deleted row's verified_sms_key becomes NULL, freeing that number globally. + foreach ($cred->factorsFor($userId) as $f) { + if ((string) $f->type === Tiger_Model_UserCredential::TYPE_SMS + && (string) $f->credential_id !== (string) $pending->credential_id) { + $cred->softDelete($cred->getAdapter()->quoteInto('credential_id = ?', (string) $f->credential_id)); + } + } + $cred->markVerified((string) $pending->credential_id); // UNIQUE(verified_sms_key) is the backstop + (new Tiger_Model_SmsConsent())->record( + $userId, + $e164, + [Tiger_Model_SmsConsent::SCOPE_TWOFA, Tiger_Model_SmsConsent::SCOPE_ALERTS], + self::CONSENT_TEXT, + $_SERVER['REMOTE_ADDR'] ?? null + ); + }); + $this->_success($this->_state($userId), 'profile.sms.verified'); + } catch (Throwable $e) { + // A duplicate-key here means another account verified it in the race window. + $msg = $e->getMessage(); + if (stripos($msg, 'uq_cred_verified_sms') !== false || stripos($msg, 'Duplicate') !== false) { + $this->_error('profile.sms.in_use', ['field' => 'phone']); return; + } + $this->_error(APPLICATION_ENV !== 'production' ? $msg : 'core.api.error.general'); + } + } + + /** + * Remove the user's verified/pending number and soft-delete their consent record. + * + * @param array $params _csrf + * @return void + */ + public function remove(array $params): void + { + $userId = (string) $this->_user_id; + if ($userId === '') { $this->_error('core.api.error.not_allowed'); return; } + if (!$this->_validCsrf(Profile_Form_Sms::class, $params)) { $this->_error('core.api.error.csrf'); return; } + + try { + $this->_transaction(function () use ($userId) { + $cred = new Tiger_Model_UserCredential(); + foreach ($cred->factorsFor($userId) as $f) { + if ((string) $f->type === Tiger_Model_UserCredential::TYPE_SMS) { + $cred->softDelete($cred->getAdapter()->quoteInto('credential_id = ?', (string) $f->credential_id)); + } + } + (new Tiger_Model_SmsConsent())->removeForUser($userId); + }); + $this->_success($this->_state($userId), 'profile.sms.removed'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + + /** + * The current SMS state payload + a rotated CSRF token for the reused card form. + * + * @param string $userId + * @return array + */ + protected function _state($userId) + { + $cred = new Tiger_Model_UserCredential(); + $verified = null; + $pending = null; + foreach ($cred->factorsFor($userId) as $f) { + if ((string) $f->type !== Tiger_Model_UserCredential::TYPE_SMS) { continue; } + if ($f->verified_at !== null) { $verified = $f; } else { $pending = $f; } + } + $consent = (new Tiger_Model_SmsConsent())->forUser($userId); + + return [ + 'sms' => [ + 'verified' => $verified ? true : false, + 'phone' => $verified ? $this->_mask((string) $verified->identifier) : '', + 'pending' => $pending ? true : false, + 'pending_phone' => $pending ? (string) $pending->identifier : '', + 'pending_mask' => $pending ? $this->_mask((string) $pending->identifier) : '', + 'scopes' => $consent ? (string) $consent->scopes : '', + 'opted_out' => $consent && $consent->opt_out_at !== null, + ], + '_csrf' => $this->_freshToken(Profile_Form_Sms::class), + ]; + } + + /** + * Mask an E.164 number for display — last four digits only (e.g. +1 ••• ••• 3563 → ••••••3563). + * + * @param string $e164 + * @return string + */ + protected function _mask($e164) + { + $e164 = (string) $e164; + if (strlen($e164) <= 4) { return $e164; } + return '••••••' . substr($e164, -4); + } + + /** + * The configured site/brand name (prefixes the SMS code), or 'Tiger'. + * + * @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; + return ($site && (string) $site->get('name') !== '') ? (string) $site->get('name') : 'Tiger'; + } +} diff --git a/modules/profile/views/scripts/index/_security.phtml b/modules/profile/views/scripts/index/_security.phtml index febdf324..f6b5b4c0 100644 --- a/modules/profile/views/scripts/index/_security.phtml +++ b/modules/profile/views/scripts/index/_security.phtml @@ -92,3 +92,99 @@ $ta = isset($this->themeAssets) && $this->themeAssets ? $this->themeAssets : '/_ }); })(); + + — house rule); the logic is profile.sms.js. +$smsCfg = [ + 'phoneDefault' => strtolower((string) $this->phoneDefault), + 'configured' => (bool) $this->smsConfigured, + 'str' => [ + 'send_code' => $this->t('profile.sms.send_code'), + 'verify' => $this->t('profile.sms.verify'), + 'resend' => $this->t('profile.sms.resend'), + 'change' => $this->t('profile.sms.change'), + 'remove' => $this->t('profile.sms.remove'), + 'cancel' => $this->t('profile.sms.cancel'), + 'none' => $this->t('profile.sms.none'), + 'verified_label' => $this->t('profile.sms.verified_label'), + 'verified_hint' => $this->t('profile.sms.verified_hint'), + 'opted_out_hint' => $this->t('profile.sms.opted_out_hint'), + 'pending_hint' => $this->t('profile.sms.pending_hint'), + 'not_configured' => $this->t('profile.sms.not_configured'), + 'confirm_remove' => $this->t('profile.sms.confirm_remove'), + 'bad_phone' => $this->t('profile.sms.bad_phone'), + 'consent_req' => $this->t('profile.sms.consent_required'), + 'code_sent' => $this->t('profile.sms.code_sent'), + 'verified' => $this->t('profile.sms.verified'), + 'removed' => $this->t('profile.sms.removed'), + 'net_error' => $this->t('profile.net_error'), + ], +]; +?> +
+
t('profile.sms.title') ?>
+
+

t('profile.sms.intro') ?>

+ +
+ smsForm->getElement('_csrf') ?> + + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

+
+
+ + +
+
+ + + +
+
+
+ + +
+
+
+ + + t('profile.sms.verified_label') ?> +
+
+ + +
+
+

+
+
+
+
+pageScript($this->asset('js/profile.sms.js', 'profile')); ?> diff --git a/modules/sms/Bootstrap.php b/modules/sms/Bootstrap.php index 126a7182..4be1fc93 100644 --- a/modules/sms/Bootstrap.php +++ b/modules/sms/Bootstrap.php @@ -19,6 +19,27 @@ protected function _initSmsAdapters() Tiger_Sms::register('telnyx', 'Tiger_Sms_Adapter_Telnyx'); } + /** + * Honor STOP / START on inbound SMS — the carrier-required opt-out/opt-in that suppresses (or + * re-enables) alerts to a number. Transport is TigerSMS's job; interpreting the keyword is ours, so + * we subscribe to the inbound event and record it against the consent ledger (Tiger_Model_SmsConsent). + * HELP is left to the carrier's canned auto-reply (and our help message copy) — no state change. + */ + protected function _initSmsInboundConsent() + { + if (!class_exists('Tiger_Sms') || !class_exists('Tiger_Model_SmsConsent')) { return; } + Tiger_Sms::onInbound(function (array $m) { + $from = (string) ($m['from'] ?? ''); + if ($from === '') { return; } + $body = strtoupper(trim((string) ($m['body'] ?? ''))); + $stop = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT', 'OPTOUT']; + $start = ['START', 'YES', 'UNSTOP', 'OPTIN']; + $consent = new Tiger_Model_SmsConsent(); + if (in_array($body, $stop, true)) { $consent->optOutByPhone($from); } + elseif (in_array($body, $start, true)) { $consent->optInByPhone($from); } + }); + } + /** Add the SMS settings page to the shared admin Settings tree (ACL-filtered, no core edit). */ protected function _initSmsSettings() { diff --git a/tests/Integration/Model/SmsConsentTest.php b/tests/Integration/Model/SmsConsentTest.php new file mode 100644 index 00000000..c8b42a28 --- /dev/null +++ b/tests/Integration/Model/SmsConsentTest.php @@ -0,0 +1,97 @@ +userId = (new Tiger_Model_User())->insert(['email' => 'consent@w376.test', 'status' => 'active']); + } + + #[Test] + public function record_writes_the_consent_with_scopes_and_audit_fields(): void + { + $model = new Tiger_Model_SmsConsent(); + $id = $model->record( + $this->userId, + $this->phone, + [Tiger_Model_SmsConsent::SCOPE_TWOFA, Tiger_Model_SmsConsent::SCOPE_ALERTS], + 'I agree to texts.', + '203.0.113.7' + ); + $this->assertNotSame('', $id); + + $row = $model->forUser($this->userId); + $this->assertNotNull($row); + $this->assertSame($this->phone, (string) $row->phone); + $this->assertSame('twofa,alerts', (string) $row->scopes); + $this->assertSame('203.0.113.7', (string) $row->consent_ip); + $this->assertNotNull($row->consent_at); + $this->assertNull($row->opt_out_at); + } + + #[Test] + public function re_consent_revives_the_same_row_rather_than_duplicating(): void + { + $model = new Tiger_Model_SmsConsent(); + $first = $model->record($this->userId, $this->phone, [Tiger_Model_SmsConsent::SCOPE_TWOFA], 'v1', null); + $model->removeForUser($this->userId); // soft-delete + $second = $model->record($this->userId, $this->phone, [Tiger_Model_SmsConsent::SCOPE_ALERTS], 'v2', null); + + $this->assertSame($first, $second, 'the one row is revived + overwritten (the (user_id) unique holds)'); + $row = $model->forUser($this->userId); + $this->assertSame('alerts', (string) $row->scopes); + $this->assertSame(0, (int) $row->deleted); + } + + #[Test] + public function stop_suppresses_and_start_restores_by_phone(): void + { + $model = new Tiger_Model_SmsConsent(); + $model->record($this->userId, $this->phone, [Tiger_Model_SmsConsent::SCOPE_ALERTS], 't', null); + $this->assertFalse($model->isSuppressed($this->phone)); + + $this->assertSame(1, $model->optOutByPhone($this->phone)); + $this->assertTrue($model->isSuppressed($this->phone), 'STOP suppresses the number'); + + $this->assertSame(1, $model->optInByPhone($this->phone)); + $this->assertFalse($model->isSuppressed($this->phone), 'START lifts the suppression'); + } + + #[Test] + public function a_number_with_no_record_is_not_suppressed(): void + { + $this->assertFalse((new Tiger_Model_SmsConsent())->isSuppressed('+14075550123')); + } + + #[Test] + public function remove_soft_deletes_the_record(): void + { + $model = new Tiger_Model_SmsConsent(); + $model->record($this->userId, $this->phone, [Tiger_Model_SmsConsent::SCOPE_TWOFA], 't', null); + $this->assertTrue($model->removeForUser($this->userId)); + $this->assertNull($model->forUser($this->userId)); + $this->assertFalse($model->removeForUser($this->userId), 'removing again is a no-op'); + } +} diff --git a/tests/Integration/Profile/SmsServiceTest.php b/tests/Integration/Profile/SmsServiceTest.php new file mode 100644 index 00000000..78eadd2d --- /dev/null +++ b/tests/Integration/Profile/SmsServiceTest.php @@ -0,0 +1,217 @@ + */ + public static array $sent = []; + public function isConfigured(): bool { return true; } + public function send(string $to, string $message, string $from, array $opts = []): array + { + self::$sent[] = ['to' => $to, 'message' => $message]; + return ['ok' => true, 'id' => 'cap-' . count(self::$sent)]; + } +} + +/** + * Profile_Service_Sms — the self-service Phone & SMS opt-in (/api), TIGER-376. + * + * Covers the double opt-in: consent is mandatory before a code is sent; requestCode issues an sms_otp + * challenge + texts a code and creates a pending factor; verifyCode marks the number verified and writes + * an auditable consent record; a number verified on one account can't be enrolled on another (global + * uniqueness); a wrong code is refused; remove unlinks the number and the consent; a guest is refused. + */ +#[CoversClass(Profile_Service_Sms::class)] +final class SmsServiceTest extends IntegrationTestCase +{ + private string $userId; + private string $phone = '+14075550155'; + private $prevConfig = null; + + protected function setUp(): void + { + parent::setUp(); + Zend_Registry::set('tiger.auth.stateless', true); + + CapturingSmsAdapter::$sent = []; + Tiger_Sms::register('capture', CapturingSmsAdapter::class); + + // Merge-swap the config so the facade routes to the capturing provider (preserves the rest). + $this->prevConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + $arr = $this->prevConfig ? $this->prevConfig->toArray() : []; + $arr['tiger']['sms']['provider'] = 'capture'; + $arr['tiger']['sms']['from'] = '+15550001111'; + Zend_Registry::set('Zend_Config', new Zend_Config($arr, true)); + + $this->userId = (new Tiger_Model_User())->insert(['email' => 'sms@w376.test', 'status' => 'active']); + $this->login($this->userId, 'org-sms', 'user'); + } + + protected function tearDown(): void + { + if ($this->prevConfig !== null) { Zend_Registry::set('Zend_Config', $this->prevConfig); } + $reg = Zend_Registry::getInstance(); + if ($reg->offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); } + parent::tearDown(); + } + + private function call(string $action, array $params = []): object + { + return (new Profile_Service_Sms(['action' => $action] + $params))->getResponse(); + } + + private function lastCode(): string + { + $msg = CapturingSmsAdapter::$sent ? end(CapturingSmsAdapter::$sent)['message'] : ''; + return preg_match('/(\d{6})/', $msg, $m) ? $m[1] : ''; + } + + private function enroll(): string + { + $this->assertSame(1, (int) $this->call('requestCode', ['phone' => $this->phone, 'consent' => '1'])->result); + $code = $this->lastCode(); + $this->assertMatchesRegularExpression('/^\d{6}$/', $code); + $this->assertSame(1, (int) $this->call('verifyCode', ['phone' => $this->phone, 'code' => $code])->result); + return $code; + } + + // ----- gate --------------------------------------------------------------- + + #[Test] + public function a_guest_is_refused(): void + { + $this->logout(); + $this->assertSame(0, (int) $this->call('requestCode', ['phone' => $this->phone, 'consent' => '1'])->result); + $this->assertSame(0, (int) $this->call('status')->result); + } + + // ----- consent + request -------------------------------------------------- + + #[Test] + public function a_code_is_not_sent_without_consent(): void + { + $res = $this->call('requestCode', ['phone' => $this->phone]); // no consent + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('consent_required', json_encode($res->messages)); + $this->assertCount(0, CapturingSmsAdapter::$sent, 'nothing is texted without consent'); + } + + #[Test] + public function a_bad_number_is_refused(): void + { + $res = $this->call('requestCode', ['phone' => 'nope', 'consent' => '1']); + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('bad_phone', json_encode($res->messages)); + } + + #[Test] + public function request_sends_a_code_and_creates_a_pending_unverified_factor(): void + { + $res = $this->call('requestCode', ['phone' => $this->phone, 'consent' => '1']); + $this->assertSame(1, (int) $res->result); + $this->assertStringContainsString('code_sent', json_encode($res->messages)); + $this->assertCount(1, CapturingSmsAdapter::$sent); + $this->assertSame($this->phone, CapturingSmsAdapter::$sent[0]['to']); + + $factor = (new Tiger_Model_UserCredential())->factor($this->userId, Tiger_Model_UserCredential::TYPE_SMS, $this->phone); + $this->assertNotNull($factor); + $this->assertNull($factor->verified_at, 'the factor stays pending until the code is confirmed'); + $this->assertTrue((bool) $res->data['sms']['pending']); + } + + // ----- verify ------------------------------------------------------------- + + #[Test] + public function verify_marks_the_number_verified_and_records_consent(): void + { + $this->call('requestCode', ['phone' => $this->phone, 'consent' => '1']); + $res = $this->call('verifyCode', ['phone' => $this->phone, 'code' => $this->lastCode()]); + + $this->assertSame(1, (int) $res->result); + $this->assertStringContainsString('sms.verified', json_encode($res->messages)); + $this->assertTrue((bool) $res->data['sms']['verified']); + + $factor = (new Tiger_Model_UserCredential())->findVerifiedByIdentifier(Tiger_Model_UserCredential::TYPE_SMS, $this->phone); + $this->assertNotNull($factor); + $this->assertSame($this->userId, (string) $factor->user_id); + + $consent = (new Tiger_Model_SmsConsent())->forUser($this->userId); + $this->assertNotNull($consent, 'an auditable consent record is written on verify'); + $this->assertSame('twofa,alerts', (string) $consent->scopes); + $this->assertNotNull($consent->consent_at); + } + + #[Test] + public function a_wrong_code_is_refused(): void + { + $this->call('requestCode', ['phone' => $this->phone, 'consent' => '1']); + $wrong = $this->lastCode() === '000000' ? '111111' : '000000'; + $res = $this->call('verifyCode', ['phone' => $this->phone, 'code' => $wrong]); + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('bad_code', json_encode($res->messages)); + } + + // ----- global uniqueness (login-by-phone needs one number → one user) ----- + + #[Test] + public function a_number_verified_on_another_account_cannot_be_enrolled(): void + { + $this->enroll(); // this user verifies $this->phone + + // A second user tries the same number. + $otherId = (new Tiger_Model_User())->insert(['email' => 'other@w376.test', 'status' => 'active']); + $this->login($otherId, 'org-sms', 'user'); + CapturingSmsAdapter::$sent = []; + + $res = $this->call('requestCode', ['phone' => $this->phone, 'consent' => '1']); + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('in_use', json_encode($res->messages)); + $this->assertCount(0, CapturingSmsAdapter::$sent, 'no code is sent for a taken number'); + } + + // ----- remove ------------------------------------------------------------- + + #[Test] + public function remove_unlinks_the_number_and_the_consent(): void + { + $this->enroll(); + $res = $this->call('remove'); + $this->assertSame(1, (int) $res->result); + $this->assertFalse((bool) $res->data['sms']['verified']); + + $this->assertNull((new Tiger_Model_UserCredential())->findVerifiedByIdentifier(Tiger_Model_UserCredential::TYPE_SMS, $this->phone)); + $this->assertNull((new Tiger_Model_SmsConsent())->forUser($this->userId)); + } + + #[Test] + public function removing_frees_the_number_for_another_account(): void + { + $this->enroll(); + $this->call('remove'); + + // Now a different user can enroll it. + $otherId = (new Tiger_Model_User())->insert(['email' => 'reuse@w376.test', 'status' => 'active']); + $this->login($otherId, 'org-sms', 'user'); + CapturingSmsAdapter::$sent = []; + $this->assertSame(1, (int) $this->call('requestCode', ['phone' => $this->phone, 'consent' => '1'])->result); + $this->assertSame(1, (int) $this->call('verifyCode', ['phone' => $this->phone, 'code' => $this->lastCode()])->result); + $owner = (new Tiger_Model_UserCredential())->findVerifiedByIdentifier(Tiger_Model_UserCredential::TYPE_SMS, $this->phone); + $this->assertSame($otherId, (string) $owner->user_id); + } +} diff --git a/tests/Integration/Service/AuthenticationSmsLoginTest.php b/tests/Integration/Service/AuthenticationSmsLoginTest.php new file mode 100644 index 00000000..14d12290 --- /dev/null +++ b/tests/Integration/Service/AuthenticationSmsLoginTest.php @@ -0,0 +1,92 @@ +auth = new Tiger_Service_Authentication(); + } + + /** Create an active user with a VERIFIED sms factor for $e164; returns the user_id. */ + private function userWithVerifiedPhone(string $email, string $e164): string + { + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'active']); + $cred = new Tiger_Model_UserCredential(); + $cid = $cred->addSms($uid, $e164); + $cred->markVerified($cid); + return $uid; + } + + #[Test] + public function request_issues_a_code_for_a_verified_number(): void + { + $uid = $this->userWithVerifiedPhone('p1@w376.test', '+14075550201'); + $this->auth->requestLoginCodeSms('(407) 555-0201'); // any form → E.164 normalized + $this->assertSame(1, (new Tiger_Model_AuthChallenge())->countRecent($uid, 'sms_otp', 3600)); + } + + #[Test] + public function request_is_silent_for_an_unknown_number(): void + { + $uid = $this->userWithVerifiedPhone('p2@w376.test', '+14075550202'); + $this->auth->requestLoginCodeSms('+14075559999'); // not enrolled anywhere + $this->assertSame(0, (new Tiger_Model_AuthChallenge())->countRecent($uid, 'sms_otp', 3600)); + } + + #[Test] + public function request_is_silent_for_a_suppressed_number(): void + { + $phone = '+14075550203'; + $uid = $this->userWithVerifiedPhone('p3@w376.test', $phone); + $consent = new Tiger_Model_SmsConsent(); + $consent->record($uid, $phone, [Tiger_Model_SmsConsent::SCOPE_TWOFA], 't', null); + $consent->optOutByPhone($phone); // the number replied STOP + + $this->auth->requestLoginCodeSms($phone); + $this->assertSame(0, (new Tiger_Model_AuthChallenge())->countRecent($uid, 'sms_otp', 3600), 'a STOP number is never texted a login code'); + } + + #[Test] + public function verify_signs_in_with_the_right_code_and_rejects_a_wrong_one(): void + { + $phone = '+14075550204'; + $uid = $this->userWithVerifiedPhone('p4@w376.test', $phone); + (new Tiger_Model_AuthChallenge())->issue($uid, 'sms_otp', '424242', 600); // known code (seeded; real one is hashed) + + $this->assertFalse($this->auth->verifyLoginCodeSms($phone, '000000'), 'a wrong code fails'); + + $identity = $this->auth->verifyLoginCodeSms($phone, '424242'); + $this->assertIsObject($identity, 'the right code signs in by phone'); + $this->assertSame($uid, $identity->user_id); + } + + #[Test] + public function verify_fails_for_a_number_with_no_verified_factor(): void + { + $this->assertFalse($this->auth->verifyLoginCodeSms('+14075550205', '424242')); + } +} diff --git a/tests/Unit/View/AssetTest.php b/tests/Unit/View/AssetTest.php index fa5d146f..f6ceb109 100644 --- a/tests/Unit/View/AssetTest.php +++ b/tests/Unit/View/AssetTest.php @@ -34,6 +34,10 @@ public static function setUpBeforeClass(): void @mkdir(self::$docroot . '/_theme', 0777, true); file_put_contents(self::$docroot . '/_theme/app.css', 'body{}'); touch(self::$docroot . '/_theme/app.css', 1713900000); + // A published module asset (public/_modules//…) for the module-form tests. + @mkdir(self::$docroot . '/_modules/sms/js', 0777, true); + file_put_contents(self::$docroot . '/_modules/sms/js/sms.settings.js', '/* */'); + touch(self::$docroot . '/_modules/sms/js/sms.settings.js', 1713900000); if (!defined('PUBLIC_PATH')) { define('PUBLIC_PATH', self::$docroot); } @@ -130,4 +134,48 @@ public function the_cache_bust_flag_explicitly_on_still_versions(): void $this->setConfig(['tiger' => ['assets' => ['cache_bust' => '1']]]); $this->assertSame('/_theme/app.css?v=1713900000', $this->helper()->asset('/_theme/app.css')); } + + // ----- module form: asset('js/x.js', 'slug') -> /_modules/slug/js/x.js ----------------------- + + #[Test] + public function the_module_form_resolves_to_the_published_module_assets_dir(): void + { + // The intuitive "name the module" call — the bug this guards: before, a bare relative path was + // passed straight through and the JS 404'd silently. + $this->assertSame( + '/_modules/sms/js/sms.settings.js?v=1713900000', + $this->helper()->asset('js/sms.settings.js', 'sms') + ); + } + + #[Test] + public function the_module_form_tolerates_a_leading_slash_on_the_path(): void + { + $this->assertSame( + '/_modules/sms/js/sms.settings.js?v=1713900000', + $this->helper()->asset('/js/sms.settings.js', 'sms') + ); + } + + #[Test] + public function the_module_slug_is_sanitized(): void + { + // Junk in the slug can't escape the _modules dir (path traversal / separators are stripped). + $this->assertSame( + '/_modules/sms/js/sms.settings.js?v=1713900000', + $this->helper()->asset('js/sms.settings.js', '../sms/') + ); + } + + #[Test] + public function a_missing_module_asset_passes_through_as_the_resolved_path(): void + { + $this->assertSame('/_modules/sms/js/nope.js', $this->helper()->asset('js/nope.js', 'sms')); + } + + #[Test] + public function an_empty_module_arg_is_ignored(): void + { + $this->assertSame('/_theme/app.css?v=1713900000', $this->helper()->asset('/_theme/app.css', '')); + } } From 447ada15bb32f655e2713d1f9ec4e80b03ee5f2a Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 23 Oct 2026 13:29:51 -0400 Subject: [PATCH 2/2] test: use Zend array-backed session mode in AuthenticationSmsLoginTest _finishLogin() regenerates the session id (fixation guard); without an active PHP session that emits a warning, which the testing env treats as a failure. Mirror AuthenticationTest's setUp (Zend_Session::$_unitTestEnabled = true + a real auth storage) so the passwordless-by-phone login test runs the production session path cleanly. Passed locally only because a session happened to be active; the full CI suite ran it without one. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- .../Service/AuthenticationSmsLoginTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Integration/Service/AuthenticationSmsLoginTest.php b/tests/Integration/Service/AuthenticationSmsLoginTest.php index 14d12290..1583bfce 100644 --- a/tests/Integration/Service/AuthenticationSmsLoginTest.php +++ b/tests/Integration/Service/AuthenticationSmsLoginTest.php @@ -12,6 +12,9 @@ use Tiger_Model_User; use Tiger_Model_UserCredential; use Tiger_Service_Authentication; +use Zend_Auth; +use Zend_Auth_Storage_Session; +use Zend_Session; /** * Tiger_Service_Authentication — passwordless login BY PHONE (sms_otp), TIGER-376. @@ -24,13 +27,29 @@ final class AuthenticationSmsLoginTest extends IntegrationTestCase { private Tiger_Service_Authentication $auth; + private $priorUnitTestMode; protected function setUp(): void { parent::setUp(); + // _finishLogin regenerates the session id (fixation guard); use Zend's array-backed unit-test + // session mode so that runs without an active PHP session (else a warning fails the suite). + $this->priorUnitTestMode = Zend_Session::$_unitTestEnabled; + Zend_Session::$_unitTestEnabled = true; + $_SESSION = []; + Zend_Auth::getInstance()->setStorage(new Zend_Auth_Storage_Session()); + Zend_Auth::getInstance()->clearIdentity(); $this->auth = new Tiger_Service_Authentication(); } + protected function tearDown(): void + { + Zend_Auth::getInstance()->clearIdentity(); + $_SESSION = []; + Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode; + parent::tearDown(); + } + /** Create an active user with a VERIFIED sms factor for $e164; returns the user_id. */ private function userWithVerifiedPhone(string $email, string $e164): string {