feat(AppFramework): Add missing NoTwoFactorRequired attribute

It's in our documentation but was never implemented.

Signed-off-by: Carl Schwan <carl.schwan@nextcloud.com>
pull/55474/head
Carl Schwan 2025-09-17 15:32:11 +07:00
parent 5f6e6b305f
commit b2ed0fa37c
No known key found for this signature in database
GPG Key ID: 02325448204E452A
11 changed files with 165 additions and 97 deletions

@ -17,6 +17,7 @@ use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
use OCP\AppFramework\Http\Attribute\BruteForceProtection; use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\NoTwoFactorRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\ContentSecurityPolicy;
@ -61,13 +62,10 @@ class ThemingController extends Controller {
} }
/** /**
* @param string $setting
* @param string $value
* @return DataResponse
* @throws NotPermittedException * @throws NotPermittedException
*/ */
#[AuthorizedAdminSetting(settings: Admin::class)] #[AuthorizedAdminSetting(settings: Admin::class)]
public function updateStylesheet($setting, $value) { public function updateStylesheet(string $setting, string $value): DataResponse {
$value = trim($value); $value = trim($value);
$error = null; $error = null;
$saved = false; $saved = false;
@ -153,13 +151,10 @@ class ThemingController extends Controller {
} }
/** /**
* @param string $setting
* @param mixed $value
* @return DataResponse
* @throws NotPermittedException * @throws NotPermittedException
*/ */
#[AuthorizedAdminSetting(settings: Admin::class)] #[AuthorizedAdminSetting(settings: Admin::class)]
public function updateAppMenu($setting, $value) { public function updateAppMenu(string $setting, mixed $value): DataResponse {
$error = null; $error = null;
switch ($setting) { switch ($setting) {
case 'defaultApps': case 'defaultApps':
@ -204,7 +199,6 @@ class ThemingController extends Controller {
} }
/** /**
* @return DataResponse
* @throws NotPermittedException * @throws NotPermittedException
*/ */
#[AuthorizedAdminSetting(settings: Admin::class)] #[AuthorizedAdminSetting(settings: Admin::class)]
@ -367,7 +361,6 @@ class ThemingController extends Controller {
/** /**
* @NoSameSiteCookieRequired * @NoSameSiteCookieRequired
* @NoTwoFactorRequired
* *
* Get the CSS stylesheet for a theme * Get the CSS stylesheet for a theme
* *
@ -381,6 +374,7 @@ class ThemingController extends Controller {
*/ */
#[PublicPage] #[PublicPage]
#[NoCSRFRequired] #[NoCSRFRequired]
#[NoTwoFactorRequired]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) { public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) {
$themes = $this->themesService->getThemes(); $themes = $this->themesService->getThemes();

@ -13,6 +13,7 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\NoTwoFactorRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\JSONResponse;
@ -34,13 +35,12 @@ class CSRFTokenController extends Controller {
* *
* 200: CSRF token returned * 200: CSRF token returned
* 403: Strict cookie check failed * 403: Strict cookie check failed
*
* @NoTwoFactorRequired
*/ */
#[PublicPage] #[PublicPage]
#[NoCSRFRequired] #[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/csrftoken')] #[FrontpageRoute(verb: 'GET', url: '/csrftoken')]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
#[NoTwoFactorRequired]
public function index(): JSONResponse { public function index(): JSONResponse {
if (!$this->request->passesStrictCookieCheck()) { if (!$this->request->passesStrictCookieCheck()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN); return new JSONResponse([], Http::STATUS_FORBIDDEN);

@ -13,6 +13,7 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\NoTwoFactorRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\FileDisplayResponse;
@ -42,16 +43,15 @@ class JsController extends Controller {
/** /**
* @NoSameSiteCookieRequired * @NoSameSiteCookieRequired
* @NoTwoFactorRequired
* *
* @param string $fileName js filename with extension * @param string $fileName js filename with extension
* @param string $appName js folder name * @param string $appName js folder name
* @return FileDisplayResponse|NotFoundResponse
*/ */
#[PublicPage] #[PublicPage]
#[NoCSRFRequired] #[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/js/{appName}/{fileName}')] #[FrontpageRoute(verb: 'GET', url: '/js/{appName}/{fileName}')]
public function getJs(string $fileName, string $appName): Response { #[NoTwoFactorRequired]
public function getJs(string $fileName, string $appName): FileDisplayResponse|NotFoundResponse {
try { try {
$folder = $this->appData->getFolder($appName); $folder = $this->appData->getFolder($appName);
$gzip = false; $gzip = false;
@ -76,15 +76,11 @@ class JsController extends Controller {
} }
/** /**
* @NoTwoFactorRequired
*
* @param ISimpleFolder $folder
* @param string $fileName
* @param bool $gzip is set to true if we use the gzip file * @param bool $gzip is set to true if we use the gzip file
* @return ISimpleFile
* *
* @throws NotFoundException * @throws NotFoundException
*/ */
#[NoTwoFactorRequired]
private function getFile(ISimpleFolder $folder, string $fileName, bool &$gzip): ISimpleFile { private function getFile(ISimpleFolder $folder, string $fileName, bool &$gzip): ISimpleFile {
$encoding = $this->request->getHeader('Accept-Encoding'); $encoding = $this->request->getHeader('Accept-Encoding');

@ -16,6 +16,7 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\NoTwoFactorRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\DataDisplayResponse;
@ -75,12 +76,10 @@ class OCJSController extends Controller {
); );
} }
/**
* @NoTwoFactorRequired
*/
#[PublicPage] #[PublicPage]
#[NoCSRFRequired] #[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/core/js/oc.js')] #[FrontpageRoute(verb: 'GET', url: '/core/js/oc.js')]
#[NoTwoFactorRequired]
public function getConfig(): DataDisplayResponse { public function getConfig(): DataDisplayResponse {
$data = $this->helper->getConfig(); $data = $this->helper->getConfig();

@ -7,6 +7,7 @@
*/ */
namespace OC\Core\Controller; namespace OC\Core\Controller;
use OC\AppFramework\Http\Attributes\TwoFactorSetUpDoneRequired;
use OC\Authentication\TwoFactorAuth\Manager; use OC\Authentication\TwoFactorAuth\Manager;
use OC_User; use OC_User;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
@ -67,16 +68,11 @@ class TwoFactorChallengeController extends Controller {
return [$regular, $backup]; return [$regular, $backup];
} }
/**
* @TwoFactorSetUpDoneRequired
*
* @param string $redirect_url
* @return StandaloneTemplateResponse
*/
#[NoAdminRequired] #[NoAdminRequired]
#[NoCSRFRequired] #[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/login/selectchallenge')] #[FrontpageRoute(verb: 'GET', url: '/login/selectchallenge')]
public function selectChallenge($redirect_url) { #[TwoFactorSetUpDoneRequired]
public function selectChallenge(string $redirect_url): StandaloneTemplateResponse {
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
$providerSet = $this->twoFactorManager->getProviderSet($user); $providerSet = $this->twoFactorManager->getProviderSet($user);
$allProviders = $providerSet->getProviders(); $allProviders = $providerSet->getProviders();
@ -95,18 +91,12 @@ class TwoFactorChallengeController extends Controller {
return new StandaloneTemplateResponse($this->appName, 'twofactorselectchallenge', $data, 'guest'); return new StandaloneTemplateResponse($this->appName, 'twofactorselectchallenge', $data, 'guest');
} }
/**
* @TwoFactorSetUpDoneRequired
*
* @param string $challengeProviderId
* @param string $redirect_url
* @return StandaloneTemplateResponse|RedirectResponse
*/
#[NoAdminRequired] #[NoAdminRequired]
#[NoCSRFRequired] #[NoCSRFRequired]
#[UseSession] #[UseSession]
#[TwoFactorSetUpDoneRequired]
#[FrontpageRoute(verb: 'GET', url: '/login/challenge/{challengeProviderId}')] #[FrontpageRoute(verb: 'GET', url: '/login/challenge/{challengeProviderId}')]
public function showChallenge($challengeProviderId, $redirect_url) { public function showChallenge(string $challengeProviderId, string $redirect_url): StandaloneTemplateResponse|RedirectResponse {
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
$providerSet = $this->twoFactorManager->getProviderSet($user); $providerSet = $this->twoFactorManager->getProviderSet($user);
$provider = $providerSet->getProvider($challengeProviderId); $provider = $providerSet->getProvider($challengeProviderId);
@ -148,21 +138,13 @@ class TwoFactorChallengeController extends Controller {
return $response; return $response;
} }
/**
* @TwoFactorSetUpDoneRequired
*
*
* @param string $challengeProviderId
* @param string $challenge
* @param string $redirect_url
* @return RedirectResponse
*/
#[NoAdminRequired] #[NoAdminRequired]
#[NoCSRFRequired] #[NoCSRFRequired]
#[UseSession] #[UseSession]
#[FrontpageRoute(verb: 'POST', url: '/login/challenge/{challengeProviderId}')] #[FrontpageRoute(verb: 'POST', url: '/login/challenge/{challengeProviderId}')]
#[TwoFactorSetUpDoneRequired]
#[UserRateLimit(limit: 5, period: 100)] #[UserRateLimit(limit: 5, period: 100)]
public function solveChallenge($challengeProviderId, $challenge, $redirect_url = null) { public function solveChallenge(string $challengeProviderId, string $challenge, ?string $redirect_url = null): RedirectResponse {
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
$provider = $this->twoFactorManager->getProvider($user, $challengeProviderId); $provider = $this->twoFactorManager->getProvider($user, $challengeProviderId);
if (is_null($provider)) { if (is_null($provider)) {

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace OC\Core\Middleware; namespace OC\Core\Middleware;
use Exception; use Exception;
use OC\AppFramework\Http\Attributes\TwoFactorSetUpDoneRequired;
use OC\Authentication\Exceptions\TwoFactorAuthRequiredException; use OC\Authentication\Exceptions\TwoFactorAuthRequiredException;
use OC\Authentication\Exceptions\UserAlreadyLoggedInException; use OC\Authentication\Exceptions\UserAlreadyLoggedInException;
use OC\Authentication\TwoFactorAuth\Manager; use OC\Authentication\TwoFactorAuth\Manager;
@ -18,6 +19,7 @@ use OC\Core\Controller\TwoFactorChallengeController;
use OC\User\Session; use OC\User\Session;
use OCA\TwoFactorNextcloudNotification\Controller\APIController; use OCA\TwoFactorNextcloudNotification\Controller\APIController;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoTwoFactorRequired;
use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Middleware; use OCP\AppFramework\Middleware;
use OCP\AppFramework\Utility\IControllerMethodReflector; use OCP\AppFramework\Utility\IControllerMethodReflector;
@ -26,6 +28,8 @@ use OCP\IRequest;
use OCP\ISession; use OCP\ISession;
use OCP\IURLGenerator; use OCP\IURLGenerator;
use OCP\IUser; use OCP\IUser;
use Psr\Log\LoggerInterface;
use ReflectionMethod;
class TwoFactorMiddleware extends Middleware { class TwoFactorMiddleware extends Middleware {
public function __construct( public function __construct(
@ -35,6 +39,7 @@ class TwoFactorMiddleware extends Middleware {
private IURLGenerator $urlGenerator, private IURLGenerator $urlGenerator,
private IControllerMethodReflector $reflector, private IControllerMethodReflector $reflector,
private IRequest $request, private IRequest $request,
private LoggerInterface $logger,
) { ) {
} }
@ -43,7 +48,9 @@ class TwoFactorMiddleware extends Middleware {
* @param string $methodName * @param string $methodName
*/ */
public function beforeController($controller, $methodName) { public function beforeController($controller, $methodName) {
if ($this->reflector->hasAnnotation('NoTwoFactorRequired')) { $reflectionMethod = new ReflectionMethod($controller, $methodName);
if ($this->hasAnnotationOrAttribute($reflectionMethod, 'NoTwoFactorRequired', NoTwoFactorRequired::class)) {
// Route handler explicitly marked to work without finished 2FA are // Route handler explicitly marked to work without finished 2FA are
// not blocked // not blocked
return; return;
@ -56,7 +63,7 @@ class TwoFactorMiddleware extends Middleware {
if ($controller instanceof TwoFactorChallengeController if ($controller instanceof TwoFactorChallengeController
&& $this->userSession->getUser() !== null && $this->userSession->getUser() !== null
&& !$this->reflector->hasAnnotation('TwoFactorSetUpDoneRequired')) { && !$reflectionMethod->getAttributes(TwoFactorSetUpDoneRequired::class)) {
$providers = $this->twoFactorManager->getProviderSet($this->userSession->getUser()); $providers = $this->twoFactorManager->getProviderSet($this->userSession->getUser());
if (!($providers->getPrimaryProviders() === [] && !$providers->isProviderMissing())) { if (!($providers->getPrimaryProviders() === [] && !$providers->isProviderMissing())) {
@ -86,7 +93,7 @@ class TwoFactorMiddleware extends Middleware {
|| $this->session->exists('app_api') // authenticated using an AppAPI Auth || $this->session->exists('app_api') // authenticated using an AppAPI Auth
|| $this->twoFactorManager->isTwoFactorAuthenticated($user)) { || $this->twoFactorManager->isTwoFactorAuthenticated($user)) {
$this->checkTwoFactor($controller, $methodName, $user); $this->checkTwoFactor($controller, $user);
} elseif ($controller instanceof TwoFactorChallengeController) { } elseif ($controller instanceof TwoFactorChallengeController) {
// Allow access to the two-factor controllers only if two-factor authentication // Allow access to the two-factor controllers only if two-factor authentication
// is in progress. // is in progress.
@ -96,7 +103,7 @@ class TwoFactorMiddleware extends Middleware {
// TODO: dont check/enforce 2FA if a auth token is used // TODO: dont check/enforce 2FA if a auth token is used
} }
private function checkTwoFactor(Controller $controller, $methodName, IUser $user) { private function checkTwoFactor(Controller $controller, IUser $user) {
// If two-factor auth is in progress disallow access to any controllers // If two-factor auth is in progress disallow access to any controllers
// defined within "LoginController". // defined within "LoginController".
$needsSecondFactor = $this->twoFactorManager->needsSecondFactor($user); $needsSecondFactor = $this->twoFactorManager->needsSecondFactor($user);
@ -130,4 +137,26 @@ class TwoFactorMiddleware extends Middleware {
throw $exception; throw $exception;
} }
/**
* @template T
*
* @param ReflectionMethod $reflectionMethod
* @param ?string $annotationName
* @param class-string<T> $attributeClass
* @return boolean
*/
protected function hasAnnotationOrAttribute(ReflectionMethod $reflectionMethod, ?string $annotationName, string $attributeClass): bool {
if (!empty($reflectionMethod->getAttributes($attributeClass))) {
return true;
}
if ($annotationName && $this->reflector->hasAnnotation($annotationName)) {
$this->logger->debug($reflectionMethod->getDeclaringClass()->getName() . '::' . $reflectionMethod->getName() . ' uses the @' . $annotationName . ' annotation and should use the #[' . $attributeClass . '] attribute instead');
return true;
}
return false;
}
} }

@ -92,6 +92,7 @@ return array(
'OCP\\AppFramework\\Http\\Attribute\\IgnoreOpenAPI' => $baseDir . '/lib/public/AppFramework/Http/Attribute/IgnoreOpenAPI.php', 'OCP\\AppFramework\\Http\\Attribute\\IgnoreOpenAPI' => $baseDir . '/lib/public/AppFramework/Http/Attribute/IgnoreOpenAPI.php',
'OCP\\AppFramework\\Http\\Attribute\\NoAdminRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/NoAdminRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\NoAdminRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/NoAdminRequired.php',
'OCP\\AppFramework\\Http\\Attribute\\NoCSRFRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/NoCSRFRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\NoCSRFRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/NoCSRFRequired.php',
'OCP\\AppFramework\\Http\\Attribute\\NoTwoFactorRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/NoTwoFactorRequired.php',
'OCP\\AppFramework\\Http\\Attribute\\OpenAPI' => $baseDir . '/lib/public/AppFramework/Http/Attribute/OpenAPI.php', 'OCP\\AppFramework\\Http\\Attribute\\OpenAPI' => $baseDir . '/lib/public/AppFramework/Http/Attribute/OpenAPI.php',
'OCP\\AppFramework\\Http\\Attribute\\PasswordConfirmationRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/PasswordConfirmationRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\PasswordConfirmationRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/PasswordConfirmationRequired.php',
'OCP\\AppFramework\\Http\\Attribute\\PublicPage' => $baseDir . '/lib/public/AppFramework/Http/Attribute/PublicPage.php', 'OCP\\AppFramework\\Http\\Attribute\\PublicPage' => $baseDir . '/lib/public/AppFramework/Http/Attribute/PublicPage.php',
@ -1053,6 +1054,7 @@ return array(
'OC\\AppFramework\\Bootstrap\\ServiceRegistration' => $baseDir . '/lib/private/AppFramework/Bootstrap/ServiceRegistration.php', 'OC\\AppFramework\\Bootstrap\\ServiceRegistration' => $baseDir . '/lib/private/AppFramework/Bootstrap/ServiceRegistration.php',
'OC\\AppFramework\\DependencyInjection\\DIContainer' => $baseDir . '/lib/private/AppFramework/DependencyInjection/DIContainer.php', 'OC\\AppFramework\\DependencyInjection\\DIContainer' => $baseDir . '/lib/private/AppFramework/DependencyInjection/DIContainer.php',
'OC\\AppFramework\\Http' => $baseDir . '/lib/private/AppFramework/Http.php', 'OC\\AppFramework\\Http' => $baseDir . '/lib/private/AppFramework/Http.php',
'OC\\AppFramework\\Http\\Attributes\\TwoFactorSetUpDoneRequired' => $baseDir . '/lib/private/AppFramework/Http/Attributes/TwoFactorSetUpDoneRequired.php',
'OC\\AppFramework\\Http\\Dispatcher' => $baseDir . '/lib/private/AppFramework/Http/Dispatcher.php', 'OC\\AppFramework\\Http\\Dispatcher' => $baseDir . '/lib/private/AppFramework/Http/Dispatcher.php',
'OC\\AppFramework\\Http\\Output' => $baseDir . '/lib/private/AppFramework/Http/Output.php', 'OC\\AppFramework\\Http\\Output' => $baseDir . '/lib/private/AppFramework/Http/Output.php',
'OC\\AppFramework\\Http\\Request' => $baseDir . '/lib/private/AppFramework/Http/Request.php', 'OC\\AppFramework\\Http\\Request' => $baseDir . '/lib/private/AppFramework/Http/Request.php',

@ -133,6 +133,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\AppFramework\\Http\\Attribute\\IgnoreOpenAPI' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/IgnoreOpenAPI.php', 'OCP\\AppFramework\\Http\\Attribute\\IgnoreOpenAPI' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/IgnoreOpenAPI.php',
'OCP\\AppFramework\\Http\\Attribute\\NoAdminRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/NoAdminRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\NoAdminRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/NoAdminRequired.php',
'OCP\\AppFramework\\Http\\Attribute\\NoCSRFRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/NoCSRFRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\NoCSRFRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/NoCSRFRequired.php',
'OCP\\AppFramework\\Http\\Attribute\\NoTwoFactorRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/NoTwoFactorRequired.php',
'OCP\\AppFramework\\Http\\Attribute\\OpenAPI' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/OpenAPI.php', 'OCP\\AppFramework\\Http\\Attribute\\OpenAPI' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/OpenAPI.php',
'OCP\\AppFramework\\Http\\Attribute\\PasswordConfirmationRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/PasswordConfirmationRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\PasswordConfirmationRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/PasswordConfirmationRequired.php',
'OCP\\AppFramework\\Http\\Attribute\\PublicPage' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/PublicPage.php', 'OCP\\AppFramework\\Http\\Attribute\\PublicPage' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/PublicPage.php',
@ -1094,6 +1095,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\AppFramework\\Bootstrap\\ServiceRegistration' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Bootstrap/ServiceRegistration.php', 'OC\\AppFramework\\Bootstrap\\ServiceRegistration' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Bootstrap/ServiceRegistration.php',
'OC\\AppFramework\\DependencyInjection\\DIContainer' => __DIR__ . '/../../..' . '/lib/private/AppFramework/DependencyInjection/DIContainer.php', 'OC\\AppFramework\\DependencyInjection\\DIContainer' => __DIR__ . '/../../..' . '/lib/private/AppFramework/DependencyInjection/DIContainer.php',
'OC\\AppFramework\\Http' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http.php', 'OC\\AppFramework\\Http' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http.php',
'OC\\AppFramework\\Http\\Attributes\\TwoFactorSetUpDoneRequired' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Attributes/TwoFactorSetUpDoneRequired.php',
'OC\\AppFramework\\Http\\Dispatcher' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Dispatcher.php', 'OC\\AppFramework\\Http\\Dispatcher' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Dispatcher.php',
'OC\\AppFramework\\Http\\Output' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Output.php', 'OC\\AppFramework\\Http\\Output' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Output.php',
'OC\\AppFramework\\Http\\Request' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Request.php', 'OC\\AppFramework\\Http\\Request' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Request.php',

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\AppFramework\Http\Attributes;
use Attribute;
#[Attribute]
class TwoFactorSetUpDoneRequired {
}

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\AppFramework\Http\Attribute;
use Attribute;
/**
* A user can access the page before the two-factor challenge has been passed
* (use this wisely and only in two-factor auth apps, e.g. to allow setup during
* login).
*
* @since 33.0.0
*/
#[Attribute]
class NoTwoFactorRequired {
}

@ -8,6 +8,7 @@
namespace Test\Core\Middleware; namespace Test\Core\Middleware;
use OC\AppFramework\Http\Attributes\TwoFactorSetUpDoneRequired;
use OC\AppFramework\Http\Request; use OC\AppFramework\Http\Request;
use OC\Authentication\Exceptions\TwoFactorAuthRequiredException; use OC\Authentication\Exceptions\TwoFactorAuthRequiredException;
use OC\Authentication\Exceptions\UserAlreadyLoggedInException; use OC\Authentication\Exceptions\UserAlreadyLoggedInException;
@ -17,7 +18,9 @@ use OC\Core\Controller\TwoFactorChallengeController;
use OC\Core\Middleware\TwoFactorMiddleware; use OC\Core\Middleware\TwoFactorMiddleware;
use OC\User\Session; use OC\User\Session;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoTwoFactorRequired;
use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Utility\IControllerMethodReflector; use OCP\AppFramework\Utility\IControllerMethodReflector;
use OCP\Authentication\TwoFactorAuth\ALoginSetupController; use OCP\Authentication\TwoFactorAuth\ALoginSetupController;
use OCP\Authentication\TwoFactorAuth\IProvider; use OCP\Authentication\TwoFactorAuth\IProvider;
@ -28,33 +31,52 @@ use OCP\ISession;
use OCP\IURLGenerator; use OCP\IURLGenerator;
use OCP\IUser; use OCP\IUser;
use OCP\IUserSession; use OCP\IUserSession;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase; use Test\TestCase;
class TwoFactorMiddlewareTest extends TestCase { class HasTwoFactorAnnotationController extends Controller {
/** @var Manager|MockObject */ #[NoTwoFactorRequired]
private $twoFactorManager; public function index(): Response {
return new Response();
/** @var IUserSession|MockObject */ }
private $userSession; }
/** @var ISession|MockObject */
private $session;
/** @var IURLGenerator|MockObject */ class LoginSetupController extends ALoginSetupController {
private $urlGenerator; public function index(): Response {
return new Response();
}
}
/** @var IControllerMethodReflector|MockObject */ class NoTwoFactorAnnotationController extends Controller {
private $reflector; public function index(): Response {
return new Response();
}
}
/** @var IRequest|MockObject */ class NoTwoFactorChallengeAnnotationController extends TwoFactorChallengeController {
private $request; public function index(): Response {
return new Response();
}
}
/** @var TwoFactorMiddleware */ class HasTwoFactorSetUpDoneAnnotationController extends TwoFactorChallengeController {
private $middleware; #[TwoFactorSetUpDoneRequired]
public function index(): Response {
return new Response();
}
}
/** @var Controller */ class TwoFactorMiddlewareTest extends TestCase {
private $controller; private Manager&MockObject $twoFactorManager;
private IUserSession&MockObject $userSession;
private ISession&MockObject $session;
private IURLGenerator&MockObject $urlGenerator;
private IControllerMethodReflector&MockObject $reflector;
private IRequest $request;
private TwoFactorMiddleware $middleware;
private LoggerInterface&MockObject $logger;
protected function setUp(): void { protected function setUp(): void {
parent::setUp(); parent::setUp();
@ -68,6 +90,7 @@ class TwoFactorMiddlewareTest extends TestCase {
$this->session = $this->createMock(ISession::class); $this->session = $this->createMock(ISession::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class); $this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->reflector = $this->createMock(IControllerMethodReflector::class); $this->reflector = $this->createMock(IControllerMethodReflector::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->request = new Request( $this->request = new Request(
[ [
'server' => [ 'server' => [
@ -78,8 +101,7 @@ class TwoFactorMiddlewareTest extends TestCase {
$this->createMock(IConfig::class) $this->createMock(IConfig::class)
); );
$this->middleware = new TwoFactorMiddleware($this->twoFactorManager, $this->userSession, $this->session, $this->urlGenerator, $this->reflector, $this->request); $this->middleware = new TwoFactorMiddleware($this->twoFactorManager, $this->userSession, $this->session, $this->urlGenerator, $this->reflector, $this->request, $this->logger);
$this->controller = $this->createMock(Controller::class);
} }
public function testBeforeControllerNotLoggedIn(): void { public function testBeforeControllerNotLoggedIn(): void {
@ -90,12 +112,14 @@ class TwoFactorMiddlewareTest extends TestCase {
$this->userSession->expects($this->never()) $this->userSession->expects($this->never())
->method('getUser'); ->method('getUser');
$this->middleware->beforeController($this->controller, 'index'); $controller = $this->getMockBuilder(NoTwoFactorAnnotationController::class)
->disableOriginalConstructor()
->getMock();
$this->middleware->beforeController($controller, 'index');
} }
public function testBeforeSetupController(): void { public function testBeforeSetupController(): void {
$user = $this->createMock(IUser::class); $user = $this->createMock(IUser::class);
$controller = $this->createMock(ALoginSetupController::class);
$this->userSession->expects($this->any()) $this->userSession->expects($this->any())
->method('getUser') ->method('getUser')
->willReturn($user); ->willReturn($user);
@ -105,7 +129,7 @@ class TwoFactorMiddlewareTest extends TestCase {
$this->userSession->expects($this->never()) $this->userSession->expects($this->never())
->method('isLoggedIn'); ->method('isLoggedIn');
$this->middleware->beforeController($controller, 'create'); $this->middleware->beforeController(new LoginSetupController('foo', $this->request), 'index');
} }
public function testBeforeControllerNoTwoFactorCheckNeeded(): void { public function testBeforeControllerNoTwoFactorCheckNeeded(): void {
@ -122,7 +146,10 @@ class TwoFactorMiddlewareTest extends TestCase {
->with($user) ->with($user)
->willReturn(false); ->willReturn(false);
$this->middleware->beforeController($this->controller, 'index'); $controller = $this->getMockBuilder(NoTwoFactorAnnotationController::class)
->disableOriginalConstructor()
->getMock();
$this->middleware->beforeController($controller, 'index');
} }
@ -146,7 +173,10 @@ class TwoFactorMiddlewareTest extends TestCase {
->with($user) ->with($user)
->willReturn(true); ->willReturn(true);
$this->middleware->beforeController($this->controller, 'index'); $controller = $this->getMockBuilder(NoTwoFactorAnnotationController::class)
->disableOriginalConstructor()
->getMock();
$this->middleware->beforeController($controller, 'index');
} }
@ -155,9 +185,6 @@ class TwoFactorMiddlewareTest extends TestCase {
$user = $this->createMock(IUser::class); $user = $this->createMock(IUser::class);
$this->reflector
->method('hasAnnotation')
->willReturn(false);
$this->userSession->expects($this->once()) $this->userSession->expects($this->once())
->method('isLoggedIn') ->method('isLoggedIn')
->willReturn(true); ->willReturn(true);
@ -173,7 +200,7 @@ class TwoFactorMiddlewareTest extends TestCase {
->with($user) ->with($user)
->willReturn(false); ->willReturn(false);
$twoFactorChallengeController = $this->getMockBuilder(TwoFactorChallengeController::class) $twoFactorChallengeController = $this->getMockBuilder(NoTwoFactorChallengeAnnotationController::class)
->disableOriginalConstructor() ->disableOriginalConstructor()
->getMock(); ->getMock();
$this->middleware->beforeController($twoFactorChallengeController, 'index'); $this->middleware->beforeController($twoFactorChallengeController, 'index');
@ -188,7 +215,8 @@ class TwoFactorMiddlewareTest extends TestCase {
->willReturn('test/url'); ->willReturn('test/url');
$expected = new RedirectResponse('test/url'); $expected = new RedirectResponse('test/url');
$this->assertEquals($expected, $this->middleware->afterException($this->controller, 'index', $ex)); $controller = new HasTwoFactorAnnotationController('foo', $this->request);
$this->assertEquals($expected, $this->middleware->afterException($controller, 'index', $ex));
} }
public function testAfterException(): void { public function testAfterException(): void {
@ -200,17 +228,13 @@ class TwoFactorMiddlewareTest extends TestCase {
->willReturn('redirect/url'); ->willReturn('redirect/url');
$expected = new RedirectResponse('redirect/url'); $expected = new RedirectResponse('redirect/url');
$this->assertEquals($expected, $this->middleware->afterException($this->controller, 'index', $ex)); $controller = new HasTwoFactorAnnotationController('foo', $this->request);
$this->assertEquals($expected, $this->middleware->afterException($controller, 'index', $ex));
} }
public function testRequires2FASetupDoneAnnotated(): void { public function testRequires2FASetupDoneAnnotated(): void {
$user = $this->createMock(IUser::class); $user = $this->createMock(IUser::class);
$this->reflector
->method('hasAnnotation')
->willReturnCallback(function (string $annotation) {
return $annotation === 'TwoFactorSetUpDoneRequired';
});
$this->userSession->expects($this->once()) $this->userSession->expects($this->once())
->method('isLoggedIn') ->method('isLoggedIn')
->willReturn(true); ->willReturn(true);
@ -228,10 +252,10 @@ class TwoFactorMiddlewareTest extends TestCase {
$this->expectException(UserAlreadyLoggedInException::class); $this->expectException(UserAlreadyLoggedInException::class);
$twoFactorChallengeController = $this->getMockBuilder(TwoFactorChallengeController::class) $controller = $this->getMockBuilder(HasTwoFactorSetUpDoneAnnotationController::class)
->disableOriginalConstructor() ->disableOriginalConstructor()
->getMock(); ->getMock();
$this->middleware->beforeController($twoFactorChallengeController, 'index'); $this->middleware->beforeController($controller, 'index');
} }
public static function dataRequires2FASetupDone(): array { public static function dataRequires2FASetupDone(): array {
@ -243,7 +267,7 @@ class TwoFactorMiddlewareTest extends TestCase {
]; ];
} }
#[\PHPUnit\Framework\Attributes\DataProvider('dataRequires2FASetupDone')] #[DataProvider('dataRequires2FASetupDone')]
public function testRequires2FASetupDone(bool $hasProvider, bool $missingProviders, bool $expectEception): void { public function testRequires2FASetupDone(bool $hasProvider, bool $missingProviders, bool $expectEception): void {
if ($hasProvider) { if ($hasProvider) {
$provider = $this->createMock(IProvider::class); $provider = $this->createMock(IProvider::class);
@ -257,9 +281,6 @@ class TwoFactorMiddlewareTest extends TestCase {
$user = $this->createMock(IUser::class); $user = $this->createMock(IUser::class);
$this->reflector
->method('hasAnnotation')
->willReturn(false);
$this->userSession $this->userSession
->method('getUser') ->method('getUser')
->willReturn($user); ->willReturn($user);
@ -278,9 +299,9 @@ class TwoFactorMiddlewareTest extends TestCase {
$this->assertTrue(true); $this->assertTrue(true);
} }
$twoFactorChallengeController = $this->getMockBuilder(TwoFactorChallengeController::class) $controller = $this->getMockBuilder(NoTwoFactorChallengeAnnotationController::class)
->disableOriginalConstructor() ->disableOriginalConstructor()
->getMock(); ->getMock();
$this->middleware->beforeController($twoFactorChallengeController, 'index'); $this->middleware->beforeController($controller, 'index');
} }
} }