Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
dcd54db
wip
lippserd Jul 23, 2025
8645996
Add: 2FA login challenge without proper validation
Jan-Schuppik Jul 24, 2025
4724125
Add: cancel button for 2fa challenge
Jan-Schuppik Jul 24, 2025
4805486
WIP Add: toggle 2FA control via preferences
Jan-Schuppik Jul 26, 2025
c1ee1b5
WIP: Authentication Working
Jan-Schuppik Jul 27, 2025
f752cf3
Fix: display temporary state in form
Jan-Schuppik Jul 28, 2025
9e2af1f
Delete: tryout page
Jan-Schuppik Jul 28, 2025
bbfea41
Cleanup
Jan-Schuppik Jul 31, 2025
70cdb25
Add: secret verification before applying it to the user
Jan-Schuppik Jul 31, 2025
b9378de
Add: cancel-button & verification labels & comments
Jan-Schuppik Jul 31, 2025
6ae38bb
Add: qr code for new secrets
Jan-Schuppik Aug 1, 2025
2bd856d
Add: styling of qr code
Jan-Schuppik Aug 1, 2025
66a8437
Fix: prevent error if enabled-2fa isn't set
Jan-Schuppik Aug 8, 2025
afa1213
Fix: lint-errors
Jan-Schuppik Aug 11, 2025
287831e
Remove: inheritdocs
Jan-Schuppik Aug 11, 2025
c797756
Remove `mtime` column from database
jrauh01 Sep 30, 2025
f7ddce5
Rename 'TotpForm' to 'TotpConfigForm'
jrauh01 Sep 30, 2025
e443f51
Rename 'Totp' to 'IcingaTotp'
jrauh01 Sep 30, 2025
69ff0cd
Move 'PsrClock' to Authentication stuff in library
jrauh01 Sep 30, 2025
236dbc6
Rename 'Totp' to 'TotpModel'
jrauh01 Sep 30, 2025
5d79d5c
Change title for 2fa settings
jrauh01 Sep 30, 2025
c7b7f5f
Add missing types in `User`
jrauh01 Sep 30, 2025
c9e41a7
Refactor 2FA secret handling
jrauh01 Sep 30, 2025
5267e7b
Remove unused `.aligned-group` css class
jrauh01 Sep 30, 2025
19a1d1a
Simplify css for the qr code
jrauh01 Sep 30, 2025
657241e
Adjust comment
jrauh01 Oct 2, 2025
c6fb048
Adjust 'Cancel' button
jrauh01 Oct 2, 2025
b71c1da
Rename text input to 'token'
jrauh01 Oct 2, 2025
130d462
Rename session key for 2fa challenge
jrauh01 Oct 2, 2025
06defe6
Remove session cookie for successful 2fa
jrauh01 Oct 2, 2025
1066f59
Verify 2fa token properly
jrauh01 Oct 2, 2025
7591f43
Simplify if condition
jrauh01 Oct 2, 2025
f96dc61
Adjust remember me functionality to work with 2fa
jrauh01 Oct 2, 2025
4c059c5
Add license headers for new files
jrauh01 Nov 18, 2025
cbef7db
Remove `@inheritDoc`
jrauh01 Nov 19, 2025
5d62d9d
Add function return types
jrauh01 Nov 19, 2025
e4d1d5c
Add space after '!'
jrauh01 Nov 19, 2025
f348f02
Adjust comment in `Auth::setupUser()`
jrauh01 Nov 19, 2025
5ea0021
Add function default values
jrauh01 Nov 19, 2025
57e9b39
Remove unused `TotpConfigForm::onRequest()`
jrauh01 Nov 19, 2025
a55cd02
Remove redundant object creation
jrauh01 Nov 19, 2025
b934efe
Don't show the stored secret anymore
jrauh01 Nov 19, 2025
9825899
Use microseconds timestamp for `ctime`
jrauh01 Nov 19, 2025
f93928b
Add property docs for model
jrauh01 Nov 19, 2025
e5cd5ad
Add `icingaweb_totp` to pgsql schema
jrauh01 Nov 19, 2025
cd6d118
Add schema upgrades
jrauh01 Nov 19, 2025
4f08467
Rename label to disable 2FA
jrauh01 Nov 19, 2025
f394eff
Use more appropriate names
jrauh01 Nov 20, 2025
6cded0c
Remove `TwoFactorTotp::setTotp()`
jrauh01 Nov 20, 2025
7edc2ec
Remove superfluous variables
jrauh01 Nov 20, 2025
82861e4
Add missing docs for `TwoFactorTotp`
jrauh01 Nov 20, 2025
f79017c
Correct array indentation
jrauh01 Nov 20, 2025
0c4111f
Rename schema upgrades to '2.13.0.sql'
jrauh01 Nov 21, 2025
8039f3c
Rewrite `TwoFactorConfigForm` to ipl form
jrauh01 Nov 21, 2025
08aa691
If no 2fa db table exists 2fa is disabled
jrauh01 Nov 21, 2025
76b487e
Rewrite authentication forms to ipl forms
jrauh01 Nov 26, 2025
039642b
Add `TotpTokenValidator`
jrauh01 Nov 26, 2025
f951eb1
Use one combined `LoginForm`
jrauh01 Nov 26, 2025
262c0db
Improve `TwoFactorConfigForm`
jrauh01 Nov 27, 2025
d7e05ef
Use `endroid/qr-code` to generate QR code
jrauh01 Dec 1, 2025
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
26 changes: 26 additions & 0 deletions application/controllers/AccountController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,27 @@

