Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
97d3106
wip
lippserd Jul 23, 2025
121fe23
Initial implementation
Jan-Schuppik Jul 24, 2025
7133fa1
Remove `mtime` column from database
jrauh01 Sep 30, 2025
00438e5
Rename stuff
jrauh01 Sep 30, 2025
383e39f
Move 'PsrClock' to Authentication stuff in library
jrauh01 Sep 30, 2025
f7b424c
Change title for 2fa settings
jrauh01 Sep 30, 2025
3c08c50
Add missing types in `User`
jrauh01 Sep 30, 2025
50901ed
Refactor 2FA secret handling
jrauh01 Sep 30, 2025
4ec7f6a
Adjust comment
jrauh01 Oct 2, 2025
3cb93d7
Adjust 'Cancel' button
jrauh01 Oct 2, 2025
1bcbff9
Rename text input to 'token'
jrauh01 Oct 2, 2025
b349785
Rename session key for 2fa challenge
jrauh01 Oct 2, 2025
2471231
Remove session cookie for successful 2fa
jrauh01 Oct 2, 2025
3cc2bdc
Verify 2fa token properly
jrauh01 Oct 2, 2025
69e4e43
Simplify if condition
jrauh01 Oct 2, 2025
5e22332
Adjust remember me functionality to work with 2fa
jrauh01 Oct 2, 2025
ebf1b11
Add license headers for new files
jrauh01 Nov 18, 2025
b0b9d3a
Remove `@inheritDoc`
jrauh01 Nov 19, 2025
c7bded3
Add function return types
jrauh01 Nov 19, 2025
f174a66
Add space after '!'
jrauh01 Nov 19, 2025
d652be2
Adjust comment in `Auth::setupUser()`
jrauh01 Nov 19, 2025
598e038
Add function default values
jrauh01 Nov 19, 2025
97b4f54
Remove unused `TotpConfigForm::onRequest()`
jrauh01 Nov 19, 2025
9ad7ec0
Remove redundant object creation
jrauh01 Nov 19, 2025
9db39aa
Don't show the stored secret anymore
jrauh01 Nov 19, 2025
15a59e1
Use microseconds timestamp for `ctime`
jrauh01 Nov 19, 2025
c7fc712
Add property docs for model
jrauh01 Nov 19, 2025
3511471
Add `icingaweb_totp` to pgsql schema
jrauh01 Nov 19, 2025
844ed19
Add schema upgrades
jrauh01 Nov 19, 2025
3baa18f
Rename label to disable 2FA
jrauh01 Nov 19, 2025
599cc23
Use more appropriate names
jrauh01 Nov 20, 2025
a2b0a0f
Remove `TwoFactorTotp::setTotp()`
jrauh01 Nov 20, 2025
469f921
Remove superfluous variables
jrauh01 Nov 20, 2025
f80777f
Add missing docs for `TwoFactorTotp`
jrauh01 Nov 20, 2025
697b53a
Correct array indentation
jrauh01 Nov 20, 2025
1f6962b
Rename schema upgrades to '2.13.0.sql'
jrauh01 Nov 21, 2025
c164f2d
Rewrite `TwoFactorConfigForm` to ipl form
jrauh01 Nov 21, 2025
d539ce0
If no 2fa db table exists 2fa is disabled
jrauh01 Nov 21, 2025
e6b97ee
Rewrite authentication forms to ipl forms
jrauh01 Nov 26, 2025
e7dbc7a
Add `TotpTokenValidator`
jrauh01 Nov 26, 2025
b1a3ec8
Use one combined `LoginForm`
jrauh01 Nov 26, 2025
5556cef
Improve `TwoFactorConfigForm`
jrauh01 Nov 27, 2025
dd09e07
Use `endroid/qr-code` to generate QR code
jrauh01 Dec 1, 2025
268c6dd
Add `CsrfCounterMeasure` to `TwoFactorConfigForm`
jrauh01 Dec 2, 2025
e643dcc
Add 'Download QR Code' action link
jrauh01 Dec 2, 2025
73ef66d
Prevent bypassing 2fa by changing username case
jrauh01 Dec 8, 2025
d443c4c
Show only secret instead of whole otpauth url
jrauh01 Dec 9, 2025
8b866a7
Make `ctime` column not nullable
jrauh01 Dec 9, 2025
8656ecf
Add CLI commands for 2FA
jrauh01 Dec 11, 2025
0194c75
Remove permission `user/two-factor-authentication`
jrauh01 Dec 11, 2025
7348e85
Add hint to store the secret for recovery
jrauh01 Dec 11, 2025
4af80a5
Add docs for 2FA
jrauh01 Dec 12, 2025
53b01f2
wip
jrauh01 Apr 21, 2026
3e96d71
wip: hookable config form
jrauh01 Apr 22, 2026
8c5a11f
wip: hookable auth
jrauh01 Apr 22, 2026
5665b64
Remove redundant `$skip2fa` parameter from `Auth::isAuthenticated()`
jrauh01 Apr 23, 2026
d94e0f2
Redirect directly to the target URL after successful 2FA verification
jrauh01 Apr 23, 2026
d2eb060
Remove `enroll()` and `unenroll()` from `TwoFactor` interface
jrauh01 Apr 24, 2026
7ddf2e9
Delegate 2FA verification form assembly to hook implementations
jrauh01 Apr 24, 2026
b185676
Add `getUsername()` helper to `TwoFactorHook`
jrauh01 Apr 24, 2026
ca200ba
Set 2FA enabled flag during `RememberMe` authentication
jrauh01 Apr 24, 2026
0d65e6c
Remove stale commented-out 2FA code from Auth
jrauh01 Apr 24, 2026
f97f750
Move enroll/unenroll responsibility to `TwoFactorEnrollmentForm`
jrauh01 Apr 27, 2026
7f0ea5b
Scope enrollment elements in a per-method `FieldsetElement`
jrauh01 Apr 28, 2026
35db0ec
Link directly to `TwoFactorEnrollmentForm` in interface docs
jrauh01 Apr 28, 2026
09fae0c
fixup! Delegate 2FA verification form assembly to hook implementations
jrauh01 Apr 28, 2026
860bf99
Move 2FA challenge form assembly back to `LoginForm`
jrauh01 Apr 28, 2026
c4207ca
fixup! wip: hookable config form
jrauh01 Apr 29, 2026
3592d79
Use variable to not redundantly generate server request
jrauh01 Apr 29, 2026
eeb1c14
fixup! Rewrite authentication forms to ipl forms
jrauh01 Apr 29, 2026
f9a00a2
Add `LoginPage` widget to centralise login page and modernize to ipl
jrauh01 Apr 29, 2026
5cb979c
Add `TwoFactorChallengeForm` for the 2FA verification step
jrauh01 Apr 29, 2026
cf748f4
Migrate `AuthenticationController` to `CompatController`
jrauh01 Apr 29, 2026
b8ec333
Strip 2FA logic out of `LoginForm`
jrauh01 Apr 29, 2026
5ac3aed
Add `AuthenticationController::twofactorAction()`
jrauh01 Apr 29, 2026
99e4e0b
Fix typo in login-orbs CSS selector
jrauh01 Apr 29, 2026
e31d588
Add `TwoFactorState` to centralise 2FA session handling
jrauh01 Apr 29, 2026
5617f11
fixup! Add `LoginPage` widget to centralise login page and modernize …
jrauh01 Apr 29, 2026
1be24d1
Separate `$form` and `$loginButtons` in `LoginPage`
jrauh01 Apr 29, 2026
aa5962a
Log 2FA challenge and verification outcome
jrauh01 Apr 29, 2026
eed79b8
Reject API requests from users with 2FA enrolled
jrauh01 Apr 30, 2026
ecec2bb
fixup! Strip 2FA logic out of `LoginForm`
jrauh01 Apr 30, 2026
299e3fd
Reuse `LoginForm::REDIRECT_URL` in `TwoFactorChallengeForm`
jrauh01 Apr 30, 2026
b6374d4
Normalise naming conventions across 2FA form constants and element names
jrauh01 Apr 30, 2026
0931ffa
Reformat `LoginForm::assemble()` to inline `addElement()` call style
jrauh01 Apr 30, 2026
106f976
fixup! Add `AuthenticationController::twofactorAction()`
jrauh01 Apr 30, 2026
5525dc1
Extract `withRedirect()` helper in `AuthenticationController`
jrauh01 Apr 30, 2026
08e656b
wip: cancel button
jrauh01 Apr 30, 2026
93bf838
Replace cancel button with a link-styled back-to-login button
jrauh01 May 5, 2026
3896fad
Remove TOTP enroll styles from core — moved to icinga-totp module
jrauh01 May 5, 2026
a3f2c75
Remove `FakeFormElement` from core — moved to icinga-totp module
jrauh01 May 6, 2026
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
116 changes: 116 additions & 0 deletions application/clicommands/TwofactorCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

