mirror of
https://github.com/nextcloud/server.git
synced 2026-02-18 18:28:50 -05:00
feat(login-flow-v2): Restrict allowed apps by user agent check
Enable via: ./occ config:system:set core.login_flow_v2.allowed_user_agents 0 --value '/Custom Foo Client/i' ./occ config:system:set core.login_flow_v2.allowed_user_agents 1 --value '/Custom Bar Client/i' if user agent string is unknown the template with "Access forbidden"-"Please use original client" will be displayed Signed-off-by: Misha M.-Kupriyanov <kupriyanov@strato.de>
This commit is contained in:
parent
14868ac0ee
commit
d1a94f3c9c
9 changed files with 199 additions and 2 deletions
|
|
@ -2515,6 +2515,20 @@ $CONFIG = [
|
|||
'/^Microsoft-WebDAV-MiniRedir/', // Windows webdav drive
|
||||
],
|
||||
|
||||
/**
|
||||
* This option allows you to specify a list of allowed user agents for the Login Flow V2.
|
||||
* If a user agent is not in this list, it will not be allowed to use the Login Flow V2.
|
||||
* The user agents are defined using regular expressions.
|
||||
*
|
||||
* WARNING: only use this if you know what you are doing
|
||||
*
|
||||
* Example: Allow only the Nextcloud Android app to use the Login Flow V2
|
||||
* 'core.login_flow_v2.allowed_user_agents' => ['/Nextcloud-android/i'],
|
||||
*
|
||||
* Defaults to an empty array.
|
||||
*/
|
||||
'core.login_flow_v2.allowed_user_agents' => [],
|
||||
|
||||
/**
|
||||
* By default, there is on public pages a link shown that allows users to
|
||||
* learn about the "simple sign up" - see https://nextcloud.com/signup/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ declare(strict_types=1);
|
|||
namespace OC\Core\Controller;
|
||||
|
||||
use OC\Core\Db\LoginFlowV2;
|
||||
use OC\Core\Exception\LoginFlowV2ClientForbiddenException;
|
||||
use OC\Core\Exception\LoginFlowV2NotFoundException;
|
||||
use OC\Core\ResponseDefinitions;
|
||||
use OC\Core\Service\LoginFlowV2Service;
|
||||
|
|
@ -109,6 +110,8 @@ class ClientFlowLoginV2Controller extends Controller {
|
|||
$flow = $this->getFlowByLoginToken();
|
||||
} catch (LoginFlowV2NotFoundException $e) {
|
||||
return $this->loginTokenForbiddenResponse();
|
||||
} catch (LoginFlowV2ClientForbiddenException $e) {
|
||||
return $this->loginTokenForbiddenClientResponse();
|
||||
}
|
||||
|
||||
$stateToken = $this->random->generate(
|
||||
|
|
@ -152,6 +155,8 @@ class ClientFlowLoginV2Controller extends Controller {
|
|||
$flow = $this->getFlowByLoginToken();
|
||||
} catch (LoginFlowV2NotFoundException $e) {
|
||||
return $this->loginTokenForbiddenResponse();
|
||||
} catch (LoginFlowV2ClientForbiddenException $e) {
|
||||
return $this->loginTokenForbiddenClientResponse();
|
||||
}
|
||||
|
||||
/** @var IUser $user */
|
||||
|
|
@ -188,6 +193,8 @@ class ClientFlowLoginV2Controller extends Controller {
|
|||
$this->getFlowByLoginToken();
|
||||
} catch (LoginFlowV2NotFoundException $e) {
|
||||
return $this->loginTokenForbiddenResponse();
|
||||
} catch (LoginFlowV2ClientForbiddenException $e) {
|
||||
return $this->loginTokenForbiddenClientResponse();
|
||||
}
|
||||
|
||||
$loginToken = $this->session->get(self::TOKEN_NAME);
|
||||
|
|
@ -233,6 +240,8 @@ class ClientFlowLoginV2Controller extends Controller {
|
|||
$this->getFlowByLoginToken();
|
||||
} catch (LoginFlowV2NotFoundException $e) {
|
||||
return $this->loginTokenForbiddenResponse();
|
||||
} catch (LoginFlowV2ClientForbiddenException $e) {
|
||||
return $this->loginTokenForbiddenClientResponse();
|
||||
}
|
||||
|
||||
$loginToken = $this->session->get(self::TOKEN_NAME);
|
||||
|
|
@ -333,6 +342,7 @@ class ClientFlowLoginV2Controller extends Controller {
|
|||
/**
|
||||
* @return LoginFlowV2
|
||||
* @throws LoginFlowV2NotFoundException
|
||||
* @throws LoginFlowV2ClientForbiddenException
|
||||
*/
|
||||
private function getFlowByLoginToken(): LoginFlowV2 {
|
||||
$currentToken = $this->session->get(self::TOKEN_NAME);
|
||||
|
|
@ -356,6 +366,19 @@ class ClientFlowLoginV2Controller extends Controller {
|
|||
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 = '';
|
||||
|
||||
|
|
|
|||
12
core/Exception/LoginFlowV2ClientForbiddenException.php
Normal file
12
core/Exception/LoginFlowV2ClientForbiddenException.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OC\Core\Exception;
|
||||
|
||||
class LoginFlowV2ClientForbiddenException extends \Exception {
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ use OC\Core\Data\LoginFlowV2Credentials;
|
|||
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 OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
|
|
@ -74,13 +75,33 @@ class LoginFlowV2Service {
|
|||
* @param string $loginToken
|
||||
* @return LoginFlowV2
|
||||
* @throws LoginFlowV2NotFoundException
|
||||
* @throws LoginFlowV2ClientForbiddenException
|
||||
*/
|
||||
public function getByLoginToken(string $loginToken): LoginFlowV2 {
|
||||
/** @var LoginFlowV2|null $flow */
|
||||
$flow = null;
|
||||
|
||||
try {
|
||||
return $this->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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ if (PHP_VERSION_ID < 50600) {
|
|||
echo $err;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException($err);
|
||||
trigger_error(
|
||||
$err,
|
||||
E_USER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
|
|
|
|||
|
|
@ -1371,6 +1371,7 @@ return array(
|
|||
'OC\\Core\\Db\\ProfileConfigMapper' => $baseDir . '/core/Db/ProfileConfigMapper.php',
|
||||
'OC\\Core\\Events\\BeforePasswordResetEvent' => $baseDir . '/core/Events/BeforePasswordResetEvent.php',
|
||||
'OC\\Core\\Events\\PasswordResetEvent' => $baseDir . '/core/Events/PasswordResetEvent.php',
|
||||
'OC\\Core\\Exception\\LoginFlowV2ClientForbiddenException' => $baseDir . '/core/Exception/LoginFlowV2ClientForbiddenException.php',
|
||||
'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => $baseDir . '/core/Exception/LoginFlowV2NotFoundException.php',
|
||||
'OC\\Core\\Exception\\ResetPasswordException' => $baseDir . '/core/Exception/ResetPasswordException.php',
|
||||
'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => $baseDir . '/core/Listener/BeforeMessageLoggedEventListener.php',
|
||||
|
|
|
|||
|
|
@ -1412,6 +1412,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
|
|||
'OC\\Core\\Db\\ProfileConfigMapper' => __DIR__ . '/../../..' . '/core/Db/ProfileConfigMapper.php',
|
||||
'OC\\Core\\Events\\BeforePasswordResetEvent' => __DIR__ . '/../../..' . '/core/Events/BeforePasswordResetEvent.php',
|
||||
'OC\\Core\\Events\\PasswordResetEvent' => __DIR__ . '/../../..' . '/core/Events/PasswordResetEvent.php',
|
||||
'OC\\Core\\Exception\\LoginFlowV2ClientForbiddenException' => __DIR__ . '/../../..' . '/core/Exception/LoginFlowV2ClientForbiddenException.php',
|
||||
'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => __DIR__ . '/../../..' . '/core/Exception/LoginFlowV2NotFoundException.php',
|
||||
'OC\\Core\\Exception\\ResetPasswordException' => __DIR__ . '/../../..' . '/core/Exception/ResetPasswordException.php',
|
||||
'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeMessageLoggedEventListener.php',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ namespace Test\Core\Controller;
|
|||
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 @@ class ClientFlowLoginV2ControllerTest extends TestCase {
|
|||
$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 @@ class ClientFlowLoginV2ControllerTest extends TestCase {
|
|||
$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(): void {
|
||||
$this->session->method('get')
|
||||
->with('client.flow.v2.login.token')
|
||||
|
|
@ -206,6 +229,29 @@ class ClientFlowLoginV2ControllerTest extends TestCase {
|
|||
$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(): void {
|
||||
$this->session->method('get')
|
||||
->willReturnCallback(function ($name) {
|
||||
|
|
@ -266,6 +312,29 @@ class ClientFlowLoginV2ControllerTest extends TestCase {
|
|||
$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(): void {
|
||||
$this->session->method('get')
|
||||
->willReturnCallback(function ($name) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
|
@ -14,6 +15,7 @@ use OC\Core\Data\LoginFlowV2Credentials;
|
|||
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 +239,57 @@ class LoginFlowV2ServiceUnitTest extends TestCase {
|
|||
$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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue