Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 15 additions & 55 deletions Events.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
}

/**
Expand Down
8 changes: 0 additions & 8 deletions Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
106 changes: 106 additions & 0 deletions components/TwofaGate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

/**
* @link https://www.humhub.org/
* @copyright Copyright (c) 2026 HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
*/

namespace humhub\modules\twofa\components;

use humhub\components\gates\RequestClass;
use humhub\components\gates\UserGate;
use humhub\modules\twofa\helpers\TwofaHelper;
use humhub\modules\twofa\helpers\TwofaUrl;
use Yii;

/**
* Routes users with pending two-factor verification through the 2FA check page
* before they can use the platform (see core `docs/develop/user-gates.md`).
*
* Replaces the module's former `Controller::EVENT_BEFORE_ACTION` interception.
*
* @since 1.4
*/
class TwofaGate extends UserGate
{
/**
* @inheritdoc
*/
public function getId(): string
{
return 'twofa';
}

/**
* @inheritdoc
*/
public function getSortOrder(): int
{
return self::SORT_SECOND_FACTOR;
}

/**
* @inheritdoc
*/
public function isOpen(): bool
{
return !Yii::$app->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();
}
}
2 changes: 2 additions & 0 deletions config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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']],
Expand Down
13 changes: 6 additions & 7 deletions controllers/CheckController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@
*/
class CheckController extends Controller
{
/**
* @inheritdoc
*/
protected $doNotInterceptActionIds = ['*'];

/**
* @inheritdoc
*/
Expand All @@ -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;
}
Expand Down
11 changes: 11 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 12 additions & 17 deletions docs/DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
});
```
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.
15 changes: 0 additions & 15 deletions events/BeforeCheck.php

This file was deleted.

28 changes: 19 additions & 9 deletions helpers/TwofaHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -316,24 +316,34 @@ public static function disableVerifying(bool $isVerified = false)
}

/**
* Check if verifying by 2fa is required for current User
* Check if the current User still has to pass the two-factor verification.
*
* Free of side effects — delivering the verification code is handled separately
* by [[sendCodeIfNeeded()]] (invoked when the TwofaGate intercepts a request).
*
* @return bool
* @throws \yii\base\NotSupportedException
* @since 1.4
*/
public static function isVerifyingRequired()
public static function isVerificationPending(): bool
{
$driver = self::getDriver();

if (!$driver || self::isSessionVerified() || !$driver->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();
}

/**
Expand Down
Loading
Loading