namespace Icinga\Controllers;

use GuzzleHttp\Psr7\ServerRequest;
use Icinga\Application\Config;
use Icinga\Authentication\TwoFactorTotp;
use Icinga\Authentication\User\UserBackend;
use Icinga\Common\Database;
use Icinga\Data\ConfigObject;
use Icinga\Exception\ConfigurationError;
use Icinga\Forms\Account\ChangePasswordForm;
use Icinga\Forms\Account\TwoFactorConfigForm;
use Icinga\Forms\PreferenceForm;
use Icinga\User\Preferences\PreferencesStore;
use Icinga\Web\Controller;
use ipl\Html\Contract\Form;

/**
* My Account
*/
class AccountController extends Controller
{
use Database;

/**
* {@inheritdoc}
*/
Expand Down Expand Up @@ -67,6 +74,25 @@ public function indexAction()
}
}

if ($user->can('user/two-factor-authentication')) {
$twoFactor = TwoFactorTotp::loadFromDb($this->getDb(), $user->getUsername());
if ($twoFactor === null) {
$twoFactor = TwoFactorTotp::generate($user->getUsername());
}

$twoFactorForm = new TwoFactorConfigForm();
$twoFactorForm->setUser($user);
$twoFactorForm->setTwoFactor($twoFactor);
$twoFactorForm->on(Form::ON_SUBMIT, function (TwoFactorConfigForm $form) {
if ($redirectUrl = $form->getRedirectUrl()) {
$this->redirectNow($redirectUrl);
}
});
$twoFactorForm->handleRequest(ServerRequest::fromGlobals());

$this->view->twoFactorForm = $twoFactorForm;
}

