From a2435cdb1508fa011023aab92e2104e7bc8010b8 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Tue, 20 Oct 2026 19:21:01 -0400 Subject: [PATCH] feat(2fa): allow apps to ask for the second factor again in a session Add IChallengeRequester. It marks the current session as needing the second factor again, using the same mechanism as the login, so core's two-factor middleware redirects every request of the session to the challenge until it is solved and then returns to the given URL. Apps can use it to confirm it is really the user before a sensitive action in a session that looks suspicious. Sessions created with an app password cannot answer a challenge and are refused. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + .../TwoFactorAuth/ChallengeRequester.php | 45 +++++++++ lib/private/Server.php | 1 + .../TwoFactorAuth/IChallengeRequester.php | 41 +++++++++ .../TwoFactorAuth/ChallengeRequesterTest.php | 91 +++++++++++++++++++ 6 files changed, 182 insertions(+) create mode 100644 lib/private/Authentication/TwoFactorAuth/ChallengeRequester.php create mode 100644 lib/public/Authentication/TwoFactorAuth/IChallengeRequester.php create mode 100644 tests/lib/Authentication/TwoFactorAuth/ChallengeRequesterTest.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 4467e4e6179c2..b95d8ba26fe4b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -233,6 +233,7 @@ 'OCP\\Authentication\\TwoFactorAuth\\ALoginSetupController' => $baseDir . '/lib/public/Authentication/TwoFactorAuth/ALoginSetupController.php', 'OCP\\Authentication\\TwoFactorAuth\\IActivatableAtLogin' => $baseDir . '/lib/public/Authentication/TwoFactorAuth/IActivatableAtLogin.php', 'OCP\\Authentication\\TwoFactorAuth\\IActivatableByAdmin' => $baseDir . '/lib/public/Authentication/TwoFactorAuth/IActivatableByAdmin.php', + 'OCP\\Authentication\\TwoFactorAuth\\IChallengeRequester' => $baseDir . '/lib/public/Authentication/TwoFactorAuth/IChallengeRequester.php', 'OCP\\Authentication\\TwoFactorAuth\\IDeactivatableByAdmin' => $baseDir . '/lib/public/Authentication/TwoFactorAuth/IDeactivatableByAdmin.php', 'OCP\\Authentication\\TwoFactorAuth\\ILoginSetupProvider' => $baseDir . '/lib/public/Authentication/TwoFactorAuth/ILoginSetupProvider.php', 'OCP\\Authentication\\TwoFactorAuth\\IPersonalProviderSettings' => $baseDir . '/lib/public/Authentication/TwoFactorAuth/IPersonalProviderSettings.php', @@ -1355,6 +1356,7 @@ 'OC\\Authentication\\Token\\RemoteWipe' => $baseDir . '/lib/private/Authentication/Token/RemoteWipe.php', 'OC\\Authentication\\Token\\TokenCleanupJob' => $baseDir . '/lib/private/Authentication/Token/TokenCleanupJob.php', 'OC\\Authentication\\Token\\TokenScopes' => $baseDir . '/lib/private/Authentication/Token/TokenScopes.php', + 'OC\\Authentication\\TwoFactorAuth\\ChallengeRequester' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/ChallengeRequester.php', 'OC\\Authentication\\TwoFactorAuth\\Db\\ProviderUserAssignmentDao' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/Db/ProviderUserAssignmentDao.php', 'OC\\Authentication\\TwoFactorAuth\\EnforcementState' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/EnforcementState.php', 'OC\\Authentication\\TwoFactorAuth\\Manager' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/Manager.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index dac59af04087a..6f728d6d6825d 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -274,6 +274,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Authentication\\TwoFactorAuth\\ALoginSetupController' => __DIR__ . '/../../..' . '/lib/public/Authentication/TwoFactorAuth/ALoginSetupController.php', 'OCP\\Authentication\\TwoFactorAuth\\IActivatableAtLogin' => __DIR__ . '/../../..' . '/lib/public/Authentication/TwoFactorAuth/IActivatableAtLogin.php', 'OCP\\Authentication\\TwoFactorAuth\\IActivatableByAdmin' => __DIR__ . '/../../..' . '/lib/public/Authentication/TwoFactorAuth/IActivatableByAdmin.php', + 'OCP\\Authentication\\TwoFactorAuth\\IChallengeRequester' => __DIR__ . '/../../..' . '/lib/public/Authentication/TwoFactorAuth/IChallengeRequester.php', 'OCP\\Authentication\\TwoFactorAuth\\IDeactivatableByAdmin' => __DIR__ . '/../../..' . '/lib/public/Authentication/TwoFactorAuth/IDeactivatableByAdmin.php', 'OCP\\Authentication\\TwoFactorAuth\\ILoginSetupProvider' => __DIR__ . '/../../..' . '/lib/public/Authentication/TwoFactorAuth/ILoginSetupProvider.php', 'OCP\\Authentication\\TwoFactorAuth\\IPersonalProviderSettings' => __DIR__ . '/../../..' . '/lib/public/Authentication/TwoFactorAuth/IPersonalProviderSettings.php', @@ -1396,6 +1397,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Authentication\\Token\\RemoteWipe' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/RemoteWipe.php', 'OC\\Authentication\\Token\\TokenCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/TokenCleanupJob.php', 'OC\\Authentication\\Token\\TokenScopes' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/TokenScopes.php', + 'OC\\Authentication\\TwoFactorAuth\\ChallengeRequester' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/ChallengeRequester.php', 'OC\\Authentication\\TwoFactorAuth\\Db\\ProviderUserAssignmentDao' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/Db/ProviderUserAssignmentDao.php', 'OC\\Authentication\\TwoFactorAuth\\EnforcementState' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/EnforcementState.php', 'OC\\Authentication\\TwoFactorAuth\\Manager' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/Manager.php', diff --git a/lib/private/Authentication/TwoFactorAuth/ChallengeRequester.php b/lib/private/Authentication/TwoFactorAuth/ChallengeRequester.php new file mode 100644 index 0000000000000..8781a90c00fa1 --- /dev/null +++ b/lib/private/Authentication/TwoFactorAuth/ChallengeRequester.php @@ -0,0 +1,45 @@ +session->exists('app_password') + && $this->session->get('app_api') !== true + && $this->manager->isTwoFactorAuthenticated($user); + } + + #[\Override] + public function requestChallenge(IUser $user, string $redirectUrl): string { + if (!$this->isAvailable($user)) { + throw new \LogicException('The second factor cannot be requested for this session'); + } + + $this->session->remove(Manager::SESSION_UID_DONE); + $this->manager->prepareTwoFactorLogin($user, false); + + return $this->urlGenerator->linkToRoute('core.TwoFactorChallenge.selectChallenge', [ + 'redirect_url' => $redirectUrl, + ]); + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index f1dff3b0693df..b21f62a46e9fa 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -469,6 +469,7 @@ public function __construct( $this->registerAlias(IUserSession::class, Session::class); $this->registerAlias(IRegistry::class, Registry::class); + $this->registerAlias(\OCP\Authentication\TwoFactorAuth\IChallengeRequester::class, \OC\Authentication\TwoFactorAuth\ChallengeRequester::class); $this->registerAlias(INavigationManager::class, NavigationManager::class); diff --git a/lib/public/Authentication/TwoFactorAuth/IChallengeRequester.php b/lib/public/Authentication/TwoFactorAuth/IChallengeRequester.php new file mode 100644 index 0000000000000..7daa8ac6b1556 --- /dev/null +++ b/lib/public/Authentication/TwoFactorAuth/IChallengeRequester.php @@ -0,0 +1,41 @@ +manager = $this->createMock(Manager::class); + $this->session = $this->createMock(ISession::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->user = $this->createMock(IUser::class); + $this->requester = new ChallengeRequester($this->manager, $this->session, $this->urlGenerator); + } + + private function withSession(bool $appPassword, bool $appApi, bool $hasSecondFactor): void { + $this->session->method('exists') + ->willReturnCallback(static fn (string $key): bool => $key === 'app_password' && $appPassword); + $this->session->method('get') + ->willReturnCallback(static fn (string $key): mixed => $key === 'app_api' ? $appApi : null); + $this->manager->method('isTwoFactorAuthenticated') + ->with($this->user) + ->willReturn($hasSecondFactor); + } + + public static function dataIsAvailable(): array { + return [ + 'second factor' => [false, false, true, true], + 'no second factor' => [false, false, false, false], + 'app password session' => [true, false, true, false], + 'AppAPI session' => [false, true, true, false], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataIsAvailable')] + public function testIsAvailable(bool $appPassword, bool $appApi, bool $hasSecondFactor, bool $expected): void { + $this->withSession($appPassword, $appApi, $hasSecondFactor); + + $this->assertSame($expected, $this->requester->isAvailable($this->user)); + } + + public function testRequestChallengeMarksTheSessionAndReturnsTheChallengeUrl(): void { + $this->withSession(false, false, true); + $this->session->expects($this->once()) + ->method('remove') + ->with(Manager::SESSION_UID_DONE); + $this->manager->expects($this->once()) + ->method('prepareTwoFactorLogin') + ->with($this->user, false); + $this->urlGenerator->expects($this->once()) + ->method('linkToRoute') + ->with('core.TwoFactorChallenge.selectChallenge', ['redirect_url' => '/apps/settings/user/security']) + ->willReturn('/index.php/login/selectchallenge?redirect_url=%2Fapps%2Fsettings%2Fuser%2Fsecurity'); + + $this->assertSame( + '/index.php/login/selectchallenge?redirect_url=%2Fapps%2Fsettings%2Fuser%2Fsecurity', + $this->requester->requestChallenge($this->user, '/apps/settings/user/security'), + ); + } + + public function testRequestChallengeRefusesSessionsThatCannotBeChallenged(): void { + $this->withSession(true, false, true); + $this->manager->expects($this->never()) + ->method('prepareTwoFactorLogin'); + + $this->expectException(\LogicException::class); + $this->requester->requestChallenge($this->user, '/'); + } +}