diff --git a/Events.php b/Events.php index b321983..64fecba 100644 --- a/Events.php +++ b/Events.php @@ -8,12 +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\controllers\CheckController; -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; @@ -51,70 +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; - } - - /** @var Controller $controller */ - $controller = $event->sender; - - if (self::isImpersonateAction($controller)) { - Yii::$app->session->set('twofa.switchedUserId', Yii::$app->user->id); - } - - // Another event handler (e.g. from a module intercepting the same action) has - // already canceled or redirected the current action; overriding its redirect - // could produce a redirect loop between the two modules - if (!$event->isValid || Yii::$app->response->getIsRedirection()) { - return; - } - - // Twofa-own allowlist — deliberately NOT the generic $doNotInterceptActionIds - // flag: that flag is set by controllers for unrelated reasons (e.g. the REST - // module, live polling, account deletion) and honoring it would exempt those - // actions from the second factor. Every entry here is a security decision. - if (self::isTwofaExemptRoute($controller, $event)) { - return; - } - - $beforeVerifying = new BeforeCheck(); - Yii::$app->trigger($beforeVerifying->name, $beforeVerifying); - - if (!$beforeVerifying->handled && TwofaHelper::isVerifyingRequired()) { - $event->isValid = false; - Yii::$app->response->redirect(TwofaUrl::toCheck()); - } + $event->manager->register(new TwofaGate()); } /** - * Routes that stay reachable while the two-factor verification is pending. + * Remembers the originating user of an admin "Impersonate" action, so the 2FA + * session state can be restored correctly. * - * @param $controller Controller - * @return bool + * @param $event */ - protected static function isTwofaExemptRoute($controller, $event): bool + public static function onBeforeAction($event): void { - // The 2fa check page itself — redirecting it would loop onto itself - if ($controller instanceof CheckController) { - return true; - } + /** @var Controller $controller */ + $controller = $event->sender; - // Login and logout must stay reachable - if ($controller instanceof AuthController) { - return true; + if (self::isImpersonateAction($controller)) { + Yii::$app->session->set('twofa.switchedUserId', Yii::$app->user->id); } - - // The mobile app updates its push token in the background - return $controller->module->id === 'fcm-push' - && $controller->id === 'token' - && $event->action->id === 'update'; } /** 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..85bbaf4 --- /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']; + } + + /** + * 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 $requestClass !== RequestClass::Api; + } + + /** + * 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 38d4a98..120e0d1 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()) { - return $this->redirect($redirectUrl); + 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 fdacae7..cc4de71 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,17 @@ Changelog ========= +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 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 + +1.3.0 (June 5, 2026) +-------------------- +- Enh #111: Update for HumHub 1.19 + 1.2.3 (July 16, 2026) --------------------- - Fix: Infinite redirect loop to the 2FA check page when another module intercepts the current action — the handler now yields when another interceptor already redirected the request, cancels the action via `$event->isValid` and uses a twofa-own, security-reviewed exemption list (check page, login/logout, push token update) instead of a generic opt-out flag 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 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/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', ]; 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', ]; diff --git a/module.json b/module.json index 865a3b7..c739873 100644 --- a/module.json +++ b/module.json @@ -14,9 +14,8 @@ "resources/screenshot3.png", "resources/screenshot4.png" ], - "version": "1.2.3", + "version": "1.4.0", "humhub": { - "minVersion": "1.18.0-beta.6", - "maxVersion": "1.18" + "minVersion": "1.19" } } 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'); diff --git a/tests/codeception/functional/TwofaGateCest.php b/tests/codeception/functional/TwofaGateCest.php new file mode 100644 index 0000000..b949e20 --- /dev/null +++ b/tests/codeception/functional/TwofaGateCest.php @@ -0,0 +1,78 @@ +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 testSessionRequestCannotEscapeViaAcceptHeader(FunctionalTester $I) + { + $I->wantTo('ensure a pending session cannot escape the 2FA check by faking a JSON Accept header'); + + $this->loginPendingAdmin($I); + + // 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->seeResponseCodeIs(403); + $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()); } }