mirror of
https://github.com/nextcloud/all-in-one.git
synced 2026-09-20 10:10:23 +00:00
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:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1669
-13
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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(),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user