Skip to content

Commit 1ce7ca2

Browse files
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 <[email protected]>
1 parent c82bc0a commit 1ce7ca2

6 files changed

Lines changed: 192 additions & 1 deletion

File tree

config/config.sample.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2438,6 +2438,20 @@
24382438
'/^Microsoft-WebDAV-MiniRedir/', // Windows webdav drive
24392439
],
24402440

2441+
/**
2442+
* This option allows you to specify a list of allowed user agents for the Login Flow V2.
2443+
* If a user agent is not in this list, it will not be allowed to use the Login Flow V2.
2444+
* The user agents are defined using regular expressions.
2445+
*
2446+
* WARNING: only use this if you know what you are doing
2447+
*
2448+
* Example: Allow only the Nextcloud Android app to use the Login Flow V2
2449+
* 'core.login_flow_v2.allowed_user_agents' => ['/Nextcloud-android/i'],
2450+
*
2451+
* Defaults to an empty array.
2452+
*/
2453+
'core.login_flow_v2.allowed_user_agents' => [],
2454+
24412455
/**
24422456
* By default, there is on public pages a link shown that allows users to
24432457
* learn about the "simple sign up" - see https://nextcloud.com/signup/

core/Controller/ClientFlowLoginV2Controller.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
use OC\Core\Db\LoginFlowV2;
1212
use OC\Core\Exception\LoginFlowV2NotFoundException;
13+
use OC\Core\Exception\LoginFlowV2ClientForbiddenException;
1314
use OC\Core\ResponseDefinitions;
1415
use OC\Core\Service\LoginFlowV2Service;
1516
use OCP\AppFramework\Controller;
@@ -107,6 +108,8 @@ public function showAuthPickerPage(string $user = '', int $direct = 0): Standalo
107108
$flow = $this->getFlowByLoginToken();
108109
} catch (LoginFlowV2NotFoundException $e) {
109110
return $this->loginTokenForbiddenResponse();
111+
} catch (LoginFlowV2ClientForbiddenException $e) {
112+
return $this->loginTokenForbiddenClientResponse();
110113
}
111114

112115
$stateToken = $this->random->generate(
@@ -150,6 +153,8 @@ public function grantPage(?string $stateToken, int $direct = 0): StandaloneTempl
150153
$flow = $this->getFlowByLoginToken();
151154
} catch (LoginFlowV2NotFoundException $e) {
152155
return $this->loginTokenForbiddenResponse();
156+
} catch (LoginFlowV2ClientForbiddenException $e) {
157+
return $this->loginTokenForbiddenClientResponse();
153158
}
154159

155160
/** @var IUser $user */
@@ -186,6 +191,8 @@ public function apptokenRedirect(?string $stateToken, string $user, string $pass
186191
$this->getFlowByLoginToken();
187192
} catch (LoginFlowV2NotFoundException $e) {
188193
return $this->loginTokenForbiddenResponse();
194+
} catch (LoginFlowV2ClientForbiddenException $e) {
195+
return $this->loginTokenForbiddenClientResponse();
189196
}
190197

191198
$loginToken = $this->session->get(self::TOKEN_NAME);
@@ -231,6 +238,8 @@ public function generateAppPassword(?string $stateToken): Response {
231238
$this->getFlowByLoginToken();
232239
} catch (LoginFlowV2NotFoundException $e) {
233240
return $this->loginTokenForbiddenResponse();
241+
} catch (LoginFlowV2ClientForbiddenException $e) {
242+
return $this->loginTokenForbiddenClientResponse();
234243
}
235244