namespace Icinga\Clicommands;

use DateTime;
use Icinga\Cli\Command;
use Icinga\Common\Database;
use ipl\Sql\Delete;
use ipl\Sql\Select;
use Throwable;

class TwofactorCommand extends Command
{
use Database;

/**
* List all users that have 2FA enabled
*
* This command lists all users that have 2FA enabled and when they enabled it.
*
* USAGE
*
* icingacli twofactor list
*/
public function listAction(): void
{
$rows = $this->getDb()->select(
(new Select())
->from('icingaweb_2fa')
->columns(['username', 'ctime'])
)->fetchAll();

if (empty($rows)) {
echo "Currently there are no users that have 2FA enabled.\n";

return;
}

printf("%-20s %-20s\n", 'USER', 'ENABLED 2FA');

foreach ($rows as $row) {
printf(
"%-20s %-20s\n",
$row->username,
DateTime::createFromFormat('U.u', $row->ctime / 1000)->format('Y-m-d H:i:s')
);
}

echo "\n";
}

/**
* Disable 2FA for a specific user
*
* This command disables 2FA for a specific user. It asks for confirmation before deleting the secret. The deletion
* cannot be undone.
*
* USAGE
*
* icingacli twofactor disable [<user>]
*
* OPTIONS
*
* --force Disable 2FA for user without confirmation
*/
public function disableAction(): void
{
$user = $this->params->shift();

if (! $user) {
fwrite(STDERR, "User must be provided!\n");
$this->showUsage('disable');

exit(1);
}

if (! $this->params->shift('force')) {
$input = readline(sprintf(
"Are you sure you want to disable 2FA for user '%s'? This cannot be undone! [y/N] ",
$user
));

if (! $input || ! in_array(strtolower(trim($input)), ['y', 'yes'])) {
echo "No changes made.\n";

return;
}
}

try {
$delete = $this->getDb()->prepexec(
(new Delete())
->from('icingaweb_2fa')
->where(['LOWER(username) = ?' => strtolower($user)])
);
} catch (Throwable $e) {
fprintf(
STDERR,
"%s: Failed to disable 2FA for '%s': %s\n",
$this->screen->colorize('ERROR', 'red'),
$user,
$e->getMessage()
);

exit(1);
}

if ($delete->rowCount() < 1) {
printf("The user '%s' doesn't have 2FA enabled.\n", $user);

return;
}

printf("Successfully disabled 2FA for user '%s'.\n", $user);
}
}
12 changes: 12 additions & 0 deletions application/controllers/AccountController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
namespace Icinga\Controllers;

