From ac7ed130ee7ecf008f270a950bf70b190947fa74 Mon Sep 17 00:00:00 2001 From: Yuriy Bakhtin Date: Mon, 1 Jun 2026 12:50:44 +0300 Subject: [PATCH 1/9] Update for HumHub 1.19 --- docs/CHANGELOG.md | 4 ++++ module.json | 4 ++-- tests/codeception/acceptance/TwofaCest.php | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0f8b849..3049050 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,10 @@ Changelog ========= +1.3.0 (Unreleased) +------------------ +- Enh #111: Update for HumHub 1.19 + 1.2.2 (Unreleased) ------------------ - Enh: Automated code refactoring for HumHub 1.18.0-beta.6 using Rector diff --git a/module.json b/module.json index dfc3e43..8b1fe04 100644 --- a/module.json +++ b/module.json @@ -14,8 +14,8 @@ "resources/screenshot3.png", "resources/screenshot4.png" ], - "version": "1.2.2", + "version": "1.3.0", "humhub": { - "minVersion": "1.18.0-beta.6" + "minVersion": "1.19" } } \ No newline at end of file diff --git a/tests/codeception/acceptance/TwofaCest.php b/tests/codeception/acceptance/TwofaCest.php index 58be43f..6a45ac6 100644 --- a/tests/codeception/acceptance/TwofaCest.php +++ b/tests/codeception/acceptance/TwofaCest.php @@ -65,7 +65,7 @@ public function testManagerCanResetUserTwoFactorAuthentication(AcceptanceTester $I->resetCookie('PHPSESSID'); $I->executeJS('window.localStorage.clear(); window.sessionStorage.clear();'); $I->amOnPage('/user/auth/login'); - $I->waitForText('Please sign in'); + $I->waitForText('Sign In'); $I->amUser2(); $I->amOnPage('/admin/user/list?UserSearch%5BfreeText%5D=User1'); From 4580d5ccb6e4239cf8eaa4d314d205f0c7cf741a Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy Date: Fri, 5 Jun 2026 00:26:45 +0200 Subject: [PATCH 2/9] Release 1.3.0 --- docs/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3049050..87c2022 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,8 +1,8 @@ Changelog ========= -1.3.0 (Unreleased) ------------------- +1.3.0 (June 5, 2026) +-------------------- - Enh #111: Update for HumHub 1.19 1.2.2 (Unreleased) From a1ec0a57c364bd7bd36526cb2fa8f6dc9ec65edf Mon Sep 17 00:00:00 2001 From: HumHub Translations Date: Sun, 21 Jun 2026 03:32:53 +0000 Subject: [PATCH 3/9] Enh: Updated Translations (translate.humhub.org) --- messages/nl/base.php | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/messages/nl/base.php b/messages/nl/base.php index 6bed335..464420c 100644 --- a/messages/nl/base.php +++ b/messages/nl/base.php @@ -1,8 +1,8 @@ Confirm two-factor authentication reset' => '', + 'Confirm two-factor authentication reset' => 'Bevestig het opnieuw instellen van de tweefactorauthenticatie', 'Request new code' => 'Vraageen nieuwe code aan', - 'Two-Factor Authentication administration' => '', + 'Two-Factor Authentication administration' => 'Tweefactorauthenticatie beheer', 'Two-Factor Authentication settings' => 'Instellingen voor Twee-factorenauthenticatie', 'Two-factor authentication' => 'Tweefactorauthenticatie', 'A confirmation code hast just been sent to your email address. Please enter the code from the email in order to proceed.' => 'Een bevestigingscode is naar uw e-mailadres gestuurd. Voer de code uit deze e-mail in om verder te gaan.', @@ -17,41 +17,41 @@ 'Default method for the mandatory groups' => 'Standaardmethode voor de verplichte groepen', 'Disable two-factor authentication (not recommended)' => 'Schakel tweefactorauthenticatie uit (niet aanbevolen)', 'Do you really want to request a new code?' => 'Wilt u een nieuwe code aanvragen?', - 'Download recovery codes' => '', - 'Download them now and store them in a safe place. Each code can be used only once.' => '', - 'Each code can be used only once.' => '', + 'Download recovery codes' => 'Herstelcodes downloaden', + 'Download them now and store them in a safe place. Each code can be used only once.' => 'Download ze nu en bewaar ze op een veilige plek. U kunt elke code slechts één keer gebruiken.', + 'Each code can be used only once.' => 'U kunt elke code slechts één keer gebruiken.', 'Email' => 'E-mail', 'Enabled methods' => 'Ingeschakelde methoden', - 'Enabled users' => '', + 'Enabled users' => 'Ingeschakelde gebruikers', 'General' => 'Algemeen', 'Google Authenticator' => 'Google Authenticator', 'Hello {displayName}!' => 'Hallo {displayName}!', - 'Help text' => '', + 'Help text' => 'Helptekst', 'Install an application that implements a time-based one-time password (TOTP) algorithm, such as {googleAuthenticatorLink}, and use it to scan the QR code shown below.' => 'Installeer een applicatie die een op tijd gebaseerd eenmalig wachtwoord (TOTP) -algoritme implementeert, zoals {googleAuthenticatorLink}, en gebruik dit om de onderstaande QR-code te scannen.', 'Leave empty to disable this feature.' => 'Laat leeg om deze functie uit te schakelen.', 'Length of verifying code' => 'Lengte van de verificatiecode', 'List of IPs or subnets to whitelist, currently yours is {0}. Use coma separator to create a list, example: "{0}, 127.0.0.1"' => 'Lijst met IP\'s of subnetten die op de vertrouwde lijst moeten worden gezet, momenteel is de jouwe {0}. Gebruik een kommascheidingsteken om een lijst te maken, bijvoorbeeld: "{0}, 127.0.0.1"', 'Log out' => 'Uitloggen', 'Mandatory for the following groups' => 'Verplicht voor de volgende groepen', - 'Method' => '', - 'No users with configured two-factor authentication found.' => '', + 'Method' => 'Methode', + 'No users with configured two-factor authentication found.' => 'Er zijn geen gebruikers gevonden met geconfigureerde tweefactorauthenticatie.', 'Open the two-factor authentication app on your device to view your authentication code and verify your identity.' => 'Open de tweefactorauthenticatie-app op uw apparaat om uw authenticatiecode te bekijken en uw identiteit te verifiëren.', - 'Optional text shown on the two-factor authentication screen.' => '', + 'Optional text shown on the two-factor authentication screen.' => 'Optionele tekst die wordt weergegeven op het scherm voor tweefactorauthenticatie.', 'Pin code' => 'Pincode', 'Please do not forget to update the code in your authenticator app! If you do not do so, you will not be able to login.' => ' de code in uw authenticator-app bij te werken! Als u dit niet doet, kunt u niet inloggen.', 'Please enter your verifying code.' => 'Voer uw verificatiecode in.', - 'Recovery codes for {appName}' => '', + 'Recovery codes for {appName}' => 'Herstelcodes voor {appName}', 'Remember browser option amount of days' => 'Onthoud browseroptie aantal dagen', 'Remember this browser for {0} days' => 'Onthoud deze browser voor {0} dagen', 'Request new code' => 'Vraag een nieuwe code aan', - 'Reset' => 'Reset', - 'Reset two-factor authentication' => '', + 'Reset' => 'Opnieuw instellen', + 'Reset two-factor authentication' => 'Tweefactorauthenticatie opnieuw instellen', 'Secret:' => 'Geheimcode:', 'TTL of verifying code in seconds' => 'Tijdsduur voor het verifiëren van de code in seconden', - 'These recovery codes are shown only once.' => '', - 'This list shows users who currently have two-factor authentication configured.' => '', + 'These recovery codes are shown only once.' => 'Deze herstelcodes worden slechts één keer weergegeven.', + 'This list shows users who currently have two-factor authentication configured.' => 'Deze lijst toont gebruikers die momenteel tweefactorauthenticatie hebben ingesteld.', 'This module is disabled because no drivers are selected, however users from the enforced groups always fallback to {defaultDriverName} driver by default.' => 'Deze module is uitgeschakeld omdat er geen stuurprogramma\'s zijn geselecteerd. Maar gebruikers uit de verplichte groepen vallen terug op het stuurprogramma {defaultDriverName}.', - 'This will remove the current two-factor authentication setup for this user. They will need to configure it again on the next login.' => '', + 'This will remove the current two-factor authentication setup for this user. They will need to configure it again on the next login.' => 'Hierdoor wordt de huidige tweefactorauthenticatie voor deze gebruiker verwijderd. De gebruiker moet deze bij de volgende aanmelding opnieuw instellen.', 'Time based: Yes' => 'Op tijd gebaseerd: ja', 'Time-based one-time passwords (e.g. Google Authenticator)' => 'Op tijd gebaseerde eenmalige wachtwoorden (bijvoorbeeld Google Authenticator)', 'To connect the app manually, provide the following details to the TOTP app (e.g. Google Authenticator).' => 'Om de app handmatig te verbinden, geeft u de volgende details op aan de TOTP-app (bijvoorbeeld Google Authenticator).', @@ -59,14 +59,14 @@ 'Two-Factor Authentication' => 'Twee-factorenauthenticatie', 'Two-factor authentication (2FA) provides an additional level of security for your account. Once enabled, you will be prompted to enter a code in addition to entering your username and password.' => 'Tweefactorauthenticatie (2FA) biedt een extra beveiligingsniveau voor uw account. Eenmaal ingeschakeld, wordt u gevraagd om naast uw gebruikersnaam en wachtwoord ook een code in te voeren.', 'Two-factor authentication code is expired. Please try again.' => 'De tweefactorauthenticatiecode is verlopen. Probeer het opnieuw.', - 'Two-factor authentication has been reset for this user.' => '', + 'Two-factor authentication has been reset for this user.' => 'De tweefactorauthenticatie is voor deze gebruiker opnieuw ingesteld.', 'User' => 'Gebruiker', - 'User: {username}' => '', + 'User: {username}' => 'Gebruiker: {username}', 'Users' => 'Gebruikers', 'Verify' => 'Verifiëren', 'Verifying code is not valid!' => 'De verificatiecode is niet geldig!', - 'You can also enter one of your recovery codes if you lost access to your authenticator app.' => '', - 'You signed in with a recovery code. Please generate new recovery codes or reconfigure your authenticator app.' => '', + 'You can also enter one of your recovery codes if you lost access to your authenticator app.' => 'U kunt ook een van uw herstelcodes invoeren als u geen toegang meer hebt tot uw authenticatie-app.', + 'You signed in with a recovery code. Please generate new recovery codes or reconfigure your authenticator app.' => 'U hebt ingelogd met een herstelcode. Genereer nieuwe herstelcodes of configureer uw authenticatie-app opnieuw.', 'Your account is secured by a two-factor authentication system. Please use the following code to proceed.' => 'Uw account is beveiligd met een tweefactorauthenticatiesysteem. Gebruik de volgende code om door te gaan.', 'Your login verification code' => 'Uw inlogverificatiecode', ]; From d4e90fa80f6566b86f756658788940e9055fbb50 Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy Date: Tue, 14 Jul 2026 12:56:03 +0200 Subject: [PATCH 4/9] Migrate request interception to the core user gate system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The former Controller::EVENT_BEFORE_ACTION handler intercepted every request itself, maintained its own exclusion list and could end up in redirect loops with other intercepting modules (humhub-internal#1261). TwofaGate (SORT_SECOND_FACTOR) now declares the check flow to the core gate dispatcher (requires humhub/humhub#8291, core 1.19): deterministic ordering towards password change and terms gates, user/auth stays reachable while verification is pending, AJAX requests receive 401 + JSON with X-Redirect and API requests 403 + JSON instead of an HTML redirect. TwofaHelper::isVerifyingRequired() is split into the side-effect free isVerificationPending() and sendCodeIfNeeded() — the code is delivered at interception time via TwofaGate::onIntercept() instead of as a side effect of a check, and a failed delivery no longer skips the verification (fail-closed). The unused BeforeCheck event, Module::isTwofaCheckUrl() and the doNotInterceptActionIds usage are removed. --- Events.php | 39 +++---- Module.php | 8 -- components/TwofaGate.php | 106 ++++++++++++++++++ config.php | 2 + controllers/CheckController.php | 11 +- docs/CHANGELOG.md | 7 ++ events/BeforeCheck.php | 15 --- helpers/TwofaHelper.php | 28 +++-- module.json | 2 +- .../codeception/functional/TwofaGateCest.php | 62 ++++++++++ tests/codeception/unit/TwofaTest.php | 21 +++- 11 files changed, 237 insertions(+), 64 deletions(-) create mode 100644 components/TwofaGate.php delete mode 100644 events/BeforeCheck.php create mode 100644 tests/codeception/functional/TwofaGateCest.php diff --git a/Events.php b/Events.php index a83c2d4..64fecba 100644 --- a/Events.php +++ b/Events.php @@ -8,11 +8,12 @@ namespace humhub\modules\twofa; +use humhub\components\gates\GateInitEvent; use humhub\helpers\ControllerHelper; use humhub\modules\admin\controllers\UserController as AdminUserController; use humhub\modules\admin\grid\UserActionColumn; use humhub\modules\admin\permissions\ManageUsers; -use humhub\modules\twofa\events\BeforeCheck; +use humhub\modules\twofa\components\TwofaGate; use humhub\modules\twofa\helpers\TwofaHelper; use humhub\modules\twofa\helpers\TwofaUrl; use humhub\modules\ui\menu\MenuLink; @@ -50,38 +51,30 @@ public static function registerAutoloader() } /** - * Check if current User has been verified by 2fa if it is required + * Registers the user gates of this module (see core docs/develop/user-gates.md). + * The gate replaces the former request interception of this handler. * - * @param $event - * @return false|\yii\console\Response|\yii\web\Response + * @since 1.4 */ - public static function onBeforeAction($event) + public static function onGateInit(GateInitEvent $event): void { - if (Yii::$app->user->mustChangePassword()) { - return false; - } + $event->manager->register(new TwofaGate()); + } + /** + * Remembers the originating user of an admin "Impersonate" action, so the 2FA + * session state can be restored correctly. + * + * @param $event + */ + public static function onBeforeAction($event): void + { /** @var Controller $controller */ $controller = $event->sender; if (self::isImpersonateAction($controller)) { Yii::$app->session->set('twofa.switchedUserId', Yii::$app->user->id); } - - if ( - $controller->module->id === 'fcm-push' - && $controller->id === 'token' - && $controller->action->id === 'update' - ) { - return false; - } - - $beforeVerifying = new BeforeCheck(); - Yii::$app->trigger($beforeVerifying->name, $beforeVerifying); - - if (!$beforeVerifying->handled && TwofaHelper::isVerifyingRequired() && !Yii::$app->getModule('twofa')->isTwofaCheckUrl()) { - return Yii::$app->response->redirect(TwofaUrl::toCheck()); - } } /** diff --git a/Module.php b/Module.php index 658a21c..b170d6b 100644 --- a/Module.php +++ b/Module.php @@ -44,14 +44,6 @@ public function getConfigUrl() return TwofaUrl::toConfig(); } - /** - * @return bool Check if current page is already URL of 2fa - */ - public function isTwofaCheckUrl() - { - return Yii::$app->getRequest()->getUrl() === TwofaUrl::toCheck(); - } - /** * Get available drivers options for the 2fa module settings * diff --git a/components/TwofaGate.php b/components/TwofaGate.php new file mode 100644 index 0000000..fda27f0 --- /dev/null +++ b/components/TwofaGate.php @@ -0,0 +1,106 @@ +user->isGuest && TwofaHelper::isVerificationPending(); + } + + /** + * @inheritdoc + */ + public function getRoute(): array + { + return [TwofaUrl::ROUTE_CHECK]; + } + + /** + * Login/logout must stay reachable while verification is pending; the mobile app + * updates its push token in the background. Each entry is a deliberate, + * security-reviewed exemption from the second factor. + * + * @inheritdoc + */ + public function getAllowedRoutes(): array + { + return ['user/auth', 'fcm-push/token/update']; + } + + /** + * A stolen password must not give access through any channel, so the gate also + * applies to API requests (answered with 403). Sessions authenticated per request + * (e.g. REST tokens) can therefore not be used by users with active 2FA until the + * verification is enforced at credential issuance. + * + * @inheritdoc + */ + public function appliesTo(RequestClass $requestClass): bool + { + return true; + } + + /** + * Whether 2FA is required follows group membership and driver settings, which can + * change at any time and must take effect instantly — so the gate is evaluated on + * every request. + * + * @inheritdoc + */ + public function isCacheable(): bool + { + return false; + } + + /** + * Lazily delivers the verification code (e.g. by mail) when the user is + * intercepted and no valid code is pending yet. + * + * @inheritdoc + */ + public function onIntercept(): void + { + TwofaHelper::sendCodeIfNeeded(); + } +} diff --git a/config.php b/config.php index 4045488..62f91ec 100644 --- a/config.php +++ b/config.php @@ -7,6 +7,7 @@ */ use humhub\components\Application; +use humhub\components\gates\GateManager; use humhub\modules\admin\grid\UserActionColumn; use humhub\modules\twofa\Events; use humhub\modules\user\controllers\AuthController; @@ -19,6 +20,7 @@ 'namespace' => 'humhub\modules\twofa', 'events' => [ [Application::class, Application::EVENT_BEFORE_REQUEST, [Events::class, 'onBeforeRequest']], + [GateManager::class, GateManager::EVENT_INIT_GATES, [Events::class, 'onGateInit']], [AuthController::class, AuthController::EVENT_AFTER_LOGIN, [Events::class, 'onAfterLogin']], [Controller::class, Controller::EVENT_BEFORE_ACTION, [Events::class, 'onBeforeAction']], [Controller::class, Controller::EVENT_AFTER_ACTION, [Events::class, 'onAfterAction']], diff --git a/controllers/CheckController.php b/controllers/CheckController.php index d52b1bd..61fee13 100644 --- a/controllers/CheckController.php +++ b/controllers/CheckController.php @@ -19,11 +19,6 @@ */ class CheckController extends Controller { - /** - * @inheritdoc - */ - protected $doNotInterceptActionIds = ['*']; - /** * @inheritdoc */ @@ -38,10 +33,14 @@ public function actionIndex() { $redirectUrl = Yii::$app->user->getReturnUrl(); - if (!TwofaHelper::isVerifyingRequired()) { + if (!TwofaHelper::isVerificationPending()) { return $this->response->redirect($redirectUrl); } + // Ensure a code is on its way even when the page is opened directly + // (interception delivers it via TwofaGate::onIntercept() already) + TwofaHelper::sendCodeIfNeeded(); + if (isset(Yii::$app->getModule('live')->isActive)) { Yii::$app->getModule('live')->isActive = false; } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 87c2022..d3460f7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,13 @@ Changelog ========= +1.4.0 (Unreleased) +------------------ +- Enh: Migrated the request interception to the core user gate system (`TwofaGate`, requires humhub/humhub#8291) — deterministic ordering towards other intercepting modules (password change → 2FA → terms), no more redirect loops, and `user/auth` (login/logout) stays reachable while verification is pending +- Enh: AJAX requests now receive `401` + JSON `{gate, url}` and API requests `403` + JSON while verification is pending, instead of a HTML redirect +- Enh: `TwofaHelper::isVerifyingRequired()` was split into the side-effect free `isVerificationPending()` and `sendCodeIfNeeded()` — the verification code is delivered at interception time (`TwofaGate::onIntercept()`) instead of as a side effect of a check; a failed code delivery no longer skips the verification (fail-closed) +- Chg: Removed the unused `BeforeCheck` event, `Module::isTwofaCheckUrl()` and the `doNotInterceptActionIds` usage + 1.3.0 (June 5, 2026) -------------------- - Enh #111: Update for HumHub 1.19 diff --git a/events/BeforeCheck.php b/events/BeforeCheck.php deleted file mode 100644 index 040f29c..0000000 --- a/events/BeforeCheck.php +++ /dev/null @@ -1,15 +0,0 @@ -canSend()) { - return false; - } + return $driver && $driver->canSend() && !self::isSessionVerified(); + } - if (!self::isPendingVerification()) { - return self::enableVerifying() || self::getCode() !== null; + /** + * Delivers a verification code unless a valid one is already pending. + * + * @return bool whether a valid code is available (already pending or newly sent) + * @since 1.4 + */ + public static function sendCodeIfNeeded(): bool + { + if (self::isPendingVerification() && self::getCode() !== null) { + return true; } - return self::getCode() !== null || self::enableVerifying(); + return self::enableVerifying(); } /** diff --git a/module.json b/module.json index 8b1fe04..9def4ab 100644 --- a/module.json +++ b/module.json @@ -14,7 +14,7 @@ "resources/screenshot3.png", "resources/screenshot4.png" ], - "version": "1.3.0", + "version": "1.4.0", "humhub": { "minVersion": "1.19" } diff --git a/tests/codeception/functional/TwofaGateCest.php b/tests/codeception/functional/TwofaGateCest.php new file mode 100644 index 0000000..2e371a1 --- /dev/null +++ b/tests/codeception/functional/TwofaGateCest.php @@ -0,0 +1,62 @@ +installationState->setInstalled(); + $loginPage = LoginPage::openBy($I); + $loginPage->login('Admin', 'admin&humhub@PASS%worD!'); + $I->see('Two-factor authentication'); + } + + public function testAjaxRequestReceivesGateResponse(FunctionalTester $I) + { + $I->wantTo('ensure that AJAX requests receive a machine-readable gate response while 2FA is pending'); + + $this->loginPendingAdmin($I); + + $I->sendAjaxGetRequest('/index-test.php?r=dashboard%2Fdashboard'); + + $I->seeResponseCodeIs(401); + $I->see('twofa'); + } + + public function testAccountDeleteStaysIntercepted(FunctionalTester $I) + { + $I->wantTo('ensure that account deletion is not reachable while 2FA is pending'); + + $this->loginPendingAdmin($I); + + $I->amOnPage('/index-test.php?r=user%2Faccount%2Fdelete'); + + $I->see('Two-factor authentication'); + } + + public function testLogoutStaysReachableWhilePending(FunctionalTester $I) + { + $I->wantTo('ensure that logout works while 2FA is pending'); + + $this->loginPendingAdmin($I); + + $I->sendAjaxPostRequest('/index-test.php?r=user%2Fauth%2Flogout'); + + $I->amOnRoute('/dashboard/dashboard'); + $I->see('Sign in'); + } +} diff --git a/tests/codeception/unit/TwofaTest.php b/tests/codeception/unit/TwofaTest.php index 96e66f5..dea46c4 100644 --- a/tests/codeception/unit/TwofaTest.php +++ b/tests/codeception/unit/TwofaTest.php @@ -35,7 +35,7 @@ public function testVerifyCode() { $this->becomeUser('Admin'); $this->assertTrue(TwofaHelper::enableVerifying()); - $this->assertTrue(TwofaHelper::isVerifyingRequired()); + $this->assertTrue(TwofaHelper::isVerificationPending()); $this->assertFalse(TwofaHelper::isValidCode('test')); } @@ -46,6 +46,23 @@ public function testDisableVerifying() $this->assertTrue(TwofaHelper::disableVerifying(true)); $this->assertNull(TwofaHelper::getCode()); $this->assertNull(TwofaHelper::getSetting(TwofaHelper::CODE_EXPIRATION_SETTING)); - $this->assertFalse(TwofaHelper::isVerifyingRequired()); + $this->assertFalse(TwofaHelper::isVerificationPending()); + } + + public function testVerificationPendingIsFreeOfSideEffects() + { + // 2FA is enforced for the Admin, but no code has been sent yet: + // the check must report pending without delivering a code itself + $this->becomeUser('Admin'); + $this->assertTrue(TwofaHelper::isVerificationPending()); + $this->assertNull(TwofaHelper::getCode()); + + // sendCodeIfNeeded() delivers once and keeps the pending code afterwards + $this->assertTrue(TwofaHelper::sendCodeIfNeeded()); + $this->assertNotNull(TwofaHelper::getCode()); + + // users without a 2FA requirement are never pending + $this->becomeUser('User1'); + $this->assertFalse(TwofaHelper::isVerificationPending()); } } From b048c1f9833c30fe4992ae100bfa8f475b671ae8 Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy <4736168+luke-@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:44:09 +0200 Subject: [PATCH 5/9] Do not apply the 2FA gate to token-authenticated API requests The verification is a session-based, interactive flow. Applying the gate to stateless REST requests would report "pending" on every call (no session to mark verified), making the API unusable for users with 2FA enabled. API authentication is handled by the rest module, so the gate now only applies to full page and AJAX requests. --- components/TwofaGate.php | 10 +++++----- docs/CHANGELOG.md | 2 +- tests/codeception/functional/TwofaGateCest.php | 13 +++++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/components/TwofaGate.php b/components/TwofaGate.php index fda27f0..85bbaf4 100644 --- a/components/TwofaGate.php +++ b/components/TwofaGate.php @@ -69,16 +69,16 @@ public function getAllowedRoutes(): array } /** - * A stolen password must not give access through any channel, so the gate also - * applies to API requests (answered with 403). Sessions authenticated per request - * (e.g. REST tokens) can therefore not be used by users with active 2FA until the - * verification is enforced at credential issuance. + * The verification is a session-based, interactive flow, so the gate does not apply + * to token-authenticated API requests: a REST token is issued through its own flow + * and stands on its own, and per-request gating a stateless request would only ever + * report "pending". API authentication is handled by the REST module. * * @inheritdoc */ public function appliesTo(RequestClass $requestClass): bool { - return true; + return $requestClass !== RequestClass::Api; } /** diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 920553a..5671dc6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,7 +4,7 @@ Changelog 1.4.0 (Unreleased) ------------------ - Enh: Migrated the request interception to the core user gate system (`TwofaGate`, requires humhub/humhub#8291) — deterministic ordering towards other intercepting modules (password change → 2FA → terms), no more redirect loops, and `user/auth` (login/logout) stays reachable while verification is pending -- Enh: AJAX requests now receive `401` + JSON `{gate, url}` and API requests `403` + JSON while verification is pending, instead of a HTML redirect +- Enh: AJAX requests now receive `401` + JSON `{gate, url}` while verification is pending, instead of a HTML redirect; token-authenticated API requests are not intercepted (REST authentication is handled by the `rest` module) - Enh: `TwofaHelper::isVerifyingRequired()` was split into the side-effect free `isVerificationPending()` and `sendCodeIfNeeded()` — the verification code is delivered at interception time (`TwofaGate::onIntercept()`) instead of as a side effect of a check; a failed code delivery no longer skips the verification (fail-closed) - Chg: Removed the unused `BeforeCheck` event, `Module::isTwofaCheckUrl()` and the `doNotInterceptActionIds` usage diff --git a/tests/codeception/functional/TwofaGateCest.php b/tests/codeception/functional/TwofaGateCest.php index 2e371a1..3296c0c 100644 --- a/tests/codeception/functional/TwofaGateCest.php +++ b/tests/codeception/functional/TwofaGateCest.php @@ -37,6 +37,19 @@ public function testAjaxRequestReceivesGateResponse(FunctionalTester $I) $I->see('twofa'); } + public function testApiRequestIsNotIntercepted(FunctionalTester $I) + { + $I->wantTo('ensure that token-authenticated API requests are not intercepted while 2FA is pending'); + + $this->loginPendingAdmin($I); + + // A REST-style request negotiates JSON and is neither a browser navigation nor XHR + $I->haveHttpHeader('Accept', 'application/json'); + $I->amOnPage('/index-test.php?r=dashboard%2Fdashboard'); + + $I->dontSee('twofa'); + } + public function testAccountDeleteStaysIntercepted(FunctionalTester $I) { $I->wantTo('ensure that account deletion is not reachable while 2FA is pending'); From 1f284ca2395e8e0735b70050a0d77c4e901ae75e Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy <4736168+luke-@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:45:57 +0200 Subject: [PATCH 6/9] Update DEVELOPER.md for the gate based interception The twofa.beforeCheck event was removed in the gate migration; document the TwofaGate model instead, including that token-authenticated API endpoints (REST, CalDAV) are not intercepted. --- docs/DEVELOPER.md | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 56c2a7b..27c656f 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -18,23 +18,18 @@ Default driver `humhub\modules\twofa\Module->defaultDriver` is used for Users fr ```php public $defaultDriver = EmailDriver::class; ``` -## Events +## Interception -### `twofa.beforeCheck` +Since 1.4 the verification is enforced through the core user gate system +(`TwofaGate`, see the core `docs/develop/user-gates.md`) instead of a +`Controller::EVENT_BEFORE_ACTION` handler. The former `twofa.beforeCheck` event +has been removed. -The `twofa.beforeCheck` event is triggered before a Two-Factor Authentication (2FA) check is performed. +The gate applies to full page navigation and AJAX/PJAX requests, but not to +token-authenticated API requests — REST, CalDAV and similar endpoints are +therefore not intercepted and do not need to opt out. Login and logout +(`user/auth`) as well as the mobile push token update stay reachable while +verification is pending. -Other modules can listen to this event and set `$handled = true` to skip the 2FA check. - -This mechanism allows disabling 2FA: - -- Globally for a module via its `beforeAction()` method -- For specific controllers via their `beforeAction()` method -- For specific actions within a controller via conditional logic in `beforeAction()` - -Example: -```php -Yii::$app->on('twofa.beforeCheck', function (Event $event) use ($action) { - $event->handled = $action->controller->id === 'some-controller'; // Will disable 2FA for `some-controller` -}); -``` \ No newline at end of file +There is no longer a per-controller opt-out: the gate intercepts every full +page request of a user with pending verification until the check is completed. \ No newline at end of file From 600059361097e9a8d7218c698a4356a7ed4591ea Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy <4736168+luke-@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:44:54 +0200 Subject: [PATCH 7/9] Test that a pending session cannot escape the gate via a faked Accept header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the test that asserted a session request with Accept: application/json is not intercepted — that was the escapable behavior. With gate applicability now based on the server-side auth context (humhub/humhub#8295), such a session request stays subject to the gate; genuine token/API requests are exempt via enableSession, which a client cannot spoof. --- tests/codeception/functional/TwofaGateCest.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/codeception/functional/TwofaGateCest.php b/tests/codeception/functional/TwofaGateCest.php index 3296c0c..b949e20 100644 --- a/tests/codeception/functional/TwofaGateCest.php +++ b/tests/codeception/functional/TwofaGateCest.php @@ -37,17 +37,20 @@ public function testAjaxRequestReceivesGateResponse(FunctionalTester $I) $I->see('twofa'); } - public function testApiRequestIsNotIntercepted(FunctionalTester $I) + public function testSessionRequestCannotEscapeViaAcceptHeader(FunctionalTester $I) { - $I->wantTo('ensure that token-authenticated API requests are not intercepted while 2FA is pending'); + $I->wantTo('ensure a pending session cannot escape the 2FA check by faking a JSON Accept header'); $this->loginPendingAdmin($I); - // A REST-style request negotiates JSON and is neither a browser navigation nor XHR + // A cookie-authenticated (session) request that fakes a non-HTML Accept header must + // stay subject to the gate — the API exemption only covers stateless token requests, + // which are determined server-side (see core GateFilter::getRequestClass()). $I->haveHttpHeader('Accept', 'application/json'); $I->amOnPage('/index-test.php?r=dashboard%2Fdashboard'); - $I->dontSee('twofa'); + $I->seeResponseCodeIs(403); + $I->see('twofa'); } public function testAccountDeleteStaysIntercepted(FunctionalTester $I) From 4aefb5b0d6211366a02d14e2b9bbfffcd2d59d7e Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy Date: Thu, 16 Jul 2026 19:45:11 +0200 Subject: [PATCH 8/9] Release 1.4.0 --- docs/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c4131dc..cc4de71 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,10 +1,10 @@ Changelog ========= -1.4.0 (Unreleased) ------------------- +1.4.0 (July 16, 2026) +--------------------- - Enh: Migrated the request interception to the core user gate system (`TwofaGate`, requires humhub/humhub#8291) — deterministic ordering towards other intercepting modules (password change → 2FA → terms), no more redirect loops, and `user/auth` (login/logout) stays reachable while verification is pending -- Enh: AJAX requests now receive `401` + JSON `{gate, url}` while verification is pending, instead of a HTML redirect; token-authenticated API requests are not intercepted (REST authentication is handled by the `rest` module) +- Enh: AJAX requests now receive `401` + JSON `{gate, url}` while verification is pending, instead of an HTML redirect; token-authenticated API requests are not intercepted (REST authentication is handled by the `rest` module) - Enh: `TwofaHelper::isVerifyingRequired()` was split into the side-effect free `isVerificationPending()` and `sendCodeIfNeeded()` — the verification code is delivered at interception time (`TwofaGate::onIntercept()`) instead of as a side effect of a check; a failed code delivery no longer skips the verification (fail-closed) - Chg: Removed the unused `BeforeCheck` event, `Module::isTwofaCheckUrl()` and the `doNotInterceptActionIds` usage From 92e351d6cece31e965fcb783cde8664417076e58 Mon Sep 17 00:00:00 2001 From: HumHub Translations Date: Wed, 22 Jul 2026 03:33:15 +0000 Subject: [PATCH 9/9] Enh: Updated Translations (translate.humhub.org) --- messages/de/base.php | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/messages/de/base.php b/messages/de/base.php index ae4e94b..8dcc983 100644 --- a/messages/de/base.php +++ b/messages/de/base.php @@ -1,8 +1,8 @@ Confirm two-factor authentication reset' => '', + 'Confirm two-factor authentication reset' => 'Zwei-Faktor-Authentifizierung zurücksetzen bestätigen', 'Request new code' => 'Neuen Code anfordern', - 'Two-Factor Authentication administration' => '', + 'Two-Factor Authentication administration' => 'Verwaltung der Zwei-Faktor-Authentifizierung', 'Two-Factor Authentication settings' => 'Zwei-Faktor-Authentifizierung Einstellungen', 'Two-factor authentication' => 'Zwei-Faktor-Authentifizierung', 'A confirmation code hast just been sent to your email address. Please enter the code from the email in order to proceed.' => 'Ein Bestätigungscode wurde soeben an Ihre E-Mail-Adresse gesendet. Bitte geben Sie den Code aus der E-Mail ein, um fortzufahren.', @@ -17,41 +17,41 @@ 'Default method for the mandatory groups' => 'Standardmethode für die obligatorischen Gruppen', 'Disable two-factor authentication (not recommended)' => 'Zwei-Faktor-Authentifizierung deaktivieren (nicht empfohlen)', 'Do you really want to request a new code?' => 'Möchtest Du wirklich einen neuen Code anfordern?', - 'Download recovery codes' => '', - 'Download them now and store them in a safe place. Each code can be used only once.' => '', - 'Each code can be used only once.' => '', + 'Download recovery codes' => 'Wiederherstellungscodes herunterladen', + 'Download them now and store them in a safe place. Each code can be used only once.' => 'Lade sie jetzt herunter und bewahre sie an einem sicheren Ort auf. Jeder Code kann nur einmal verwendet werden.', + 'Each code can be used only once.' => 'Jeder Code kann nur einmal verwendet werden.', 'Email' => 'E-Mail', 'Enabled methods' => 'Aktivierte Methoden', - 'Enabled users' => '', + 'Enabled users' => 'Aktivierte Benutzer', 'General' => 'Allgemein', 'Google Authenticator' => 'Google Authenticator', 'Hello {displayName}!' => 'Hallo {displayName}!', - 'Help text' => '', + 'Help text' => 'Hilfetext', 'Install an application that implements a time-based one-time password (TOTP) algorithm, such as {googleAuthenticatorLink}, and use it to scan the QR code shown below.' => 'Installiere eine Anwendung/App, die einen zeitbasierten One-Time-Password-Algorithmus (TOTP) implementiert, z. B. {googleAuthenticatorLink}, und scanne damit den unten gezeigten QR-Code.', 'Leave empty to disable this feature.' => 'Leer lassen um die Funktion zu deaktivieren', 'Length of verifying code' => 'Länge des Verifizierungscodes', 'List of IPs or subnets to whitelist, currently yours is {0}. Use coma separator to create a list, example: "{0}, 127.0.0.1"' => 'Liste von IP Adressen oder Subnetzen für die Whitelist. Deine derzeitige {0}. Nutze Komma getrennte Liste. z.B.: "{0}, 127.0.0.1"', 'Log out' => 'Abmelden', 'Mandatory for the following groups' => 'Obligatorisch für die folgenden Gruppen', - 'Method' => '', - 'No users with configured two-factor authentication found.' => '', + 'Method' => 'Methode', + 'No users with configured two-factor authentication found.' => 'Keine Benutzer mit eingerichteter Zwei-Faktor-Authentifizierung gefunden.', 'Open the two-factor authentication app on your device to view your authentication code and verify your identity.' => 'Öffnen Sie die App zur Zwei-Faktor-Authentifizierung auf Ihrem Gerät, um Ihren Authentifizierungscode anzuzeigen und Ihre Identität zu überprüfen.', - 'Optional text shown on the two-factor authentication screen.' => '', + 'Optional text shown on the two-factor authentication screen.' => 'Optionaler Text, der auf der Seite zur Zwei-Faktor-Authentifizierung angezeigt wird.', 'Pin code' => 'Pin-Code', 'Please do not forget to update the code in your authenticator app! If you do not do so, you will not be able to login.' => 'Bitte vergiss nicht, den Code in Ihrer Authenticator-App zu aktualisieren! Wenn Du dies nicht tust, kannst Du Dich nicht mehr anmelden.', 'Please enter your verifying code.' => 'Bitte geben Sie Ihren Verifizierungscode ein.', - 'Recovery codes for {appName}' => '', + 'Recovery codes for {appName}' => 'Wiederherstellungscodes für {appName}', 'Remember browser option amount of days' => 'Browser Vertrauen Option Tage', 'Remember this browser for {0} days' => 'Diesem Gerät für {0} Tage vertrauen', 'Request new code' => 'Neuen Code anfordern', 'Reset' => 'Zurücksetzen', - 'Reset two-factor authentication' => '', + 'Reset two-factor authentication' => 'Zwei-Faktor-Authentifizierung zurücksetzen', 'Secret:' => 'Secret:', 'TTL of verifying code in seconds' => 'Gültigkeitsdauer (TTL) des Bestätigungscodes in Sekunden', - 'These recovery codes are shown only once.' => '', - 'This list shows users who currently have two-factor authentication configured.' => '', + 'These recovery codes are shown only once.' => 'Diese Wiederherstellungscodes werden nur einmal angezeigt.', + 'This list shows users who currently have two-factor authentication configured.' => 'Diese Liste zeigt Benutzer, für die aktuell eine Zwei-Faktor-Authentifizierung eingerichtet ist.', 'This module is disabled because no drivers are selected, however users from the enforced groups always fallback to {defaultDriverName} driver by default.' => 'Dieses Modul ist deaktiviert, da keine Treiber ausgewählt sind, aber Benutzer aus den erzwungenen Gruppen greifen standardmäßig immer auf den Treiber {defaultDriverName} zurück.', - 'This will remove the current two-factor authentication setup for this user. They will need to configure it again on the next login.' => '', + 'This will remove the current two-factor authentication setup for this user. They will need to configure it again on the next login.' => 'Dadurch wird die aktuelle Einrichtung der Zwei-Faktor-Authentifizierung für diesen Benutzer entfernt. Der Benutzer muss sie bei der nächsten Anmeldung erneut einrichten.', 'Time based: Yes' => 'Zeitbasiert: Ja', 'Time-based one-time passwords (e.g. Google Authenticator)' => 'Zeitbasierte Einmal-Passwörter (z. B. Google Authenticator)', 'To connect the app manually, provide the following details to the TOTP app (e.g. Google Authenticator).' => 'Um die App manuell zu verbinden, gib bitte in der TOTP-App (z. B. Google Authenticator) die folgenden Details an.', @@ -59,14 +59,14 @@ 'Two-Factor Authentication' => 'Zwei-Faktor-Authentifizierung', 'Two-factor authentication (2FA) provides an additional level of security for your account. Once enabled, you will be prompted to enter a code in addition to entering your username and password.' => 'Die Zwei-Faktor-Authentifizierung (2FA) bietet eine zusätzliche Sicherheitsstufe für Dein Konto. Sobald sie aktiviert ist, wirst Du aufgefordert, zusätzlich zur Eingabe Deines Benutzernamens und Passworts einen Code einzugeben.', 'Two-factor authentication code is expired. Please try again.' => 'Der Zwei-Faktor-Authentifizierungscode ist abgelaufen. Bitte versuche es erneut.', - 'Two-factor authentication has been reset for this user.' => '', + 'Two-factor authentication has been reset for this user.' => 'Die Zwei-Faktor-Authentifizierung wurde für diesen Benutzer zurückgesetzt.', 'User' => 'Benutzer', - 'User: {username}' => '', + 'User: {username}' => 'Benutzer: {username}', 'Users' => 'Benutzer', 'Verify' => 'Prüfen', 'Verifying code is not valid!' => 'Verifizierungscode ist nicht gültig!', - 'You can also enter one of your recovery codes if you lost access to your authenticator app.' => '', - 'You signed in with a recovery code. Please generate new recovery codes or reconfigure your authenticator app.' => '', + 'You can also enter one of your recovery codes if you lost access to your authenticator app.' => 'Du kannst auch einen deiner Wiederherstellungscodes eingeben, wenn du den Zugriff auf deine Authenticator-App verloren hast.', + 'You signed in with a recovery code. Please generate new recovery codes or reconfigure your authenticator app.' => 'Du hast dich mit einem Wiederherstellungscode angemeldet. Bitte erstelle neue Wiederherstellungscodes oder richte deine Authenticator-App erneut ein.', 'Your account is secured by a two-factor authentication system. Please use the following code to proceed.' => 'Ihr Konto ist durch ein Zwei-Faktor-Authentifizierungssystem gesichert. Bitte verwenden Sie den folgenden Code, um fortzufahren.', 'Your login verification code' => 'Ihr Login-Verifizierungscode', ];