diff --git a/IONOS b/IONOS index 35650b0e17105..9decc9bcd4f9d 160000 --- a/IONOS +++ b/IONOS @@ -1 +1 @@ -Subproject commit 35650b0e17105902e4c0b4d15c8b51f204249010 +Subproject commit 9decc9bcd4f9defbb9206d3e69805658496a4753 diff --git a/core/Controller/ClientFlowLoginV2Controller.php b/core/Controller/ClientFlowLoginV2Controller.php index e6e1c282d2b6b..7ad91fcc40c3f 100644 --- a/core/Controller/ClientFlowLoginV2Controller.php +++ b/core/Controller/ClientFlowLoginV2Controller.php @@ -10,6 +10,7 @@ use OC\Core\Db\LoginFlowV2; use OC\Core\Exception\LoginFlowV2NotFoundException; +use OC\Core\Exception\LoginFlowV2ClientForbiddenException; use OC\Core\ResponseDefinitions; use OC\Core\Service\LoginFlowV2Service; use OCP\AppFramework\Controller; @@ -106,6 +107,8 @@ public function showAuthPickerPage(string $user = '', int $direct = 0): Standalo $flow = $this->getFlowByLoginToken(); } catch (LoginFlowV2NotFoundException $e) { return $this->loginTokenForbiddenResponse(); + } catch (LoginFlowV2ClientForbiddenException $e) { + return $this->loginTokenForbiddenClientResponse(); } $stateToken = $this->random->generate( @@ -149,6 +152,8 @@ public function grantPage(?string $stateToken, int $direct = 0): StandaloneTempl $flow = $this->getFlowByLoginToken(); } catch (LoginFlowV2NotFoundException $e) { return $this->loginTokenForbiddenResponse(); + } catch (LoginFlowV2ClientForbiddenException $e) { + return $this->loginTokenForbiddenClientResponse(); } /** @var IUser $user */ @@ -185,6 +190,8 @@ public function apptokenRedirect(?string $stateToken, string $user, string $pass $this->getFlowByLoginToken(); } catch (LoginFlowV2NotFoundException $e) { return $this->loginTokenForbiddenResponse(); + } catch (LoginFlowV2ClientForbiddenException $e) { + return $this->loginTokenForbiddenClientResponse(); } $loginToken = $this->session->get(self::TOKEN_NAME); @@ -230,6 +237,8 @@ public function generateAppPassword(?string $stateToken): Response { $this->getFlowByLoginToken(); } catch (LoginFlowV2NotFoundException $e) { return $this->loginTokenForbiddenResponse(); + } catch (LoginFlowV2ClientForbiddenException $e) { + return $this->loginTokenForbiddenClientResponse(); } $loginToken = $this->session->get(self::TOKEN_NAME); @@ -329,6 +338,7 @@ private function stateTokenForbiddenResponse(): StandaloneTemplateResponse { /** * @return LoginFlowV2 * @throws LoginFlowV2NotFoundException + * @throws LoginFlowV2ClientForbiddenException */ private function getFlowByLoginToken(): LoginFlowV2 { $currentToken = $this->session->get(self::TOKEN_NAME); @@ -352,6 +362,19 @@ private function loginTokenForbiddenResponse(): StandaloneTemplateResponse { return $response; } + private function loginTokenForbiddenClientResponse(): StandaloneTemplateResponse { + $response = new StandaloneTemplateResponse( + $this->appName, + '403', + [ + 'message' => $this->l10n->t('Please use original client'), + ], + 'guest' + ); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + private function getServerPath(): string { $serverPostfix = ''; diff --git a/core/Exception/LoginFlowV2ClientForbiddenException.php b/core/Exception/LoginFlowV2ClientForbiddenException.php new file mode 100644 index 0000000000000..459c30fb4dc45 --- /dev/null +++ b/core/Exception/LoginFlowV2ClientForbiddenException.php @@ -0,0 +1,12 @@ +mapper->getByLoginToken($loginToken); + $flow = $this->mapper->getByLoginToken($loginToken); } catch (DoesNotExistException $e) { throw new LoginFlowV2NotFoundException('Login token invalid'); } + + $allowedAgents = $this->config->getSystemValue('core.login_flow_v2.allowed_user_agents', []); + + if (empty($allowedAgents)) { + return $flow; + } + + $flowClient = $flow->getClientName(); + + foreach ($allowedAgents as $allowedAgent) { + if (preg_match($allowedAgent, $flowClient) === 1) { + return $flow; + } + } + + throw new LoginFlowV2ClientForbiddenException('Client not allowed'); } /** diff --git a/tests/Core/Controller/ClientFlowLoginV2ControllerTest.php b/tests/Core/Controller/ClientFlowLoginV2ControllerTest.php index eefa2982c74bb..8fe31e1a300cf 100644 --- a/tests/Core/Controller/ClientFlowLoginV2ControllerTest.php +++ b/tests/Core/Controller/ClientFlowLoginV2ControllerTest.php @@ -11,6 +11,7 @@ use OC\Core\Controller\ClientFlowLoginV2Controller; use OC\Core\Data\LoginFlowV2Credentials; use OC\Core\Db\LoginFlowV2; +use OC\Core\Exception\LoginFlowV2ClientForbiddenException; use OC\Core\Exception\LoginFlowV2NotFoundException; use OC\Core\Service\LoginFlowV2Service; use OCP\AppFramework\Http; @@ -56,6 +57,12 @@ protected function setUp(): void { $this->random = $this->createMock(ISecureRandom::class); $this->defaults = $this->createMock(Defaults::class); $this->l = $this->createMock(IL10N::class); + $this->l + ->expects($this->any()) + ->method('t') + ->willReturnCallback(function ($text, $parameters = []) { + return vsprintf($text, $parameters); + }); $this->controller = new ClientFlowLoginV2Controller( 'core', $this->request, @@ -150,6 +157,22 @@ public function testShowAuthPickerInvalidLoginToken() { $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); } + public function testShowAuthPickerForbiddenUserClient() { + $this->session->method('get') + ->with('client.flow.v2.login.token') + ->willReturn('loginToken'); + + $this->loginFlowV2Service->method('getByLoginToken') + ->with('loginToken') + ->willThrowException(new LoginFlowV2ClientForbiddenException()); + + $result = $this->controller->showAuthPickerPage(); + + $this->assertInstanceOf(Http\StandaloneTemplateResponse::class, $result); + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + $this->assertSame('Please use original client', $result->getParams()['message']); + } + public function testShowAuthPickerValidLoginToken() { $this->session->method('get') ->with('client.flow.v2.login.token') @@ -206,6 +229,29 @@ public function testGrantPageInvalidLoginToken() { $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); } + public function testGrantPageForbiddenUserClient() { + $this->session->method('get') + ->willReturnCallback(function ($name) { + if ($name === 'client.flow.v2.state.token') { + return 'stateToken'; + } + if ($name === 'client.flow.v2.login.token') { + return 'loginToken'; + } + return null; + }); + + $this->loginFlowV2Service->method('getByLoginToken') + ->with('loginToken') + ->willThrowException(new LoginFlowV2ClientForbiddenException()); + + $result = $this->controller->grantPage('stateToken'); + + $this->assertInstanceOf(Http\StandaloneTemplateResponse::class, $result); + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + $this->assertSame('Please use original client', $result->getParams()['message']); + } + public function testGrantPageValid() { $this->session->method('get') ->willReturnCallback(function ($name) { @@ -266,6 +312,29 @@ public function testGenerateAppPassworInvalidLoginToken() { $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); } + public function testGenerateAppPasswordForbiddenUserClient() { + $this->session->method('get') + ->willReturnCallback(function ($name) { + if ($name === 'client.flow.v2.state.token') { + return 'stateToken'; + } + if ($name === 'client.flow.v2.login.token') { + return 'loginToken'; + } + return null; + }); + + $this->loginFlowV2Service->method('getByLoginToken') + ->with('loginToken') + ->willThrowException(new LoginFlowV2ClientForbiddenException()); + + $result = $this->controller->generateAppPassword('stateToken'); + + $this->assertInstanceOf(Http\StandaloneTemplateResponse::class, $result); + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + $this->assertSame('Please use original client', $result->getParams()['message']); + } + public function testGenerateAppPassworValid() { $this->session->method('get') ->willReturnCallback(function ($name) { diff --git a/tests/Core/Service/LoginFlowV2ServiceUnitTest.php b/tests/Core/Service/LoginFlowV2ServiceUnitTest.php index 2e6407d4c00d2..dc9e8427d3e59 100644 --- a/tests/Core/Service/LoginFlowV2ServiceUnitTest.php +++ b/tests/Core/Service/LoginFlowV2ServiceUnitTest.php @@ -14,6 +14,7 @@ use OC\Core\Data\LoginFlowV2Tokens; use OC\Core\Db\LoginFlowV2; use OC\Core\Db\LoginFlowV2Mapper; +use OC\Core\Exception\LoginFlowV2ClientForbiddenException; use OC\Core\Exception\LoginFlowV2NotFoundException; use OC\Core\Service\LoginFlowV2Service; use OCP\AppFramework\Db\DoesNotExistException; @@ -237,6 +238,57 @@ public function testGetByLoginTokenLoginTokenInvalid() { $this->subjectUnderTest->getByLoginToken('test_token'); } + public function testGetByLoginTokenClientForbidden() { + $this->expectException(LoginFlowV2ClientForbiddenException::class); + $this->expectExceptionMessage('Client not allowed'); + + $allowedClients = [ + '/Custom Allowed Client/i' + ]; + + $this->config->expects($this->exactly(1)) + ->method('getSystemValue') + ->willReturn($this->returnCallback(function ($key) use ($allowedClients) { + // Note: \OCP\IConfig::getSystemValue returns either an array or string. + return $key == 'core.login_flow_v2.allowed_user_agents' ? $allowedClients : ''; + })); + + $loginFlowV2 = new LoginFlowV2(); + $loginFlowV2->setClientName('Rogue Curl Client/1.0'); + + $this->mapper->expects($this->once()) + ->method('getByLoginToken') + ->willReturn($loginFlowV2); + + $this->subjectUnderTest->getByLoginToken('test_token'); + } + + public function testGetByLoginTokenClientAllowed() { + $allowedClients = [ + '/Foo Allowed Client/i', + '/Custom Allowed Client/i' + ]; + + $loginFlowV2 = new LoginFlowV2(); + $loginFlowV2->setClientName('Custom Allowed Client Curl Client/1.0'); + + $this->config->expects($this->exactly(1)) + ->method('getSystemValue') + ->willReturn($this->returnCallback(function ($key) use ($allowedClients) { + // Note: \OCP\IConfig::getSystemValue returns either an array or string. + return $key == 'core.login_flow_v2.allowed_user_agents' ? $allowedClients : ''; + })); + + $this->mapper->expects($this->once()) + ->method('getByLoginToken') + ->willReturn($loginFlowV2); + + $result = $this->subjectUnderTest->getByLoginToken('test_token'); + + $this->assertTrue($result instanceof LoginFlowV2); + $this->assertEquals('Custom Allowed Client Curl Client/1.0', $result->getClientName()); + } + /* * Tests for startLoginFlow */