use Icinga\Application\Config;
use Icinga\Application\Hook;
use Icinga\Application\Hook\TwoFactorHook;
use Icinga\Authentication\User\UserBackend;
use Icinga\Common\Database;
use Icinga\Data\ConfigObject;
use Icinga\Exception\ConfigurationError;
use Icinga\Forms\Account\ChangePasswordForm;
Expand All @@ -19,6 +22,8 @@
*/
class AccountController extends Controller
{
use Database;

/**
* {@inheritdoc}
*/
Expand All @@ -43,6 +48,13 @@ public function init()
'url' => 'my-devices'
)
);
if (Hook::has(TwoFactorHook::NAME)) {
$this->getTabs()->add('two-factor', [
'title' => $this->translate('Configure two-factor authentication'),
'label' => $this->translate('Two-Factor Auth'),
'url' => 'two-factor/config',
]);
}
}

/**
Expand Down
101 changes: 90 additions & 11 deletions application/controllers/AuthenticationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,35 @@

namespace Icinga\Controllers;

use GuzzleHttp\Psr7\ServerRequest;
use Icinga\Application\ClassLoader;
use Icinga\Application\Hook\AuthenticationHook;
use Icinga\Application\Hook\LoginButtonHook;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Authentication\LoginButton;
use Icinga\Authentication\LoginButtonForm;
use Icinga\Authentication\Auth;
use Icinga\Authentication\TwoFactorState;
use Icinga\Authentication\User\ExternalBackend;
use Icinga\Common\Database;
use Icinga\Exception\AuthenticationException;
use Icinga\Forms\Authentication\LoginForm;
use Icinga\Web\Controller;
use Icinga\Forms\Authentication\TwoFactorChallengeForm;
use Icinga\Web\Helper\CookieHelper;
use Icinga\Web\RememberMe;
use Icinga\Web\Session;
use Icinga\Web\Url;
use Icinga\Web\Widget\LoginPage;
use ipl\Html\HtmlDocument;
use ipl\Html\Contract\Form;
use ipl\Web\Compat\CompatController;
use RuntimeException;
use Throwable;

/**
* Application wide controller for authentication
*/
class AuthenticationController extends Controller
class AuthenticationController extends CompatController
{
use Database;

Expand All @@ -45,11 +52,40 @@ class AuthenticationController extends Controller
*/
public function loginAction()
{
$twoFactorState = new TwoFactorState();
if ($twoFactorState->isChallenged()) {
$this->redirectNow($this->withRedirect('authentication/twofactor'));
}

$icinga = Icinga::app();
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_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();
}
});

