diff options
Diffstat (limited to 'tests/lib/Authentication/TwoFactorAuth/ManagerTest.php')
-rw-r--r-- | tests/lib/Authentication/TwoFactorAuth/ManagerTest.php | 795 |
1 files changed, 795 insertions, 0 deletions
diff --git a/tests/lib/Authentication/TwoFactorAuth/ManagerTest.php b/tests/lib/Authentication/TwoFactorAuth/ManagerTest.php new file mode 100644 index 00000000000..a2bed8a3652 --- /dev/null +++ b/tests/lib/Authentication/TwoFactorAuth/ManagerTest.php @@ -0,0 +1,795 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace Test\Authentication\TwoFactorAuth; + +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Token\IProvider as TokenProvider; +use OC\Authentication\Token\IToken; +use OC\Authentication\TwoFactorAuth\Manager; +use OC\Authentication\TwoFactorAuth\MandatoryTwoFactor; +use OC\Authentication\TwoFactorAuth\ProviderLoader; +use OCP\Activity\IEvent; +use OCP\Activity\IManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\TwoFactorAuth\IActivatableAtLogin; +use OCP\Authentication\TwoFactorAuth\IProvider; +use OCP\Authentication\TwoFactorAuth\IRegistry; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; +use OCP\ISession; +use OCP\IUser; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use Test\TestCase; +use function reset; + +class ManagerTest extends TestCase { + /** @var IUser|MockObject */ + private $user; + + /** @var ProviderLoader|MockObject */ + private $providerLoader; + + /** @var IRegistry|MockObject */ + private $providerRegistry; + + /** @var MandatoryTwoFactor|MockObject */ + private $mandatoryTwoFactor; + + /** @var ISession|MockObject */ + private $session; + + /** @var Manager */ + private $manager; + + /** @var IConfig|MockObject */ + private $config; + + /** @var IManager|MockObject */ + private $activityManager; + + /** @var LoggerInterface|MockObject */ + private $logger; + + /** @var IProvider|MockObject */ + private $fakeProvider; + + /** @var IProvider|MockObject */ + private $backupProvider; + + /** @var TokenProvider|MockObject */ + private $tokenProvider; + + /** @var ITimeFactory|MockObject */ + private $timeFactory; + + /** @var IEventDispatcher|MockObject */ + private $dispatcher; + + protected function setUp(): void { + parent::setUp(); + + $this->user = $this->createMock(IUser::class); + $this->providerLoader = $this->createMock(ProviderLoader::class); + $this->providerRegistry = $this->createMock(IRegistry::class); + $this->mandatoryTwoFactor = $this->createMock(MandatoryTwoFactor::class); + $this->session = $this->createMock(ISession::class); + $this->config = $this->createMock(IConfig::class); + $this->activityManager = $this->createMock(IManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->tokenProvider = $this->createMock(TokenProvider::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->dispatcher = $this->createMock(IEventDispatcher::class); + + $this->manager = new Manager( + $this->providerLoader, + $this->providerRegistry, + $this->mandatoryTwoFactor, + $this->session, + $this->config, + $this->activityManager, + $this->logger, + $this->tokenProvider, + $this->timeFactory, + $this->dispatcher, + ); + + $this->fakeProvider = $this->createMock(IProvider::class); + $this->fakeProvider->method('getId')->willReturn('email'); + + $this->backupProvider = $this->getMockBuilder('\OCP\Authentication\TwoFactorAuth\IProvider')->getMock(); + $this->backupProvider->method('getId')->willReturn('backup_codes'); + $this->backupProvider->method('isTwoFactorAuthEnabledForUser')->willReturn(true); + } + + private function prepareNoProviders() { + $this->providerLoader->method('getProviders') + ->with($this->user) + ->willReturn([]); + } + + private function prepareProviders() { + $this->providerRegistry->expects($this->once()) + ->method('getProviderStates') + ->with($this->user) + ->willReturn([ + $this->fakeProvider->getId() => true, + ]); + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->with($this->user) + ->willReturn([$this->fakeProvider]); + } + + private function prepareProvidersWitBackupProvider() { + $this->providerLoader->method('getProviders') + ->with($this->user) + ->willReturn([ + $this->fakeProvider, + $this->backupProvider, + ]); + } + + public function testIsTwoFactorAuthenticatedEnforced(): void { + $this->mandatoryTwoFactor->expects($this->once()) + ->method('isEnforcedFor') + ->with($this->user) + ->willReturn(true); + + $enabled = $this->manager->isTwoFactorAuthenticated($this->user); + + $this->assertTrue($enabled); + } + + public function testIsTwoFactorAuthenticatedNoProviders(): void { + $this->mandatoryTwoFactor->expects($this->once()) + ->method('isEnforcedFor') + ->with($this->user) + ->willReturn(false); + $this->providerRegistry->expects($this->once()) + ->method('getProviderStates') + ->willReturn([]); // No providers registered + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->willReturn([]); // No providers loadable + + $this->assertFalse($this->manager->isTwoFactorAuthenticated($this->user)); + } + + public function testIsTwoFactorAuthenticatedOnlyBackupCodes(): void { + $this->mandatoryTwoFactor->expects($this->once()) + ->method('isEnforcedFor') + ->with($this->user) + ->willReturn(false); + $this->providerRegistry->expects($this->once()) + ->method('getProviderStates') + ->willReturn([ + 'backup_codes' => true, + ]); + $backupCodesProvider = $this->createMock(IProvider::class); + $backupCodesProvider + ->method('getId') + ->willReturn('backup_codes'); + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->willReturn([ + $backupCodesProvider, + ]); + + $this->assertFalse($this->manager->isTwoFactorAuthenticated($this->user)); + } + + public function testIsTwoFactorAuthenticatedFailingProviders(): void { + $this->mandatoryTwoFactor->expects($this->once()) + ->method('isEnforcedFor') + ->with($this->user) + ->willReturn(false); + $this->providerRegistry->expects($this->once()) + ->method('getProviderStates') + ->willReturn([ + 'twofactor_totp' => true, + 'twofactor_u2f' => false, + ]); // Two providers registered, but … + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->willReturn([]); // … none of them is able to load, however … + + // … 2FA is still enforced + $this->assertTrue($this->manager->isTwoFactorAuthenticated($this->user)); + } + + public static function providerStatesFixData(): array { + return [ + [false, false], + [true, true], + ]; + } + + /** + * If the 2FA registry has not been populated when a user logs in, + * the 2FA manager has to first fix the state before it checks for + * enabled providers. + * + * If any of these providers is active, 2FA is enabled + */ + #[\PHPUnit\Framework\Attributes\DataProvider('providerStatesFixData')] + public function testIsTwoFactorAuthenticatedFixesProviderStates(bool $providerEnabled, bool $expected): void { + $this->providerRegistry->expects($this->once()) + ->method('getProviderStates') + ->willReturn([]); // Nothing registered yet + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->willReturn([ + $this->fakeProvider + ]); + $this->fakeProvider->expects($this->once()) + ->method('isTwoFactorAuthEnabledForUser') + ->with($this->user) + ->willReturn($providerEnabled); + if ($providerEnabled) { + $this->providerRegistry->expects($this->once()) + ->method('enableProviderFor') + ->with( + $this->fakeProvider, + $this->user + ); + } else { + $this->providerRegistry->expects($this->once()) + ->method('disableProviderFor') + ->with( + $this->fakeProvider, + $this->user + ); + } + + $this->assertEquals($expected, $this->manager->isTwoFactorAuthenticated($this->user)); + } + + public function testGetProvider(): void { + $this->providerRegistry->expects($this->once()) + ->method('getProviderStates') + ->with($this->user) + ->willReturn([ + $this->fakeProvider->getId() => true, + ]); + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->with($this->user) + ->willReturn([$this->fakeProvider]); + + $provider = $this->manager->getProvider($this->user, $this->fakeProvider->getId()); + + $this->assertSame($this->fakeProvider, $provider); + } + + public function testGetInvalidProvider(): void { + $this->providerRegistry->expects($this->once()) + ->method('getProviderStates') + ->with($this->user) + ->willReturn([]); + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->with($this->user) + ->willReturn([]); + + $provider = $this->manager->getProvider($this->user, 'nonexistent'); + + $this->assertNull($provider); + } + + public function testGetLoginSetupProviders(): void { + $provider1 = $this->createMock(IProvider::class); + $provider2 = $this->createMock(IActivatableAtLogin::class); + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->with($this->user) + ->willReturn([ + $provider1, + $provider2, + ]); + + $providers = $this->manager->getLoginSetupProviders($this->user); + + $this->assertCount(1, $providers); + $this->assertSame($provider2, reset($providers)); + } + + public function testGetProviders(): void { + $this->providerRegistry->expects($this->once()) + ->method('getProviderStates') + ->with($this->user) + ->willReturn([ + $this->fakeProvider->getId() => true, + ]); + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->with($this->user) + ->willReturn([$this->fakeProvider]); + $expectedProviders = [ + 'email' => $this->fakeProvider, + ]; + + $providerSet = $this->manager->getProviderSet($this->user); + $providers = $providerSet->getProviders(); + + $this->assertEquals($expectedProviders, $providers); + $this->assertFalse($providerSet->isProviderMissing()); + } + + public function testGetProvidersOneMissing(): void { + $this->providerRegistry->expects($this->once()) + ->method('getProviderStates') + ->with($this->user) + ->willReturn([ + $this->fakeProvider->getId() => true, + ]); + $this->providerLoader->expects($this->once()) + ->method('getProviders') + ->with($this->user) + ->willReturn([]); + $expectedProviders = [ + 'email' => $this->fakeProvider, + ]; + + $providerSet = $this->manager->getProviderSet($this->user); + + $this->assertTrue($providerSet->isProviderMissing()); + } + + public function testVerifyChallenge(): void { + $this->prepareProviders(); + + $challenge = 'passme'; + $event = $this->createMock(IEvent::class); + $this->fakeProvider->expects($this->once()) + ->method('verifyChallenge') + ->with($this->user, $challenge) + ->willReturn(true); + $this->session->expects($this->once()) + ->method('get') + ->with('two_factor_remember_login') + ->willReturn(false); + + $calls = [ + ['two_factor_auth_uid'], + ['two_factor_remember_login'], + ]; + $this->session->expects($this->exactly(2)) + ->method('remove') + ->willReturnCallback(function () use (&$calls): void { + $expected = array_shift($calls); + $this->assertEquals($expected, func_get_args()); + }); + + $this->session->expects($this->once()) + ->method('set') + ->with(Manager::SESSION_UID_DONE, 'jos'); + $this->session->method('getId') + ->willReturn('mysessionid'); + $this->activityManager->expects($this->once()) + ->method('generateEvent') + ->willReturn($event); + $this->user->expects($this->any()) + ->method('getUID') + ->willReturn('jos'); + $event->expects($this->once()) + ->method('setApp') + ->with($this->equalTo('core')) + ->willReturnSelf(); + $event->expects($this->once()) + ->method('setType') + ->with($this->equalTo('security')) + ->willReturnSelf(); + $event->expects($this->once()) + ->method('setAuthor') + ->with($this->equalTo('jos')) + ->willReturnSelf(); + $event->expects($this->once()) + ->method('setAffectedUser') + ->with($this->equalTo('jos')) + ->willReturnSelf(); + $this->fakeProvider + ->method('getDisplayName') + ->willReturn('Fake 2FA'); + $event->expects($this->once()) + ->method('setSubject') + ->with($this->equalTo('twofactor_success'), $this->equalTo([ + 'provider' => 'Fake 2FA', + ])) + ->willReturnSelf(); + $token = $this->createMock(IToken::class); + $this->tokenProvider->method('getToken') + ->with('mysessionid') + ->willReturn($token); + $token->method('getId') + ->willReturn(42); + $this->config->expects($this->once()) + ->method('deleteUserValue') + ->with('jos', 'login_token_2fa', '42'); + + $result = $this->manager->verifyChallenge('email', $this->user, $challenge); + + $this->assertTrue($result); + } + + public function testVerifyChallengeInvalidProviderId(): void { + $this->prepareProviders(); + + $challenge = 'passme'; + $this->fakeProvider->expects($this->never()) + ->method('verifyChallenge') + ->with($this->user, $challenge); + $this->session->expects($this->never()) + ->method('remove'); + + $this->assertFalse($this->manager->verifyChallenge('dontexist', $this->user, $challenge)); + } + + public function testVerifyInvalidChallenge(): void { + $this->prepareProviders(); + + $challenge = 'dontpassme'; + $event = $this->createMock(IEvent::class); + $this->fakeProvider->expects($this->once()) + ->method('verifyChallenge') + ->with($this->user, $challenge) + ->willReturn(false); + $this->session->expects($this->never()) + ->method('remove'); + $this->activityManager->expects($this->once()) + ->method('generateEvent') + ->willReturn($event); + $this->user->expects($this->any()) + ->method('getUID') + ->willReturn('jos'); + $event->expects($this->once()) + ->method('setApp') + ->with($this->equalTo('core')) + ->willReturnSelf(); + $event->expects($this->once()) + ->method('setType') + ->with($this->equalTo('security')) + ->willReturnSelf(); + $event->expects($this->once()) + ->method('setAuthor') + ->with($this->equalTo('jos')) + ->willReturnSelf(); + $event->expects($this->once()) + ->method('setAffectedUser') + ->with($this->equalTo('jos')) + ->willReturnSelf(); + $this->fakeProvider + ->method('getDisplayName') + ->willReturn('Fake 2FA'); + $event->expects($this->once()) + ->method('setSubject') + ->with($this->equalTo('twofactor_failed'), $this->equalTo([ + 'provider' => 'Fake 2FA', + ])) + ->willReturnSelf(); + + $this->assertFalse($this->manager->verifyChallenge('email', $this->user, $challenge)); + } + + public function testNeedsSecondFactor(): void { + $user = $this->createMock(IUser::class); + + $calls = [ + ['app_password'], + ['two_factor_auth_uid'], + [Manager::SESSION_UID_DONE], + ]; + $this->session->expects($this->exactly(3)) + ->method('exists') + ->willReturnCallback(function () use (&$calls) { + $expected = array_shift($calls); + $this->assertEquals($expected, func_get_args()); + return false; + }); + + $this->session->method('getId') + ->willReturn('mysessionid'); + $token = $this->createMock(IToken::class); + $this->tokenProvider->method('getToken') + ->with('mysessionid') + ->willReturn($token); + $token->method('getId') + ->willReturn(42); + + $user->method('getUID') + ->willReturn('user'); + $this->config->method('getUserKeys') + ->with('user', 'login_token_2fa') + ->willReturn([ + '42' + ]); + + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->providerLoader, + $this->providerRegistry, + $this->mandatoryTwoFactor, + $this->session, + $this->config, + $this->activityManager, + $this->logger, + $this->tokenProvider, + $this->timeFactory, + $this->dispatcher, + ]) + ->onlyMethods(['isTwoFactorAuthenticated'])// Do not actually load the apps + ->getMock(); + + $manager->method('isTwoFactorAuthenticated') + ->with($user) + ->willReturn(true); + + $this->assertTrue($manager->needsSecondFactor($user)); + } + + public function testNeedsSecondFactorUserIsNull(): void { + $user = null; + $this->session->expects($this->never()) + ->method('exists'); + + $this->assertFalse($this->manager->needsSecondFactor($user)); + } + + public function testNeedsSecondFactorWithNoProviderAvailableAnymore(): void { + $this->prepareNoProviders(); + + $user = null; + $this->session->expects($this->never()) + ->method('exists') + ->with('two_factor_auth_uid') + ->willReturn(true); + $this->session->expects($this->never()) + ->method('remove') + ->with('two_factor_auth_uid'); + + $this->assertFalse($this->manager->needsSecondFactor($user)); + } + + public function testPrepareTwoFactorLogin(): void { + $this->user->method('getUID') + ->willReturn('ferdinand'); + + $calls = [ + ['two_factor_auth_uid', 'ferdinand'], + ['two_factor_remember_login', true], + ]; + $this->session->expects($this->exactly(2)) + ->method('set') + ->willReturnCallback(function () use (&$calls): void { + $expected = array_shift($calls); + $this->assertEquals($expected, func_get_args()); + }); + + $this->session->method('getId') + ->willReturn('mysessionid'); + $token = $this->createMock(IToken::class); + $this->tokenProvider->method('getToken') + ->with('mysessionid') + ->willReturn($token); + $token->method('getId') + ->willReturn(42); + + $this->timeFactory->method('getTime') + ->willReturn(1337); + + $this->config->method('setUserValue') + ->with('ferdinand', 'login_token_2fa', '42', '1337'); + + + $this->manager->prepareTwoFactorLogin($this->user, true); + } + + public function testPrepareTwoFactorLoginDontRemember(): void { + $this->user->method('getUID') + ->willReturn('ferdinand'); + + $calls = [ + ['two_factor_auth_uid', 'ferdinand'], + ['two_factor_remember_login', false], + ]; + $this->session->expects($this->exactly(2)) + ->method('set') + ->willReturnCallback(function () use (&$calls): void { + $expected = array_shift($calls); + $this->assertEquals($expected, func_get_args()); + }); + + $this->session->method('getId') + ->willReturn('mysessionid'); + $token = $this->createMock(IToken::class); + $this->tokenProvider->method('getToken') + ->with('mysessionid') + ->willReturn($token); + $token->method('getId') + ->willReturn(42); + + $this->timeFactory->method('getTime') + ->willReturn(1337); + + $this->config->method('setUserValue') + ->with('ferdinand', 'login_token_2fa', '42', '1337'); + + $this->manager->prepareTwoFactorLogin($this->user, false); + } + + public function testNeedsSecondFactorSessionAuth(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + + $this->session->method('exists') + ->willReturnCallback(function ($var) { + if ($var === Manager::SESSION_UID_KEY) { + return false; + } elseif ($var === 'app_password') { + return false; + } elseif ($var === 'app_api') { + return false; + } + return true; + }); + $this->session->method('get') + ->willReturnCallback(function ($var) { + if ($var === Manager::SESSION_UID_KEY) { + return 'user'; + } elseif ($var === 'app_api') { + return true; + } + return null; + }); + $this->session->expects($this->once()) + ->method('get') + ->willReturnMap([ + [Manager::SESSION_UID_DONE, 'user'], + ['app_api', true] + ]); + + $this->assertFalse($this->manager->needsSecondFactor($user)); + } + + public function testNeedsSecondFactorSessionAuthFailDBPass(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + + $this->session->method('exists') + ->willReturn(false); + $this->session->method('getId') + ->willReturn('mysessionid'); + + $token = $this->createMock(IToken::class); + $token->method('getId') + ->willReturn(40); + + $this->tokenProvider->method('getToken') + ->with('mysessionid') + ->willReturn($token); + + $this->config->method('getUserKeys') + ->with('user', 'login_token_2fa') + ->willReturn([ + '42', '43', '44' + ]); + + $this->session->expects($this->once()) + ->method('set') + ->with(Manager::SESSION_UID_DONE, 'user'); + + $this->assertFalse($this->manager->needsSecondFactor($user)); + } + + public function testNeedsSecondFactorInvalidToken(): void { + $this->prepareNoProviders(); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + + $this->session->method('exists') + ->willReturn(false); + $this->session->method('getId') + ->willReturn('mysessionid'); + + $this->tokenProvider->method('getToken') + ->with('mysessionid') + ->willThrowException(new InvalidTokenException()); + + $this->config->method('getUserKeys')->willReturn([]); + + $this->assertFalse($this->manager->needsSecondFactor($user)); + } + + public function testNeedsSecondFactorAppPassword(): void { + $user = $this->createMock(IUser::class); + $this->session->method('exists') + ->willReturnMap([ + ['app_password', true], + ['app_api', true] + ]); + + $this->assertFalse($this->manager->needsSecondFactor($user)); + } + + public function testClearTwoFactorPending() { + $this->config->method('getUserKeys') + ->with('theUserId', 'login_token_2fa') + ->willReturn([ + '42', '43', '44' + ]); + + $deleteUserValueCalls = [ + ['theUserId', 'login_token_2fa', '42'], + ['theUserId', 'login_token_2fa', '43'], + ['theUserId', 'login_token_2fa', '44'], + ]; + $this->config->expects($this->exactly(3)) + ->method('deleteUserValue') + ->willReturnCallback(function () use (&$deleteUserValueCalls): void { + $expected = array_shift($deleteUserValueCalls); + $this->assertEquals($expected, func_get_args()); + }); + + $invalidateCalls = [ + ['theUserId', 42], + ['theUserId', 43], + ['theUserId', 44], + ]; + $this->tokenProvider->expects($this->exactly(3)) + ->method('invalidateTokenById') + ->willReturnCallback(function () use (&$invalidateCalls): void { + $expected = array_shift($invalidateCalls); + $this->assertEquals($expected, func_get_args()); + }); + + $this->manager->clearTwoFactorPending('theUserId'); + } + + public function testClearTwoFactorPendingTokenDoesNotExist() { + $this->config->method('getUserKeys') + ->with('theUserId', 'login_token_2fa') + ->willReturn([ + '42', '43', '44' + ]); + + $deleteUserValueCalls = [ + ['theUserId', 'login_token_2fa', '42'], + ['theUserId', 'login_token_2fa', '43'], + ['theUserId', 'login_token_2fa', '44'], + ]; + $this->config->expects($this->exactly(3)) + ->method('deleteUserValue') + ->willReturnCallback(function () use (&$deleteUserValueCalls): void { + $expected = array_shift($deleteUserValueCalls); + $this->assertEquals($expected, func_get_args()); + }); + + $invalidateCalls = [ + ['theUserId', 42], + ['theUserId', 43], + ['theUserId', 44], + ]; + $this->tokenProvider->expects($this->exactly(3)) + ->method('invalidateTokenById') + ->willReturnCallback(function ($user, $tokenId) use (&$invalidateCalls): void { + $expected = array_shift($invalidateCalls); + $this->assertEquals($expected, func_get_args()); + if ($tokenId === 43) { + throw new DoesNotExistException('token does not exist'); + } + }); + + $this->manager->clearTwoFactorPending('theUserId'); + } +} |