236245
$loginToken = $this->session->get(self::TOKEN_NAME);
@@ -331,6 +340,7 @@ private function stateTokenForbiddenResponse(): StandaloneTemplateResponse {
331340
/**
332341
* @return LoginFlowV2
333342
* @throws LoginFlowV2NotFoundException
343+
* @throws LoginFlowV2ClientForbiddenException
334344
*/
335345
private function getFlowByLoginToken(): LoginFlowV2 {
336346
$currentToken = $this->session->get(self::TOKEN_NAME);
@@ -354,6 +364,19 @@ private function loginTokenForbiddenResponse(): StandaloneTemplateResponse {
354364
return $response;
355365
}
356366

367+
private function loginTokenForbiddenClientResponse(): StandaloneTemplateResponse {
368+
$response = new StandaloneTemplateResponse(
369+
$this->appName,
370+
'403',
371+
[
372+
'message' => $this->l10n->t('Please use original client'),
373+
],
374+
'guest'
375+
);
376+
$response->setStatus(Http::STATUS_FORBIDDEN);
377+
return $response;
378+
}
379+
357380
private function getServerPath(): string {
358381
$serverPostfix = '';
359382

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 STRATO GmbH
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OC\Core\Exception;
10+
11+
class LoginFlowV2ClientForbiddenException extends \Exception {
12+
}

core/Service/LoginFlowV2Service.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use OC\Core\Data\LoginFlowV2Tokens;
1616
use OC\Core\Db\LoginFlowV2;
1717
use OC\Core\Db\LoginFlowV2Mapper;
18+
use OC\Core\Exception\LoginFlowV2ClientForbiddenException;
1819
use OC\Core\Exception\LoginFlowV2NotFoundException;
1920
use OCP\AppFramework\Db\DoesNotExistException;
2021
use OCP\AppFramework\Utility\ITimeFactory;
@@ -74,13 +75,33 @@ public function poll(string $pollToken): LoginFlowV2Credentials {
7475
* @param string $loginToken
7576
* @return LoginFlowV2
7677
* @throws LoginFlowV2NotFoundException
78+
* @throws LoginFlowV2ClientForbiddenException
7779
*/
7880
public function getByLoginToken(string $loginToken): LoginFlowV2 {
81+
/** @var LoginFlowV2|null $flow */
82+
$flow = null;
83+
7984
try {
80-
return $this->mapper->getByLoginToken($loginToken);
85+
$flow = $this->mapper->getByLoginToken($loginToken);
8186
} catch (DoesNotExistException $e) {
8287
throw new LoginFlowV2NotFoundException('Login token invalid');
8388
}
89+
90+
$allowedAgents = $this->config->getSystemValue('core.login_flow_v2.allowed_user_agents', []);
91+
92+
if (empty($allowedAgents)) {
93+
return $flow;
94+
}
95+
96+
$flowClient = $flow->getClientName();
97+
98+
foreach ($allowedAgents as $allowedAgent) {
99+
if (preg_match($allowedAgent, $flowClient) === 1) {
100+
return $flow;
101+
}
102+
}
103+
104+
throw new LoginFlowV2ClientForbiddenException('Client not allowed');
84105
}
85106

86107
/**

tests/Core/Controller/ClientFlowLoginV2ControllerTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use OC\Core\Controller\ClientFlowLoginV2Controller;
1212
use OC\Core\Data\LoginFlowV2Credentials;
1313
use OC\Core\Db\LoginFlowV2;
14+
use OC\Core\Exception\LoginFlowV2ClientForbiddenException;
1415
use OC\Core\Exception\LoginFlowV2NotFoundException;
1516
use OC\Core\Service\LoginFlowV2Service;
1617
use OCP\AppFramework\Http;
@@ -56,6 +57,12 @@ protected function setUp(): void {
5657
$this->random = $this->createMock(ISecureRandom::class);
5758
$this->defaults = $this->createMock(Defaults::class);
5859
$this->l = $this->createMock(IL10N::class);
60+
$this->l
61+
->expects($this->any())
62+
->method('t')
63+
->willReturnCallback(function ($text, $parameters = []) {
64+
return vsprintf($text, $parameters);
65+
});
5966
$this->controller = new ClientFlowLoginV2Controller(
6067
'core',
6168
$this->request,
@@ -150,6 +157,22 @@ public function testShowAuthPickerInvalidLoginToken(): void {
150157
$this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus());
151158
}
152159

160+
public function testShowAuthPickerForbiddenUserClient() {
161+
$this->session->method('get')
162+
->with('client.flow.v2.login.token')
163+
->willReturn('loginToken');
164+
165+
$this->loginFlowV2Service->method('getByLoginToken')
166+
->with('loginToken')
167+
->willThrowException(new LoginFlowV2ClientForbiddenException());
168+
169+
$result = $this->controller->showAuthPickerPage();
170+
171+
$this->assertInstanceOf(Http\StandaloneTemplateResponse::class, $result);
172+
$this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus());
173+
$this->assertSame('Please use original client', $result->getParams()['message']);
174+
}
175+
153176
public function testShowAuthPickerValidLoginToken(): void {
154177
$this->session->method('get')
155178
->with('client.flow.v2.login.token')
@@ -206,6 +229,29 @@ public function testGrantPageInvalidLoginToken(): void {
206229
$this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus());
207230
}
208231

232+
public function testGrantPageForbiddenUserClient() {
233+
$this->session->method('get')
234+
->willReturnCallback(function ($name) {
235+
if ($name === 'client.flow.v2.state.token') {
236+
return 'stateToken';
237+
}
238+
if ($name === 'client.flow.v2.login.token') {
239+
return 'loginToken';
240+
}
241+
return null;
242+
});
243+
244+
$this->loginFlowV2Service->method('getByLoginToken')
245+
->with('loginToken')
246+
->willThrowException(new LoginFlowV2ClientForbiddenException());
247+
248+
$result = $this->controller->grantPage('stateToken');
249+
250+
$this->assertInstanceOf(Http\StandaloneTemplateResponse::class, $result);
251+
$this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus());
252+
$this->assertSame('Please use original client', $result->getParams()['message']);
253+
}
254+
209255
public function testGrantPageValid(): void {
210256
$this->session->method('get')
211257
->willReturnCallback(function ($name) {
@@ -266,6 +312,29 @@ public function testGenerateAppPassworInvalidLoginToken(): void {
266312
$this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus());
267313
}
268314

315+
public function testGenerateAppPasswordForbiddenUserClient() {
316+
$this->session->method('get')
317+
->willReturnCallback(function ($name) {
318+
if ($name === 'client.flow.v2.state.token') {
319+
return 'stateToken';
320+
}
321+
if ($name === 'client.flow.v2.login.token') {
322+
return 'loginToken';
323+
}
324+
return null;
325+
});
326+
327+
$this->loginFlowV2Service->method('getByLoginToken')
328+
->with('loginToken')
329+
->willThrowException(new LoginFlowV2ClientForbiddenException());
330+
331+
$result = $this->controller->generateAppPassword('stateToken');
332+
333+
$this->assertInstanceOf(Http\StandaloneTemplateResponse::class, $result);
334+
$this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus());
335+
$this->assertSame('Please use original client', $result->getParams()['message']);
336+
}
337+
269338
public function testGenerateAppPassworValid(): void {
270339
$this->session->method('get')
271340
->willReturnCallback(function ($name) {

tests/Core/Service/LoginFlowV2ServiceUnitTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OC\Core\Data\LoginFlowV2Tokens;
1515
use OC\Core\Db\LoginFlowV2;
1616
use OC\Core\Db\LoginFlowV2Mapper;
17+
use OC\Core\Exception\LoginFlowV2ClientForbiddenException;
1718
use OC\Core\Exception\LoginFlowV2NotFoundException;
1819
use OC\Core\Service\LoginFlowV2Service;
1920
use OCP\AppFramework\Db\DoesNotExistException;
@@ -237,6 +238,57 @@ public function testGetByLoginTokenLoginTokenInvalid(): void {
237238
$this->subjectUnderTest->getByLoginToken('test_token');
238239
}
239240

241+
public function testGetByLoginTokenClientForbidden() {
242+
$this->expectException(LoginFlowV2ClientForbiddenException::class);
243+
$this->expectExceptionMessage('Client not allowed');
244+
245+
$allowedClients = [
246+
'/Custom Allowed Client/i'
247+
];
248+
249+
$this->config->expects($this->exactly(1))
250+
->method('getSystemValue')
251+
->willReturn($this->returnCallback(function ($key) use ($allowedClients) {
252+
// Note: \OCP\IConfig::getSystemValue returns either an array or string.
253+
return $key == 'core.login_flow_v2.allowed_user_agents' ? $allowedClients : '';
254+
}));
255+
256+
$loginFlowV2 = new LoginFlowV2();
257+
$loginFlowV2->setClientName('Rogue Curl Client/1.0');
258+
259+
$this->mapper->expects($this->once())
260+
->method('getByLoginToken')
261+
->willReturn($loginFlowV2);
262+
263+
$this->subjectUnderTest->getByLoginToken('test_token');
264+
}
265+
266+
public function testGetByLoginTokenClientAllowed() {
267+
$allowedClients = [
268+
'/Foo Allowed Client/i',
269+
'/Custom Allowed Client/i'
270+
];
271+
272+
$loginFlowV2 = new LoginFlowV2();
273+
$loginFlowV2->setClientName('Custom Allowed Client Curl Client/1.0');
274+
275+
$this->config->expects($this->exactly(1))
276+
->method('getSystemValue')
277+
->willReturn($this->returnCallback(function ($key) use ($allowedClients) {
278+
// Note: \OCP\IConfig::getSystemValue returns either an array or string.
279+
return $key == 'core.login_flow_v2.allowed_user_agents' ? $allowedClients : '';
280+
}));
281+
282+
$this->mapper->expects($this->once())
283+
->method('getByLoginToken')
284+
->willReturn($loginFlowV2);
285+
286+
$result = $this->subjectUnderTest->getByLoginToken('test_token');
287+
288+
$this->assertTrue($result instanceof LoginFlowV2);
289+
$this->assertEquals('Custom Allowed Client Curl Client/1.0', $result->getClientName());
290+
}
291+
240292
/*
241293
* Tests for startLoginFlow
242294
*/

0 commit comments

Comments
 (0)