if (RememberMe::hasCookie() && $this->hasDb()) {
$authenticated = false;
Expand Down Expand Up @@ -84,11 +120,13 @@ public function loginAction()
$this->httpBadRequest('nope');
}
} else {
$redirectUrl = $form->getRedirectUrl();
$redirectUrl = $form->createRedirectUrl();
}

$this->redirectNow($redirectUrl);
}

$request = $this->getServerRequest();
if (! $requiresSetup) {
$cookies = new CookieHelper($this->getRequest());
if (! $cookies->isSupported()) {
Expand All @@ -99,11 +137,10 @@ public function loginAction()
->sendResponse();
exit;
}
$form->handleRequest();
$form->handleRequest($request);
}

$loginButtons = [];
$request = ServerRequest::fromGlobals();

foreach (LoginButtonHook::all() as $class => $hook) {
try {
Expand All @@ -125,11 +162,11 @@ public function loginAction()
continue;
}
}
$this->setTitle($this->translate('Icinga Web 2 Login'));

$this->view->form = $form;
$this->view->loginButtons = $loginButtons;
$this->view->defaultTitle = $this->translate('Icinga Web 2 Login');
$this->view->requiresSetup = $requiresSetup;
// Suppress the rendering of an empty tab bar
$this->controls = new HtmlDocument();
$this->addContent(new LoginPage($form, $loginButtons, $requiresSetup));
}

/**
Expand Down Expand Up @@ -158,4 +195,46 @@ public function logoutAction()
$this->redirectToLogin();
}
}

public function twofactorAction(): void
{
$twoFactorState = new TwoFactorState();
if (! $twoFactorState->isChallenged()) {
$this->redirectToLogin();
}

$form = (new TwoFactorChallengeForm())
->setAction(Url::fromRequest()->getAbsoluteUrl())
->on(Form::ON_SUBMIT, function (TwoFactorChallengeForm $form) {
if ($redirectUrl = $form->getRedirectUrl()) {
$this->redirectNow($redirectUrl);
}
})
->on(Form::ON_SENT, function (TwoFactorChallengeForm $form) {
$isCsrfValid = $form->getElement('CSRFToken')->isValid();
$isCancelPressed =
$form->getPressedSubmitElement()?->getName() === TwoFactorChallengeForm::SUBMIT_CANCEL;

if ($isCsrfValid && $isCancelPressed) {
Session::getSession()->purge();
$this->redirectNow($this->withRedirect('authentication/login'));
}
})
->handleRequest($this->getServerRequest());

$this->setTitle($this->translate('Icinga Web 2 Two-Factor Auth'));

// Suppress the rendering of an empty tab bar
$this->controls = new HtmlDocument();
$this->addContent(new LoginPage($form));
}

protected function withRedirect(string $path): Url
{
$url = Url::fromPath($path);
if ($redirect = $this->params->get('redirect')) {
$url->setParam('redirect', $redirect);
}
return $url;
}
}
10 changes: 10 additions & 0 deletions application/controllers/MyDevicesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

namespace Icinga\Controllers;

use Icinga\Application\Hook;
use Icinga\Application\Hook\TwoFactorHook;
use Icinga\Common\Database;
use Icinga\Web\Notification;
use Icinga\Web\RememberMe;
Expand Down Expand Up @@ -47,6 +49,14 @@ public function init()
'url' => 'my-devices'
)
)->activate('devices');

if (Hook::has(TwoFactorHook::NAME)) {
$this->getTabs()->add('two-factor', [
'title' => $this->translate('Configure two-factor authentication'),
'label' => $this->translate('Two-Factor Auth'),
'url' => 'two-factor/config',
]);
}
}

public function indexAction()
Expand Down
9 changes: 9 additions & 0 deletions application/controllers/NavigationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

use Exception;
use Icinga\Application\Config;
use Icinga\Application\Hook;
use Icinga\Application\Hook\TwoFactorHook;
use Icinga\Exception\NotFoundError;
use Icinga\Data\DataArray\ArrayDatasource;
use Icinga\Data\Filter\FilterMatchCaseInsensitive;
Expand Down Expand Up @@ -156,6 +158,13 @@ public function indexAction()
'url' => 'my-devices'
)
);
if (Hook::has(TwoFactorHook::NAME)) {
$this->getTabs()->add('two-factor', [
'title' => $this->translate('Configure two-factor authentication'),
'label' => $this->translate('Two-Factor Auth'),
'url' => 'two-factor/config',
]);
}
$this->setupSortControl(
array(
'type' => $this->translate('Type'),
Expand Down
Loading
Loading