Two factor authentication for both login variants

This adds optional two factor authentication based on shared
TOTP-secrets to both login variants (password and AIO token
authentication).

The 2FA can be enabled as soon as the containers are running in a
section below the backup configuration. As long as it is not enabled,
the AIO UI shows a warning at the top, and inside the Nextcloud a
notification is shown to all Nextcloud admins, strongly recommending to
enable it. The Nextcloud admin notification is sent via the
mastercontainer's `cron.sh`, thus will be renewed whenever it is
dismissed.

In order to show the notices, this PR includes a notification system for
the AIO UI, providing two variants: notices, and warnings. Notices have
a green background and border and vanish after 5 seconds. Warnings have
a orange-leaning yellow background and border and don't vanish (and
can't be dismissed manually, neither).

Another visual improvement is highlighted section headlines: If the URL
hash matches an `h2` element's ID, the `h2` is highlighted and if the
`h2` is followed by a `detail` element, that `detail` is opened. If
effect, browsing to `#two-factor-auth` jumps to the section, which is
highlighted and opened already (as shown in the second screenshot
below).

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>
AI-assistant: Claude Opus 4.8
This commit is contained in:
Pablo Zmdl
2026-08-20 16:33:29 +02:00
parent 866d87de16
commit 93cf3d3931
26 changed files with 4859 additions and 29 deletions
+3
View File
@@ -57,6 +57,9 @@ while true; do
# Check if AIO is outdated
su-exec www-data php /var/www/docker-aio/php/src/Cron/OutdatedNotification.php
# Nag admins to enable two-factor authentication if it is not set up yet
su-exec www-data php /var/www/docker-aio/php/src/Cron/TwoFactorAuthNotification.php
# Update deSEC DNS IP record (no-op when IP is unchanged or deSEC is not configured)
su-exec www-data php /var/www/docker-aio/php/src/Cron/UpdateDesecIp.php
+6 -2
View File
@@ -17,12 +17,15 @@
"slim/twig-view": "^3.3",
"slim/csrf": "^1.3",
"ext-apcu": "*",
"slim/psr7": "^1.8"
"slim/psr7": "^1.8",
"rullzer/easytotp": "^0.1.4",
"christian-riesen/base32": "^1.6"
},
"require-dev": {
"sserbin/twig-linter": "@dev",
"vimeo/psalm": "^6.0",
"wapmorgan/php-deprecation-detector": "dev-master"
"wapmorgan/php-deprecation-detector": "dev-master",
"phpunit/phpunit": "^12"
},
"scripts": {
"dev": [
@@ -34,6 +37,7 @@
"psalm:strict": "psalm --threads=1 --show-info=true",
"lint": "php -l src/*.php src/**/*.php public/index.php",
"lint:twig": "twig-linter lint ./templates",
"test": "phpunit --configuration phpunit.xml",
"php-deprecation-detector": "phpdd scan -n -t 8.5 src/*.php src/**/*.php public/index.php"
}
}
+1669 -13
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="unit">
<directory>tests/php</directory>
</testsuite>
</testsuites>
</phpunit>
+25
View File
@@ -0,0 +1,25 @@
"use strict";
// Expands the <details> section that directly follows the <h2> which is the
// current URL anchor target (mirroring the h2:target headline highlight). So
// following an in-page link like the "enabling it below" nag both highlights the
// headline and opens its section. Runs on load and whenever the hash changes.
(function () {
function expandAnchoredDetails() {
const headline = document.querySelector("h2:target");
if (!headline) {
return;
}
const details = headline.nextElementSibling;
if (details && details.tagName === "DETAILS") {
details.open = true;
}
}
window.addEventListener("hashchange", expandAnchoredDetails);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", expandAnchoredDetails);
} else {
expandAnchoredDetails();
}
})();
+38
View File
@@ -123,9 +123,30 @@ $app->get('/containers', function (Request $request, Response $response, array $
$dockerActionManager = $container->get(\AIO\Docker\DockerActionManager::class);
/** @var \AIO\Controller\DockerController $dockerController */
$dockerController = $container->get(\AIO\Controller\DockerController::class);
/** @var \AIO\Auth\TotpService $totpService */
$totpService = $container->get(\AIO\Auth\TotpService::class);
/** @var \AIO\Notification\NotificationService $notificationService */
$notificationService = $container->get(\AIO\Notification\NotificationService::class);
$dockerActionManager->ConnectMasterContainerToNetwork();
$dockerController->StartDomaincheckContainer();
// A fresh candidate secret for the (optional) two-factor setup. It is stateless:
// carried in a hidden form field and only persisted once a code confirms it, so a
// page reload simply produces a new candidate. Only used while 2FA is disabled.
$totpSetupSecret = $totpService->generateSecret();
// Show any one-time flash notifications and, as long as 2FA is not enabled, a
// permanent warning nagging the user to set it up.
$notifications = $notificationService->consume();
if (!$configurationManager->isTwoFactorAuthEnabled()) {
$notifications[] = [
'message' => 'Two-factor authentication is not set up. We strongly recommend {link} to better protect access to the AIO interface with a second factor.',
'type' => \AIO\Notification\NotificationType::Warning->value,
'temporary' => false,
'link' => ['href' => '#two-factor-auth-setup', 'text' => 'enabling it below'],
];
}
// Check if bypass_mastercontainer_update is provided on the URL, a special developer mode to bypass a mastercontainer update and use local image.
$params = $request->getQueryParams();
$bypass_mastercontainer_update = isset($params['bypass_mastercontainer_update']);
@@ -187,6 +208,15 @@ $app->get('/containers', function (Request $request, Response $response, array $
'is_desec_domain' => $configurationManager->isDesecDomain(),
'desec_account_registered' => $configurationManager->isDesecAccountRegistered(),
'desec_awaiting_verification' => $configurationManager->isDesecAwaitingVerification(),
'is_two_factor_auth_enabled' => $configurationManager->isTwoFactorAuthEnabled(),
'totp_setup_secret' => $totpSetupSecret,
'totp_setup_uri' => $totpService->getProvisioningUri(
$totpSetupSecret,
$configurationManager->domain !== '' ? $configurationManager->domain : 'admin',
'Nextcloud AIO',
),
// One-time flash notifications plus, while 2FA is off, a permanent nag to set it up.
'notifications' => $notifications,
// Do not cache the page as it shows credentials
])->withHeader('Cache-Control', 'no-store');
})->setName('profile');
@@ -215,8 +245,16 @@ $app->get('/login', function (Request $request, Response $response, array $args)
$view = Twig::fromRequest($request);
/** @var \AIO\Docker\DockerActionManager $dockerActionManager */
$dockerActionManager = $container->get(\AIO\Docker\DockerActionManager::class);
/** @var \AIO\Data\ConfigurationManager $configurationManager */
$configurationManager = $container->get(\AIO\Data\ConfigurationManager::class);
/** @var \AIO\Auth\AuthManager $authManager */
$authManager = $container->get(\AIO\Auth\AuthManager::class);
return $view->render($response, 'login.twig', [
'is_login_allowed' => $dockerActionManager->isLoginAllowed(),
'is_two_factor_auth_enabled' => $configurationManager->isTwoFactorAuthEnabled(),
// A pending token means we're in the second-factor step of the auto-login:
// show only the code field (the token is the first factor, held in session).
'two_factor_auth_only' => $authManager->hasPendingToken(),
]);
});
+21
View File
@@ -0,0 +1,21 @@
"use strict";
// Auto-dismisses temporary notifications (see NotificationService / notifications.twig)
// a few seconds after the page loads. Permanent notifications are left untouched.
(function () {
const TIMEOUT_MS = 5000;
function dismissTemporaryNotifications() {
document.querySelectorAll(".notification--temporary").forEach(function (el) {
setTimeout(function () {
el.remove();
}, TIMEOUT_MS);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", dismissTemporaryNotifications);
} else {
dismissTemporaryNotifications();
}
})();
+44
View File
@@ -22,6 +22,13 @@
--color-disabled: #d3d3d3; /* light gray background for disabled checkboxes */
--color-border-disabled: #a9a9a9; /* darker gray border for disabled checkboxes */
--color-text-disabled: #a9a9a9; /* matching label text color for disabled checkboxes */
--color-notice-background: #e9f7ee; /* friendly light green for notices */
--color-notice-border: var(--color-success);
--color-notice-text: #1c4e2b;
--color-warning-background: #fff4e0; /* orange-leaning yellow (amber) for warnings */
--color-warning-border: #e6960b;
--color-warning-text: #824d00;
--color-marker-highlight: #fdf39a; /* light yellow text-marker highlight */
--border: .5px;
--border-hover: 2px;
--border-radius: 7px;
@@ -63,6 +70,13 @@ Note: Unfortunately, it's not possible to calculate this dynamically using CSS v
--color-primary-element-light-hover:#1e2d35;
--color-primary-element-light-text:#99d3f9;
--color-loader: var(--color-border-maxcontrast);
--color-notice-background: #16261b;
--color-notice-text: #a6e3b8;
--color-warning-background: #2c2210;
--color-warning-border: #eaa13a;
--color-warning-text: #f3c877;
/* Translucent so the light headline text stays readable over the marker. */
--color-marker-highlight: rgba(255, 224, 130, 0.28);
--border-hover: var(--border);
}
@@ -195,6 +209,36 @@ div.toast {
color: var(--color-main-text);
}
/* Flash notifications shown at the top of the page (see NotificationService). */
.notification {
border: 2px solid;
border-radius: var(--border-radius);
padding: 12px 16px;
margin: 0 0 20px 0;
box-sizing: border-box;
}
.notification--notice {
background-color: var(--color-notice-background);
border-color: var(--color-notice-border);
color: var(--color-notice-text);
}
.notification--warning {
background-color: var(--color-warning-background);
border-color: var(--color-warning-border);
color: var(--color-warning-text);
}
/* Highlight any headline that is the URL anchor target (e.g. the 2FA section
reached from the "enabling it below" link) like a text marker on paper: a light
yellow band whose transparent top makes it sit a little low, as if swiped by a
highlighter. Constrained to the text width via fit-content. */
h2:target {
width: fit-content;
background-image: linear-gradient(transparent 35%, var(--color-marker-highlight) 35%);
}
.nextcloud-logo {
margin-left: auto;
margin-right: auto;
+45
View File
@@ -0,0 +1,45 @@
"use strict";
// Renders the TOTP setup QR code in the browser from the otpauth:// URI that the
// server put into the #totp-qr element's data-otpauth attribute.
// Uses the vendored qrcode-generator library (vendor-qrcode.js).
//
// The QR is drawn onto a <canvas> rather than injected as HTML/an <img> because the page enforces Trusted
// Types (which forbids assigning a string to innerHTML) and may restrict img-src, so canvas drawing is the
// CSP-safe option. The QR is always black-on-white regardless of page theme, since scanners need that
// contrast.
(function () {
const el = document.getElementById("totp-qr");
if (!el || !el.dataset.otpauth || typeof qrcode === "undefined") {
return;
}
const qr = qrcode(0, "M"); // type 0 = auto-size, error correction level M
qr.addData(el.dataset.otpauth);
qr.make();
const count = qr.getModuleCount();
const cell = 4; // px per module
const quietZone = 4 * cell; // 4-module quiet zone, per the QR spec
const size = count * cell + quietZone * 2;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", "TOTP setup QR code");
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#000000";
for (let row = 0; row < count; row++) {
for (let col = 0; col < count; col++) {
if (qr.isDark(row, col)) {
ctx.fillRect(quietZone + col * cell, quietZone + row * cell, cell, cell);
}
}
}
el.appendChild(canvas);
})();
File diff suppressed because it is too large Load Diff
+64 -1
View File
@@ -9,9 +9,11 @@ use \DateTime;
readonly class AuthManager {
public const string SESSION_KEY = 'aio_authenticated';
private const string PENDING_TOKEN_KEY = 'pending_getlogin_token';
public function __construct(
private ConfigurationManager $configurationManager
private ConfigurationManager $configurationManager,
private TotpService $totpService,
) {
}
@@ -19,6 +21,67 @@ readonly class AuthManager {
return hash_equals($this->configurationManager->password, $password);
}
public function isTwoFactorAuthEnabled() : bool {
return $this->configurationManager->isTwoFactorAuthEnabled();
}
/**
* Stash a token-based auto-login (getlogin) token in the session so the
* second-factor step of that flow can validate it once a code is submitted.
*/
public function storePendingToken(string $token) : void {
$_SESSION[self::PENDING_TOKEN_KEY] = $token;
}
public function hasPendingToken() : bool {
return $this->getPendingToken() !== '';
}
/** Read the pending token without removing it, so a mistimed code can be retried. */
public function getPendingToken() : string {
$token = $_SESSION[self::PENDING_TOKEN_KEY] ?? '';
return is_string($token) ? $token : '';
}
/** Remove the pending token from the session (call once the login has succeeded). */
public function clearPendingToken() : void {
unset($_SESSION[self::PENDING_TOKEN_KEY]);
}
/**
* Validate the optional TOTP second factor WITHOUT consuming it. Returns
* [bool $isValid, ?int $matchedCounter]; when valid, the caller must pass
* $matchedCounter to commitTwoFactorAuth() once the whole login has
* succeeded, so a code is only burned on a successful login — a mistyped
* password alongside a correct code does not waste it. Returns [true, null]
* when 2FA is not set up, so the combined login check is uniform.
*
* @return array{0: bool, 1: int|null}
*/
public function verifyTwoFactorAuthCode(string $code) : array {
$secret = $this->configurationManager->twoFactorAuthSecret;
if ($secret === '') {
return [true, null]; // 2FA not set up → nothing to validate
}
return $this->totpService->verify(
$secret,
$code,
$this->configurationManager->twoFactorAuthLastCounter,
);
}
/**
* Persist the accepted TOTP counter so the same code cannot be reused inside
* its window (RFC 6238 single-use). Call only after a fully successful login;
* a null counter (2FA disabled, or nothing matched) is a no-op.
*/
public function commitTwoFactorAuth(?int $counter) : void {
if ($counter !== null) {
// The setter persists immediately (single write) when not batching.
$this->configurationManager->twoFactorAuthLastCounter = $counter;
}
}
public function CheckToken(string $token) : bool {
return hash_equals($this->configurationManager->aioToken, $token);
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace AIO\Auth;
use Base32\Base32;
use EasyTOTP\Factory;
use EasyTOTP\TOTPInterface;
use EasyTOTP\TOTPValidResultInterface;
/**
* Thin wrapper around rullzer/easytotp (the same library the reference app
* nextcloud/twofactor_totp uses).
*/
readonly class TotpService {
private const int PERIOD = 30;
private const int DIGITS = 6;
// Accepted clock-skew window, in 30s steps. 1 = ±30s.
private const int DRIFT = 1;
private const string BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/** 32 base32 chars = 160 bits, per RFC 4226; no padding, so it drops straight into an otpauth URI. */
public function generateSecret() : string {
$secret = '';
for ($i = 0; $i < 32; $i++) {
$secret .= self::BASE32_ALPHABET[random_int(0, 31)];
}
return $secret;
}
/**
* Build otpauth:// provisioning URI for authenticator apps/QR-codes.
*/
public function getProvisioningUri(string $secret, string $label, string $issuer) : string {
$name = rawurlencode($issuer) . ':' . rawurlencode($label);
return "otpauth://totp/{$name}?secret={$secret}&issuer=" . rawurlencode($issuer)
. '&algorithm=SHA1&period=' . self::PERIOD . '&digits=' . self::DIGITS;
}
/**
* Verify a code. Returns [bool $ok, ?int $matchedCounter]; the matched
* counter is meant to be persisted and passed back as $lastCounter on the
* next verify, so a valid code cannot be reused inside its own window (RFC 6238).
*
* easytotp verifies against the raw HMAC key, so the base32 secret is
* decoded first.
*
* @return array{0: bool, 1: int|null}
*/
public function verify(string $secret, string $code, ?int $lastCounter = null) : array {
$code = trim($code);
if ($secret === '' || !preg_match('#^\d{' . self::DIGITS . '}$#', $code)) {
return [false, null];
}
$totp = Factory::getTOTP(Base32::decode($secret), self::PERIOD, self::DIGITS, 0, TOTPInterface::HASH_SHA1);
$result = $totp->verify($code, self::DRIFT, $lastCounter);
if ($result instanceof TOTPValidResultInterface) {
return [true, $result->getCounter()];
}
return [false, null];
}
}
@@ -3,15 +3,20 @@ declare(strict_types=1);
namespace AIO\Controller;
use AIO\Auth\TotpService;
use AIO\Data\ConfigurationManager;
use AIO\Data\InvalidSettingConfigurationException;
use AIO\Data\OfficeSuite;
use AIO\Notification\NotificationType;
use AIO\Notification\NotificationService;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
readonly class ConfigurationController {
public function __construct(
private ConfigurationManager $configurationManager,
private TotpService $totpService,
private NotificationService $notificationService,
) {
}
@@ -124,6 +129,19 @@ readonly class ConfigurationController {
$this->configurationManager->collaboraAdditionalOptions = $additionalCollaboraOptions;
}
if (isset($request->getParsedBody()['enable_totp'])) {
$secret = $request->getParsedBody()['totp_secret'] ?? '';
$code = $request->getParsedBody()['totp_code'] ?? '';
$this->configurationManager->enableTwoFactorAuth($secret, $code, $this->totpService);
$this->notificationService->add('Two-factor authentication has been enabled.', NotificationType::Notice, true);
}
if (isset($request->getParsedBody()['disable_totp'])) {
$code = $request->getParsedBody()['totp_code'] ?? '';
$this->configurationManager->disableTwoFactorAuth($code, $this->totpService);
$this->notificationService->add('Two-factor authentication has been disabled.', NotificationType::Notice, true);
}
return $response->withStatus(201)->withHeader('Location', '.');
} catch (InvalidSettingConfigurationException $ex) {
$response->getBody()->write($ex->getMessage());
+50 -10
View File
@@ -18,34 +18,74 @@ readonly class LoginController {
}
public function TryLogin(Request $request, Response $response, array $args) : Response {
$totp = $request->getParsedBody()['totp'] ?? '';
// Second-factor step of the token-based auto-login: a token was stashed in
// the session by GetTryLogin, so it — not a password — is the first factor.
if ($this->authManager->hasPendingToken()) {
$token = $this->authManager->getPendingToken();
if ($this->authenticate($this->authManager->CheckToken($token), $totp)) {
// Remove the token only now that login has fully succeeded. Keeping
// it on failure lets the user retry — a TOTP code that rolled over
// between rendering and submitting must not kill the whole flow.
$this->authManager->clearPendingToken();
return $response->withHeader('Location', '.')->withStatus(201);
}
$response->getBody()->write("Login failed. Please check your two-factor authentication code and try again.");
return $response->withHeader('Location', '.')->withStatus(422);
}
if (!$this->dockerActionManager->isLoginAllowed()) {
$response->getBody()->write("The login is blocked since Nextcloud is running.");
return $response->withHeader('Location', '.')->withStatus(422);
}
$password = $request->getParsedBody()['password'] ?? '';
if($this->authManager->CheckCredentials($password)) {
$this->authManager->SetAuthState(true);
if ($this->authenticate($this->authManager->CheckCredentials($password), $totp)) {
return $response->withHeader('Location', '.')->withStatus(201);
}
// Punish failed auth attempts with a delay, as a very simple means against bots.
sleep(5);
$response->getBody()->write("The password is incorrect.");
// One generic message that does not reveal which of the two factors failed.
$response->getBody()->write("Login failed. Please check your credentials and, if enabled, your two-factor authentication code.");
return $response->withHeader('Location', '.')->withStatus(422);
}
public function GetTryLogin(Request $request, Response $response, array $args) : Response {
$token = $request->getQueryParams()['token'] ?? '';
if($this->authManager->CheckToken($token)) {
// Before validating the token, gate on 2FA: if it is enabled the token
// alone is not enough. Stash it in the session (unvalidated) and hand off
// to the /login flow, which asks for a code and then validates both.
if ($this->authManager->isTwoFactorAuthEnabled()) {
$this->authManager->storePendingToken($token);
return $response->withHeader('Location', '../../login')->withStatus(302);
}
// No 2FA: validate the token as before.
$this->authenticate($this->authManager->CheckToken($token), '');
return $response->withHeader('Location', '../..')->withStatus(302);
}
/**
* Shared login gate for both the password and the token flows. Succeeds only
* when the primary credential AND the optional TOTP second factor both check
* out. Both are evaluated without short-circuiting, so neither the outcome nor
* the timing reveals which factor failed; the code is consumed only on full
* success, so a mistyped password does not waste an otherwise-correct code.
* Sleeps on failure as a simple bot-throttle. Returns whether login succeeded.
*/
private function authenticate(bool $primaryCredentialOk, string $totpCode) : bool {
[$twoFactorAuthOk, $twoFactorAuthCounter] = $this->authManager->verifyTwoFactorAuthCode($totpCode);
if ($primaryCredentialOk && $twoFactorAuthOk) {
$this->authManager->commitTwoFactorAuth($twoFactorAuthCounter);
$this->authManager->SetAuthState(true);
return $response->withHeader('Location', '../..')->withStatus(302);
return true;
}
// Punish failed auth attempts with a delay, as a very simple means against bots.
sleep(5);
return $response->withHeader('Location', '../..')->withStatus(302);
return false;
}
public function Logout(Request $request, Response $response, array $args) : Response
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
// increase memory limit to 2GB
ini_set('memory_limit', '2048M');
use DI\Container;
require __DIR__ . '/../../vendor/autoload.php';
$container = \AIO\DependencyInjection::GetContainer();
/** @var \AIO\Data\ConfigurationManager $configurationManager */
$configurationManager = $container->get(\AIO\Data\ConfigurationManager::class);
// Nag the admins to set up a second factor as long as none is configured.
if (!$configurationManager->isTwoFactorAuthEnabled()) {
/** @var \AIO\Docker\DockerActionManager $dockerActionManager */
$dockerActionManager = $container->get(\AIO\Docker\DockerActionManager::class);
/** @var \AIO\ContainerDefinitionFetcher $containerDefinitionFetcher */
$containerDefinitionFetcher = $container->get(\AIO\ContainerDefinitionFetcher::class);
$id = 'nextcloud-aio-nextcloud';
$nextcloudContainer = $containerDefinitionFetcher->GetContainerById($id);
$dockerActionManager->sendNotification($nextcloudContainer, 'Two-factor authentication is not enabled!', 'We strongly recommend protecting the Nextcloud AIO interface with a second factor. You can enable two-factor authentication in the AIO interface.');
}
+64
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace AIO\Data;
use AIO\Auth\PasswordGenerator;
use AIO\Auth\TotpService;
use AIO\Controller\DockerController;
use AIO\Helper\NetworkHelper;
use GuzzleHttp\Client;
@@ -33,6 +34,24 @@ class ConfigurationManager
set { $this->set('password', $value); }
}
/**
* The base32 TOTP secret for the optional second factor. Its presence
* (non-empty) is the single source of truth for "2FA is enabled".
*/
public string $twoFactorAuthSecret {
get => $this->get('totp_secret', '');
set { $this->set('totp_secret', $value); }
}
/**
* The last TOTP counter that was accepted on login. Passed back on the next
* verify so a code cannot be reused inside its own window. -1 = none yet.
*/
public int $twoFactorAuthLastCounter {
get => (int) $this->get('totp_last_counter', -1);
set { $this->set('totp_last_counter', $value); }
}
public bool $isDockerSocketProxyEnabled {
// Type-cast because old configs could have 1/0 for this key.
get => (bool) $this->get('isDockerSocketProxyEnabled', false);
@@ -826,6 +845,51 @@ class ConfigurationManager
$this->set('password', $newPassword);
}
public function isTwoFactorAuthEnabled() : bool {
return $this->twoFactorAuthSecret !== '';
}
/**
* Enable the second factor. Requires a valid code for the freshly generated
* secret, so a mis-scanned secret can never lock the user out (the secret is
* only persisted once a code confirms it).
*
* @throws InvalidSettingConfigurationException
*/
public function enableTwoFactorAuth(string $secret, string $code, TotpService $totpService) : void {
if ($this->isTwoFactorAuthEnabled()) {
throw new InvalidSettingConfigurationException("Two-factor authentication is already enabled.");
}
[$isValid, $counter] = $totpService->verify($secret, $code);
if (!$isValid) {
throw new InvalidSettingConfigurationException("The entered code is not correct. Please try again.");
}
$this->startTransaction();
$this->twoFactorAuthSecret = $secret;
$this->twoFactorAuthLastCounter = $counter ?? -1;
$this->commitTransaction();
}
/**
* Disable the second factor. Requires a valid current code, so a stray
* logged-in session cannot silently turn it off.
*
* @throws InvalidSettingConfigurationException
*/
public function disableTwoFactorAuth(string $code, TotpService $totpService) : void {
if (!$this->isTwoFactorAuthEnabled()) {
return;
}
[$isValid, ] = $totpService->verify($this->twoFactorAuthSecret, $code, $this->twoFactorAuthLastCounter);
if (!$isValid) {
throw new InvalidSettingConfigurationException("The entered code is not correct. Please try again.");
}
$this->startTransaction();
$this->twoFactorAuthSecret = '';
$this->twoFactorAuthLastCounter = -1;
$this->commitTransaction();
}
/**
* @throws InvalidSettingConfigurationException
*/
+12 -1
View File
@@ -45,9 +45,20 @@ class DependencyInjection
\AIO\Auth\PasswordGenerator::class,
new \AIO\Auth\PasswordGenerator()
);
$container->set(
\AIO\Auth\TotpService::class,
new \AIO\Auth\TotpService()
);
$container->set(
\AIO\Notification\NotificationService::class,
new \AIO\Notification\NotificationService()
);
$container->set(
\AIO\Auth\AuthManager::class,
new \AIO\Auth\AuthManager($container->get(\AIO\Data\ConfigurationManager::class))
new \AIO\Auth\AuthManager(
$container->get(\AIO\Data\ConfigurationManager::class),
$container->get(\AIO\Auth\TotpService::class),
)
);
$container->set(
\AIO\Data\Setup::class,
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace AIO\Notification;
/**
* A minimal flash-notification system: messages are queued in the session and
* shown once on the next rendered page, so they survive the redirect that
* follows a successful POST. Rendered by templates/includes/notifications.twig
* (styling) and public/notifications.js (auto-dismiss of temporary ones).
*/
class NotificationService {
private const string SESSION_KEY = 'flash_notifications';
/**
* Queue a notification to show once on the next page render.
*
* @param bool $temporary When true the notification vanishes after a few
* seconds on the client; otherwise it stays until the
* next navigation.
*/
public function add(string $message, NotificationType $type = NotificationType::Notice, bool $temporary = false) : void {
if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
$_SESSION[self::SESSION_KEY] = [];
}
$_SESSION[self::SESSION_KEY][] = [
'message' => $message,
'type' => $type->value,
'temporary' => $temporary,
];
}
/**
* Return all queued notifications and clear the queue. Rebuilt with explicit
* casts so the shape is guaranteed regardless of what is in the session.
*
* @return list<array{message: string, type: string, temporary: bool}>
*/
public function consume() : array {
$stored = $_SESSION[self::SESSION_KEY] ?? [];
unset($_SESSION[self::SESSION_KEY]);
$notifications = [];
if (is_array($stored)) {
foreach ($stored as $item) {
if (is_array($item) && isset($item['message'], $item['type'], $item['temporary'])) {
$notifications[] = [
'message' => (string) $item['message'],
'type' => (string) $item['type'],
'temporary' => (bool) $item['temporary'],
];
}
}
}
return $notifications;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace AIO\Notification;
enum NotificationType: string {
case Notice = 'notice'; // friendly (light green) — informational / success
case Warning = 'warning'; // orange-leaning yellow (amber) — something needs attention
}
+35
View File
@@ -17,6 +17,7 @@
<div class="container">
<main>
{{ include('includes/notifications.twig') }}
{% set aio_version = include('includes/aio-version.twig') %}
<h1>Nextcloud AIO v{{ aio_version }}</h1>
@@ -649,6 +650,38 @@
</form>
<p>The new passphrase needs to be at least 24 characters long. Allowed characters are the <a target="_blank" href="https://en.wikipedia.org/wiki/Latin_alphabet#/media/File:Abecedarium.png"><strong>latin characters</strong></a> <strong>a-z</strong>, <strong>A-Z</strong>, <strong>0-9</strong> and <strong>spaces</strong>.</p>
</details>
<h2 id="two-factor-auth-setup">Two-factor authentication (TOTP)</h2>
<details>
{% if is_two_factor_auth_enabled == true %}
<summary>Click here to disable the authenticator-app second factor</summary>
{% else %}
<summary>Click here to set up an authenticator-app second factor</summary>
{% endif %}
{% if is_two_factor_auth_enabled == true %}
<p><span class="status success"></span> Two-factor authentication is currently <strong>enabled</strong>. It will be asked for in addition to your passphrase on every login.</p>
<p>To disable it, enter a current code from your authenticator app:</p>
<form method="POST" action="api/configuration" class="xhr">
<input type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" name="totp_code" placeholder="Authenticator code">
<input type="hidden" name="disable_totp" value="yes">
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input type="submit" value="Disable two-factor authentication">
</form>
{% else %}
<p>Scan this QR code with an authenticator app (or add the secret manually), then enter a code it generates to confirm and enable the second factor:</p>
<div id="totp-qr" data-otpauth="{{ totp_setup_uri }}"></div>
<p>Secret: <strong id="totp-setup-secret">{{ totp_setup_secret }}</strong></p>
<form method="POST" action="api/configuration" class="xhr">
<input type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" name="totp_code" placeholder="Authenticator code">
<input type="hidden" name="totp_secret" value="{{ totp_setup_secret }}">
<input type="hidden" name="enable_totp" value="yes">
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input type="submit" value="Enable two-factor authentication">
</form>
<p>If you lose access to your authenticator app, you can recover access by resetting the AIO instance as described in the documentation.</p>
{% endif %}
</details>
{% endif %}
{% endif %}
{% endif %}
@@ -695,6 +728,8 @@
{% endif %}
<script type="text/javascript" src="base_path.js"></script>
<script type="text/javascript" src="vendor-qrcode.js"></script>
<script type="text/javascript" src="totp-qr.js"></script>
</main>
</div>
+15
View File
@@ -0,0 +1,15 @@
{# Renders queued flash notifications at the top of the page. Each entry has a
`type` (`notice` | `warning`) and a `temporary` flag; temporary ones are
auto-dismissed client-side by notifications.js. #}
{% for notification in notifications|default([]) %}
<div class="notification notification--{{ notification.type }}{% if notification.temporary %} notification--temporary{% endif %}" role="alert">
{%- if notification.link is defined and notification.link -%}
{# The message carries a `{link}` placeholder that is replaced by an anchor.
Both surrounding text parts stay auto-escaped, so this is XSS-safe. #}
{%- set parts = notification.message|split('{link}', 2) -%}
{{ parts[0] }}<a href="{{ notification.link.href }}">{{ notification.link.text }}</a>{{ parts[1]|default('') }}
{%- else -%}
{{ notification.message }}
{%- endif -%}
</div>
{% endfor %}
+3 -1
View File
@@ -2,11 +2,13 @@
<html lang="en">
<head>
<title>AIO</title>
<link rel="stylesheet" href="style.css?v13" media="all" />
<link rel="stylesheet" href="style.css?v14" media="all" />
<link rel="icon" href="img/favicon.png">
<script type="text/javascript" src="forms.js?v2"></script>
<script type="text/javascript" src="toggle-dark-mode.js?v2"></script>
<script type="text/javascript" src="click-handlers.js?v2"></script>
<script type="text/javascript" src="notifications.js?v1"></script>
<script type="text/javascript" src="expand-anchored-details.js?v1"></script>
</head>
<body>
+15 -1
View File
@@ -8,10 +8,24 @@
<text x="10" y="50" fill="var(--color-nextcloud-logo)" class="fallback-text">Nextcloud Logo</text>
</svg>
<h1>Nextcloud AIO Login</h1>
{% if is_login_allowed == true %}
{% if two_factor_auth_only == true %}
{# Reached via the token auto-login while 2FA is enabled: the token (held
in the session) is the first factor, so we only ask for the
authenticator code. Submitting validates the token and the code. #}
<p>Enter your authenticator code to finish logging in:</p>
<form method="POST" action="api/auth/login" class="xhr">
<input type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" name="totp" placeholder="Authenticator code" id="totp-code" autofocus>
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input type="submit" class="button" value="Log in" />
</form>
{% elseif is_login_allowed == true %}
<p>Log in using your Nextcloud AIO passphrase:</p>
<form method="POST" action="api/auth/login" class="xhr">
<input type="password" autocomplete="current-password" name="password" placeholder="Password" id="master-password" data-input-show-password>
{% if is_two_factor_auth_enabled == true %}
<input type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" name="totp" placeholder="Authenticator code" id="totp-code">
{% endif %}
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input type="submit" class="button" value="Log in" />
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace AIO\Tests;
use AIO\Auth\TotpService;
use Base32\Base32;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the pure TOTP wrapper. TotpService touches no filesystem, so
* these run without the AIO data directory. easytotp reads the wall clock via
* its own TimeService, so codes are computed relative to the current window
* rather than from fixed RFC vectors.
*/
final class TotpServiceTest extends TestCase {
private TotpService $totp;
protected function setUp(): void {
$this->totp = new TotpService();
}
/** Compute the authenticator code the same way any TOTP app does. */
private function codeFor(string $secret, int $offsetSteps = 0): string {
$key = Base32::decode($secret);
$counter = intdiv(time(), 30) + $offsetSteps;
$hash = hash_hmac('sha1', pack('J', $counter), $key, true);
$offset = ord($hash[19]) & 0xf;
$binary = (unpack('N', substr($hash, $offset, 4))[1]) & 0x7fffffff;
return str_pad((string)($binary % 1000000), 6, '0', STR_PAD_LEFT);
}
public function testGeneratedSecretIs32Base32Chars(): void {
$secret = $this->totp->generateSecret();
$this->assertSame(32, strlen($secret));
$this->assertMatchesRegularExpression('#^[A-Z2-7]{32}$#', $secret);
}
public function testGeneratedSecretsAreRandom(): void {
$this->assertNotSame($this->totp->generateSecret(), $this->totp->generateSecret());
}
public function testProvisioningUriShape(): void {
$uri = $this->totp->getProvisioningUri('JBSWY3DPEHPK3PXP', 'admin', 'Nextcloud AIO');
$this->assertStringStartsWith('otpauth://totp/Nextcloud%20AIO:admin?', $uri);
$this->assertStringContainsString('secret=JBSWY3DPEHPK3PXP', $uri);
$this->assertStringContainsString('issuer=Nextcloud%20AIO', $uri);
$this->assertStringContainsString('algorithm=SHA1', $uri);
$this->assertStringContainsString('period=30', $uri);
$this->assertStringContainsString('digits=6', $uri);
}
public function testVerifyAcceptsACurrentCode(): void {
$secret = $this->totp->generateSecret();
[$ok, $counter] = $this->totp->verify($secret, $this->codeFor($secret));
$this->assertTrue($ok);
$this->assertIsInt($counter);
$this->assertGreaterThan(0, $counter);
}
public function testVerifyReturnsMatchedCounterForReplayProtection(): void {
$secret = $this->totp->generateSecret();
[$ok, $counter] = $this->totp->verify($secret, $this->codeFor($secret));
$this->assertTrue($ok);
// Passing the matched counter back as lastCounter must reject the same code.
[$replayOk] = $this->totp->verify($secret, $this->codeFor($secret), $counter);
$this->assertFalse($replayOk);
}
public function testVerifyToleratesTheAdjacentWindow(): void {
$secret = $this->totp->generateSecret();
// The previous window's code is within the ±1 drift and must still be accepted.
[$ok] = $this->totp->verify($secret, $this->codeFor($secret, -1));
$this->assertTrue($ok);
}
public function testVerifyRejectsCodeTwoWindowsAway(): void {
$secret = $this->totp->generateSecret();
[$ok] = $this->totp->verify($secret, $this->codeFor($secret, 2));
$this->assertFalse($ok);
}
public function testVerifyRejectsWrongCode(): void {
$secret = $this->totp->generateSecret();
$current = $this->codeFor($secret);
$wrong = $current === '000000' ? '111111' : '000000';
[$ok, $counter] = $this->totp->verify($secret, $wrong);
$this->assertFalse($ok);
$this->assertNull($counter);
}
/** Malformed input must be rejected before any crypto runs. */
public function testVerifyRejectsMalformedInput(): void {
$secret = $this->totp->generateSecret();
foreach (['', '12345', '1234567', 'abcdef', '12 345'] as $bad) {
[$ok] = $this->totp->verify($secret, $bad);
$this->assertFalse($ok, "expected '$bad' to be rejected");
}
}
public function testVerifyRejectsWhenSecretEmpty(): void {
[$ok] = $this->totp->verify('', '123456');
$this->assertFalse($ok);
}
}
+58
View File
@@ -0,0 +1,58 @@
// Test-side TOTP generator (RFC 6238, SHA-1, 6 digits, 30s) used by the
// two-factor E2E to produce the codes an authenticator app would show for the
// secret AIO displays during setup. Kept dependency-free (node:crypto only).
import { createHmac } from 'node:crypto';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Decode(input) {
let bits = '';
for (const ch of input.replace(/=+$/, '').toUpperCase()) {
const idx = BASE32_ALPHABET.indexOf(ch);
if (idx === -1) {
throw new Error(`Invalid base32 character: ${ch}`);
}
bits += idx.toString(2).padStart(5, '0');
}
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(parseInt(bits.slice(i, i + 8), 2));
}
return Buffer.from(bytes);
}
export const PERIOD = 30;
export function currentCounter(offsetSteps = 0) {
return Math.floor(Date.now() / 1000 / PERIOD) + offsetSteps;
}
export function totpCode(secret, counter = currentCounter()) {
const key = base32Decode(secret);
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const hash = createHmac('sha1', key).update(buf).digest();
const offset = hash[hash.length - 1] & 0xf;
const binary =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
return String(binary % 1_000_000).padStart(6, '0');
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Return a code for a window strictly newer than `usedCounter`, waiting for the
* clock to roll into it if necessary. Every successful verify consumes its
* counter (replay protection), so each fresh login/enable/disable needs a new
* window. Returns { code, counter }.
*/
export async function freshCode(secret, usedCounter = -1) {
while (currentCounter() <= usedCounter) {
await sleep(1000);
}
const counter = currentCounter();
return { code: totpCode(secret, counter), counter };
}
+105
View File
@@ -0,0 +1,105 @@
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { logInToContainersPage } from './helpers.js';
import { freshCode } from './totp-helper.js';
const CONFIG_FILE = '/mnt/docker-aio-config/data/configuration.json';
const GENERIC_LOGIN_ERROR = 'Login failed. Please check your credentials';
function readConfig() {
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
}
// Re-open the AIO login page and log in, filling the second factor when asked.
async function login(page, { password, totp }) {
await page.goto('./login');
await page.locator('#master-password').fill(password);
if (totp !== undefined) {
await page.locator('#totp-code').fill(totp);
}
await page.getByRole('button', { name: 'Log in' }).click();
}
test('Optional TOTP second factor: enable, enforce, disable', async ({ page }) => {
test.setTimeout(10 * 60 * 1000);
const containersPage = await logInToContainersPage(page);
const password = readConfig().password;
// While 2FA is off, a permanent warning nags the user to set it up, with a link
// to the 2FA section further down the page.
await expect(containersPage.locator('.notification--warning')).toContainText('Two-factor authentication is not set up');
const nagLink = containersPage.locator('.notification--warning a[href="#two-factor-auth-setup"]');
await expect(nagLink).toHaveText('enabling it below');
// Following the link anchors to the 2FA headline (which becomes the :target for
// the highlight) and expands the section below it.
await nagLink.click();
await expect(containersPage).toHaveURL(/#two-factor-auth-setup$/);
expect(await containersPage.evaluate(() =>
document.querySelector('#two-factor-auth-setup') === document.querySelector('h2:target'))).toBe(true);
await expect(containersPage.locator('h2#two-factor-auth-setup + details')).toHaveJSProperty('open', true);
// --- Enable --- (the section is already expanded via the anchor above)
const secret = (await containersPage.locator('#totp-setup-secret').innerText()).trim();
expect(secret).toMatch(/^[A-Z2-7]{32}$/);
// The QR is rendered client-side from the otpauth URI onto a <canvas>.
await expect(containersPage.locator('#totp-qr canvas')).toBeVisible();
// A wrong code must be rejected and must not enable 2FA.
await containersPage.locator('input[name="totp_code"]').fill('000000');
await containersPage.getByRole('button', { name: 'Enable two-factor authentication' }).click();
await expect(containersPage.locator('body')).toContainText('The entered code is not correct');
expect(readConfig().totp_secret ?? '').toBe('');
// Confirm with a correct code → enabled. The 422 above did not reload the page,
// so the same secret and the open <details> are still in place.
const enable = await freshCode(secret);
await containersPage.locator('input[name="totp_code"]').fill(enable.code);
await containersPage.getByRole('button', { name: 'Enable two-factor authentication' }).click();
await expect(containersPage.locator('body')).toContainText('Two-factor authentication is currently');
// A temporary notice confirms the change (asserted before it auto-dismisses).
await expect(containersPage.locator('.notification--notice')).toContainText('has been enabled');
// ...and the permanent nag is gone now that 2FA is enabled.
await expect(containersPage.locator('.notification--warning')).toHaveCount(0);
expect(readConfig().totp_secret).toBe(secret);
// --- Enforce on login ---
await containersPage.getByRole('button', { name: 'Log out' }).click();
await containersPage.waitForURL('./login');
// The code field only appears once 2FA is enabled.
await expect(containersPage.locator('#totp-code')).toBeVisible();
// Right password + wrong code → generic error, nothing is consumed.
await login(containersPage, { password, totp: '000000' });
await expect(containersPage.locator('body')).toContainText(GENERIC_LOGIN_ERROR);
// One fresh code, reused across the next two attempts. Enabling consumed the
// current window's code, so use a window past that one.
const fresh = await freshCode(secret, enable.counter);
// Wrong password + this valid code → the SAME generic error (no factor is
// singled out), and the code must NOT be consumed by this failed attempt...
await login(containersPage, { password: 'definitely-the-wrong-passphrase', totp: fresh.code });
await expect(containersPage.locator('body')).toContainText(GENERIC_LOGIN_ERROR);
// ...so fixing the password and re-submitting the SAME code succeeds.
await login(containersPage, { password, totp: fresh.code });
await containersPage.waitForURL('./containers');
// --- Disable --- (needs a code from a window past the one the login consumed)
await containersPage.getByText('disable the authenticator-app second factor').click();
const disable = await freshCode(secret, fresh.counter);
await containersPage.locator('input[name="totp_code"]').fill(disable.code);
await containersPage.getByRole('button', { name: 'Disable two-factor authentication' }).click();
await expect(containersPage.locator('body')).toContainText('Scan this QR code');
await expect(containersPage.locator('.notification--notice')).toContainText('has been disabled');
// The permanent nag reappears now that 2FA is off again.
await expect(containersPage.locator('.notification--warning')).toContainText('Two-factor authentication is not set up');
expect(readConfig().totp_secret ?? '').toBe('');
// Login no longer asks for a code.
await containersPage.getByRole('button', { name: 'Log out' }).click();
await containersPage.waitForURL('./login');
await expect(containersPage.locator('#totp-code')).toHaveCount(0);
});