$form = new PreferenceForm();
$form->setPreferences($user->getPreferences());
if (isset($config->config_resource)) {
Expand Down
50 changes: 46 additions & 4 deletions application/controllers/AuthenticationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,21 @@

namespace Icinga\Controllers;

use GuzzleHttp\Psr7\ServerRequest;
use Icinga\Application\Hook\AuthenticationHook;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Authentication\Auth;
use Icinga\Authentication\User\ExternalBackend;
use Icinga\Common\Database;
use Icinga\Exception\AuthenticationException;
use Icinga\Forms\Authentication\LoginForm;
use Icinga\Web\Controller;
use Icinga\Web\Helper\CookieHelper;
use Icinga\Web\RememberMe;
use Icinga\Web\Session;
use Icinga\Web\Url;
use ipl\Html\Contract\Form;
use RuntimeException;

/**
Expand Down Expand Up @@ -41,7 +46,42 @@ public function loginAction()
if (($requiresSetup = $icinga->requiresSetup()) && $icinga->setupTokenExists()) {
$this->redirectNow(Url::fromPath('setup'));
}
$form = new LoginForm();

$form = (new LoginForm())
->setAction(Url::fromRequest()->getAbsoluteUrl())
->on(Form::ON_SUBMIT, function (LoginForm $form) {
if ($redirectUrl = $form->getRedirectUrl()) {
$this->redirectNow($redirectUrl);
}
})
->on(Form::ON_SENT, function (LoginForm $form) {
if (
$form->getElement('CSRFToken')->isValid()
&& $form->getPressedSubmitElement()?->getName() === $form::SUBMIT_CANCEL_2FA
) {
Session::getSession()->purge();
$this->redirectNow(Url::fromRequest());
}
})
->on(Form::ON_REQUEST, function ($request, LoginForm $form) {
$auth = Auth::getInstance();
$onlyExternal = true;
// TODO(el): This may be set on the auth chain once iterated. See Auth::authExternal().
foreach ($auth->getAuthChain() as $backend) {
if (! $backend instanceof ExternalBackend) {
$onlyExternal = false;
}
}
if ($onlyExternal) {
$form->addMessage($this->translate(
'You\'re currently not authenticated using any of the web server\'s authentication'
. 'mechanisms. Make sure you\'ll configure such, otherwise you\'ll not be able to login.'
));
$form->onError();
}
});

$skip2fa = false;

if (RememberMe::hasCookie() && $this->hasDb()) {
$authenticated = false;
Expand All @@ -52,6 +92,7 @@ public function loginAction()
$rememberMe = $rememberMeOld->renew();
$this->getResponse()->setCookie($rememberMe->getCookie());
$rememberMe->persist($rememberMeOld->getAesCrypt()->getIV());
$skip2fa = true;
}
} catch (RuntimeException $e) {
Logger::error("Can't authenticate user via remember me cookie: %s", $e->getMessage());
Expand All @@ -64,7 +105,7 @@ public function loginAction()
}
}

if ($this->Auth()->isAuthenticated()) {
if ($this->Auth()->isAuthenticated($skip2fa)) {
// Call provided AuthenticationHook(s) when login action is called
// but icinga web user is already authenticated
AuthenticationHook::triggerLogin($this->Auth()->getUser());
Expand All @@ -76,7 +117,7 @@ public function loginAction()
$this->httpBadRequest('nope');
}
} else {
$redirectUrl = $form->getRedirectUrl();
$redirectUrl = $form->createRedirectUrl();
}

$this->redirectNow($redirectUrl);
Expand All @@ -91,9 +132,10 @@ public function loginAction()
->sendResponse();
exit;
}
$form->handleRequest();
$form->handleRequest(ServerRequest::fromGlobals());
}
$this->view->form = $form;
$this->view->cancel2faForm = $cancel2faForm ?? null;
$this->view->defaultTitle = $this->translate('Icinga Web 2 Login');
$this->view->requiresSetup = $requiresSetup;
}
Expand Down
192 changes: 192 additions & 0 deletions application/forms/Account/TwoFactorConfigForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<?php

/* Icinga Web 2 | (c) 2025 Icinga GmbH | GPLv2+ */

namespace Icinga\Forms\Account;

use Icinga\Authentication\TwoFactorTotp;
use Icinga\Common\Database;
use Icinga\User;
use Icinga\Web\Form\Element\FakeFormElement;
use Icinga\Web\Form\Validator\TotpTokenValidator;
use Icinga\Web\Notification;
use ipl\Html\Attributes;
use ipl\Html\HtmlElement;
use ipl\Html\Text;
use ipl\Web\Common\FormUid;
use ipl\Web\Compat\CompatForm;
use ipl\Web\Url;
use ipl\Web\Widget\CopyToClipboard;

/**
* Form for enabling and disabling 2FA or creating and updating the 2FA TOTP secret
*
* This form is used to manage the 2FA settings of a user account.
*/
class TwoFactorConfigForm extends CompatForm
{
use Database;
use FormUid;

/** @var User|null The user to work with */
protected ?User $user = null;

/** @var TwoFactorTotp The TwoFactorTotp instance to work with */
protected TwoFactorTotp $twoFactor;

/** @var string The submit button to verify the 2FA TOTP secret */
protected const SUBMIT_VERIFY = 'btn_submit_verify';

/** @var string The submit button to disable 2FA */
protected const SUBMIT_DISABLE = 'btn_submit_disable';

public function __construct()
{
$this->setAttribute('name', 'form_config_2fa');
}

/**
* Set the user to work with
*
* @param User $user The user to work with
*
* @return $this
*/
public function setUser(User $user): static
{
$this->user = $user;

return $this;
}

/**
* Set the TwoFactorTotp instance to work with
*
* @param TwoFactorTotp $twoFactor
*
* @return $this
*/
public function setTwoFactor(TwoFactorTotp $twoFactor): static
{
$this->twoFactor = $twoFactor;

return $this;
}

protected function assemble(): void
{
$this->addElement($this->createUidElement());

if (TwoFactorTotp::hasDbSecret($this->getDb(), $this->user->getUsername())) {
$this->addElement(
'submit',
static::SUBMIT_DISABLE,
[
'label' => $this->translate('Disable 2FA'),
'data-progress-label' => $this->translate('Disabling')
]
);
} else {
$this->addElement(
'checkbox',
'enabled_2fa',
[
'class' => 'autosubmit',
'label' => $this->translate('Enable 2FA (TOTP)'),
'description' => $this->translate(
'This option allows you to enable or to disable the two factor authentication via TOTP.'
),
]
);

if ($this->getPopulatedValue('enabled_2fa') === 'y') {
// Keep the secret after form submission, otherwise every form submission would generate a new secret.
// This would result in the following:
// - Users would have to scan a new QR code every time the verification fails.
// - Token verification would fail every time because the secret would have changed.
if ($secret = $this->getPopulatedValue('2fa_totp_secret')) {
$this->twoFactor = TwoFactorTotp::createFromSecret($secret, $this->user->getUsername());
}

$this->addHtml(new FakeFormElement(
HtmlElement::create('img', Attributes::create([
'class' => 'two-factor-totp-qr-code',
'src' => $this->twoFactor->createQRCode()
])),
$this->translate('QR Code'),
$this->translate('Use your authenticator app to scan the QR code.')
));

$manualAuthUrl = HtmlElement::create(
'div',
Attributes::create(['class' => 'two-factor-totp-auth-url']),
new Text($this->twoFactor->getTotpAuthUrl()),
);
CopyToClipboard::attachTo($manualAuthUrl);
$this->addHtml(new FakeFormElement(
$manualAuthUrl,
$this->translate('Manual Auth URL'),
$this->translate('If you have no camera to scan the QR code you can enter the auth URL manually.')
));

$this->addElement(
'text',
'2fa_verification_token',
[
'required' => true,
'label' => $this->translate('Verification Token'),
'description' => $this->translate(
'Please enter the token from your authenticator app to verify your setup.'
),
'validators' => [new TotpTokenValidator()]
]
);

$this->addElement(
'submit',
static::SUBMIT_VERIFY,
[
'label' => $this->translate('Verify 2FA TOTP Secret'),
'data-progress-label' => $this->translate('Verifying')
]
);
}
}

$this->addElement(
'hidden',
'2fa_totp_secret',
[
'value' => $this->twoFactor->getSecret()
]
);
}

protected function onSuccess(): void
{
$twoFactor = TwoFactorTotp::createFromSecret($this->getValue('2fa_totp_secret'), $this->user->getUsername());

switch ($this->getPressedSubmitElement()?->getName()) {
case static::SUBMIT_VERIFY:
$token = $this->getValue('2fa_verification_token');
if ($token && $twoFactor->verify($token)) {
$twoFactor->saveToDb();
Notification::success($this->translate('2FA via TOTP has been configured successfully.'));
} else {
Notification::error($this->translate('The verification token is invalid. Please try again.'));

// Don't redirect in this case, as the user might want to try again.
return;
}

break;
case static::SUBMIT_DISABLE:
$twoFactor->removeFromDb();
Notification::success($this->translate('2FA TOTP secret has been removed.'));

break;
}

$this->setRedirectUrl(Url::fromRequest());
}
}
Loading
Loading