Skip to content

Commit 6bdda55

Browse files
Merge pull request #57453 from nextcloud/feat/noid/qr-code-in-account-menu
Improve usability of QR code login
2 parents 0e99c60 + 8b4491a commit 6bdda55

31 files changed

Lines changed: 853 additions & 82 deletions

apps/settings/lib/ConfigLexicon.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
* Please Add & Manage your Config Keys in that file and keep the Lexicon up to date!
2121
*/
2222
class ConfigLexicon implements ILexicon {
23+
public const LOGIN_QRCODE_ONETIME = 'qrcode_onetime';
2324
public const USER_SETTINGS_EMAIL = 'email';
2425
public const USER_LIST_SHOW_STORAGE_PATH = 'user_list_show_storage_path';
2526
public const USER_LIST_SHOW_USER_BACKEND = 'user_list_show_user_backend';
@@ -33,7 +34,9 @@ public function getStrictness(): Strictness {
3334
}
3435

3536
public function getAppConfigs(): array {
36-
return [];
37+
return [
38+
new Entry(key: self::LOGIN_QRCODE_ONETIME, type: ValueType::BOOL, defaultRaw: false, definition: 'Use onetime QR codes for app passwords', note: 'Limits compatibility for mobile apps to versions released in 2026 or later'),
39+
];
3740
}
3841

3942
public function getUserConfigs(): array {

apps/settings/lib/Controller/AuthSettingsController.php

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,20 @@
1414
use OC\Authentication\Token\IProvider;
1515
use OC\Authentication\Token\RemoteWipe;
1616
use OCA\Settings\Activity\Provider;
17+
use OCA\Settings\ConfigLexicon;
1718
use OCP\Activity\IManager;
1819
use OCP\AppFramework\Controller;
1920
use OCP\AppFramework\Http;
2021
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
2122
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
2223
use OCP\AppFramework\Http\JSONResponse;
24+
use OCP\AppFramework\Services\IAppConfig;
2325
use OCP\Authentication\Exceptions\ExpiredTokenException;
2426
use OCP\Authentication\Exceptions\InvalidTokenException;
2527
use OCP\Authentication\Exceptions\WipeTokenException;
2628
use OCP\Authentication\Token\IToken;
29+
use OCP\IConfig;
30+
use OCP\IL10N;
2731
use OCP\IRequest;
2832
use OCP\ISession;
2933
use OCP\IUserSession;
@@ -32,81 +36,96 @@
3236
use Psr\Log\LoggerInterface;
3337

3438
class AuthSettingsController extends Controller {
35-
/** @var IProvider */
36-
private $tokenProvider;
3739

38-
/** @var RemoteWipe */
39-
private $remoteWipe;
40-
41-
/**
42-
* @param string $appName
43-
* @param IRequest $request
44-
* @param IProvider $tokenProvider
45-
* @param ISession $session
46-
* @param ISecureRandom $random
47-
* @param string|null $userId
48-
* @param IUserSession $userSession
49-
* @param IManager $activityManager
50-
* @param RemoteWipe $remoteWipe
51-
* @param LoggerInterface $logger
52-
*/
5340
public function __construct(
5441
string $appName,
5542
IRequest $request,
56-
IProvider $tokenProvider,
43+
private IProvider $tokenProvider,
5744
private ISession $session,
5845
private ISecureRandom $random,
5946
private ?string $userId,
6047
private IUserSession $userSession,
6148
private IManager $activityManager,
62-
RemoteWipe $remoteWipe,
49+
private IAppConfig $appConfig,
50+
private RemoteWipe $remoteWipe,
6351
private LoggerInterface $logger,
52+
private IConfig $serverConfig,
53+
private IL10N $l,
6454
) {
6555
parent::__construct($appName, $request);
66-
$this->tokenProvider = $tokenProvider;
67-
$this->remoteWipe = $remoteWipe;
6856
}
6957

7058
/**
7159
* @NoSubAdminRequired
7260
*
73-
* @param string $name
74-
* @return JSONResponse
61+
* @param bool $qrcodeLogin If set to true, the returned token could be (depending on server settings) a onetime password, that can only be used to get the actual app password a single time
7562
*/
7663
#[NoAdminRequired]
77-
#[PasswordConfirmationRequired]
78-
public function create($name) {
64+
#[PasswordConfirmationRequired(strict: true)]
65+
public function create(string $name = '', bool $qrcodeLogin = false): JSONResponse {
7966
if ($this->checkAppToken()) {
8067
return $this->getServiceNotAvailableResponse();
8168
}
8269

8370
try {
8471
$sessionId = $this->session->getId();
85-
} catch (SessionNotAvailableException $ex) {
72+
} catch (SessionNotAvailableException) {
8673
return $this->getServiceNotAvailableResponse();
8774
}
8875
if ($this->userSession->getImpersonatingUserID() !== null) {
8976
return $this->getServiceNotAvailableResponse();
9077
}
9178

79+
if (!$this->serverConfig->getSystemValueBool('auth_can_create_app_token', true)) {
80+
return $this->getServiceNotAvailableResponse();
81+
}
82+
9283
try {
9384
$sessionToken = $this->tokenProvider->getToken($sessionId);
9485
$loginName = $sessionToken->getLoginName();
9586
try {
9687
$password = $this->tokenProvider->getPassword($sessionToken, $sessionId);
97-
} catch (PasswordlessTokenException $ex) {
88+
} catch (PasswordlessTokenException) {
9889
$password = null;
9990
}
100-
} catch (InvalidTokenException $ex) {
91+
} catch (InvalidTokenException) {
92+
return $this->getServiceNotAvailableResponse();
93+
}
94+
95+
if ($qrcodeLogin) {
96+
if ($this->appConfig->getAppValueBool(ConfigLexicon::LOGIN_QRCODE_ONETIME)) {
97+
// TRANSLATORS Fallback name for the temporary app password when using the QR code login
98+
$name = $this->l->t('One time login');
99+
$type = IToken::ONETIME_TOKEN;
100+
$scope = [];
101+
} else {
102+
// TRANSLATORS Fallback name for the app password when using the QR code login
103+
$name = $this->l->t('QR Code login');
104+
$type = IToken::PERMANENT_TOKEN;
105+
$scope = null;
106+
}
107+
} elseif ($name === '') {
108+
// No name is only allowed for one time logins
101109
return $this->getServiceNotAvailableResponse();
110+
} else {
111+
$type = IToken::PERMANENT_TOKEN;
112+
$scope = null;
102113
}
103114

104115
if (mb_strlen($name) > 128) {
105116
$name = mb_substr($name, 0, 120) . '';
106117
}
107118

108119
$token = $this->generateRandomDeviceToken();
109-
$deviceToken = $this->tokenProvider->generateToken($token, $this->userId, $loginName, $password, $name, IToken::PERMANENT_TOKEN);
120+
$deviceToken = $this->tokenProvider->generateToken(
121+
$token,
122+
$this->userId,
123+
$loginName,
124+
$password,
125+
$name,
126+
$type,
127+
scope: $scope,
128+
);
110129
$tokenData = $deviceToken->jsonSerialize();
111130
$tokenData['canDelete'] = true;
112131
$tokenData['canRename'] = true;

apps/settings/lib/Settings/Personal/Security/Authtokens.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OCP\AppFramework\Http\TemplateResponse;
1515
use OCP\AppFramework\Services\IInitialState;
1616
use OCP\Authentication\Exceptions\InvalidTokenException;
17+
use OCP\IConfig;
1718
use OCP\ISession;
1819
use OCP\IUserSession;
1920
use OCP\Session\Exceptions\SessionNotAvailableException;
@@ -27,6 +28,7 @@ public function __construct(
2728
private ISession $session,
2829
private IUserSession $userSession,
2930
private IInitialState $initialState,
31+
private IConfig $serverConfig,
3032
private ?string $userId,
3133
) {
3234
}
@@ -40,6 +42,7 @@ public function getForm(): TemplateResponse {
4042
$this->initialState->provideInitialState(
4143
'can_create_app_token',
4244
$this->userSession->getImpersonatingUserID() === null
45+
&& $this->serverConfig->getSystemValueBool('auth_can_create_app_token', true)
4346
);
4447

4548
return new TemplateResponse('settings', 'settings/personal/security/authtokens');

apps/settings/src/components/AuthTokenList.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
import { translate as t } from '@nextcloud/l10n'
3434
import { defineComponent } from 'vue'
3535
import AuthToken from './AuthToken.vue'
36-
import { useAuthTokenStore } from '../store/authtoken.ts'
36+
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'
3737
3838
export default defineComponent({
3939
name: 'AuthTokenList',
@@ -48,7 +48,9 @@ export default defineComponent({
4848
4949
computed: {
5050
sortedTokens() {
51-
return [...this.authTokenStore.tokens].sort((t1, t2) => t2.lastActivity - t1.lastActivity)
51+
return [...this.authTokenStore.tokens]
52+
.filter((t) => t.type !== TokenType.ONETIME_TOKEN)
53+
.sort((t1, t2) => t2.lastActivity - t1.lastActivity)
5254
},
5355
},
5456

apps/settings/src/store/authtoken.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import axios from '@nextcloud/axios'
66
import { showError } from '@nextcloud/dialogs'
77
import { loadState } from '@nextcloud/initial-state'
88
import { translate as t } from '@nextcloud/l10n'
9-
import { confirmPassword } from '@nextcloud/password-confirmation'
9+
import { addPasswordConfirmationInterceptors, confirmPassword, PwdConfirmationMode } from '@nextcloud/password-confirmation'
1010
import { generateUrl } from '@nextcloud/router'
1111
import { defineStore } from 'pinia'
1212
import logger from '../logger.ts'
1313

1414
const BASE_URL = generateUrl('/settings/personal/authtokens')
15+
addPasswordConfirmationInterceptors(axios)
1516

1617
/**
1718
*
@@ -31,6 +32,7 @@ export enum TokenType {
3132
TEMPORARY_TOKEN = 0,
3233
PERMANENT_TOKEN = 1,
3334
WIPING_TOKEN = 2,
35+
ONETIME_TOKEN = 3,
3436
}
3537

3638
export interface IToken {
@@ -88,9 +90,8 @@ export const useAuthTokenStore = defineStore('auth-token', {
8890
logger.debug('Creating a new app token')
8991

9092
try {
91-
await confirmPassword()
93+
const { data } = await axios.post<ITokenResponse>(BASE_URL, { name, oneTime: true }, { confirmPassword: PwdConfirmationMode.Strict })
9294

93-
const { data } = await axios.post<ITokenResponse>(BASE_URL, { name })
9495
this.tokens.push(data.deviceToken)
9596
logger.debug('App token created')
9697
return data

apps/settings/tests/Controller/AuthSettingsControllerTest.php

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
use OCP\Activity\IEvent;
2020
use OCP\Activity\IManager;
2121
use OCP\AppFramework\Http\JSONResponse;
22+
use OCP\AppFramework\Services\IAppConfig;
23+
use OCP\IConfig;
24+
use OCP\IL10N;
2225
use OCP\IRequest;
2326
use OCP\ISession;
2427
use OCP\IUserSession;
@@ -35,7 +38,10 @@ class AuthSettingsControllerTest extends TestCase {
3538
private IUserSession&MockObject $userSession;
3639
private ISecureRandom&MockObject $secureRandom;
3740
private IManager&MockObject $activityManager;
41+
private IAppConfig&MockObject $appConfig;
3842
private RemoteWipe&MockObject $remoteWipe;
43+
private IConfig&MockObject $serverConfig;
44+
private IL10N&MockObject $l;
3945
private string $uid = 'jane';
4046
private AuthSettingsController $controller;
4147

@@ -48,9 +54,12 @@ protected function setUp(): void {
4854
$this->userSession = $this->createMock(IUserSession::class);
4955
$this->secureRandom = $this->createMock(ISecureRandom::class);
5056
$this->activityManager = $this->createMock(IManager::class);
57+
$this->appConfig = $this->createMock(IAppConfig::class);
5158
$this->remoteWipe = $this->createMock(RemoteWipe::class);
59+
$this->serverConfig = $this->createMock(IConfig::class);
5260
/** @var LoggerInterface&MockObject $logger */
5361
$logger = $this->createMock(LoggerInterface::class);
62+
$this->l = $this->createMock(IL10N::class);
5463

5564
$this->controller = new AuthSettingsController(
5665
'core',
@@ -61,8 +70,11 @@ protected function setUp(): void {
6170
$this->uid,
6271
$this->userSession,
6372
$this->activityManager,
73+
$this->appConfig,
6474
$this->remoteWipe,
65-
$logger
75+
$logger,
76+
$this->serverConfig,
77+
$this->l,
6678
);
6779
}
6880

@@ -72,6 +84,9 @@ public function testCreate(): void {
7284
$deviceToken = $this->createMock(IToken::class);
7385
$password = '123456';
7486

87+
$this->serverConfig->method('getSystemValueBool')
88+
->with('auth_can_create_app_token', true)
89+
->willReturn(true);
7590
$this->session->expects($this->once())
7691
->method('getId')
7792
->willReturn('sessionid');
@@ -115,6 +130,30 @@ public function testCreate(): void {
115130
$this->assertEquals($expected, $response->getData());
116131
}
117132

133+
public function testCreateDisabledBySystemConfig(): void {
134+
$name = 'Nexus 4';
135+
136+
$this->serverConfig->method('getSystemValueBool')
137+
->with('auth_can_create_app_token', true)
138+
->willReturn(false);
139+
$this->session->expects($this->once())
140+
->method('getId')
141+
->willReturn('sessionid');
142+
$this->tokenProvider->expects($this->never())
143+
->method('getToken');
144+
$this->tokenProvider->expects($this->never())
145+
->method('getPassword');
146+
147+
148+
$this->tokenProvider->expects($this->never())
149+
->method('generateToken');
150+
151+
$expected = new JSONResponse();
152+
$expected->setStatus(Http::STATUS_SERVICE_UNAVAILABLE);
153+
154+
$this->assertEquals($expected, $this->controller->create($name));
155+
}
156+
118157
public function testCreateSessionNotAvailable(): void {
119158
$name = 'personal phone';
120159

@@ -131,6 +170,9 @@ public function testCreateSessionNotAvailable(): void {
131170
public function testCreateInvalidToken(): void {
132171
$name = 'Company IPhone';
133172

173+
$this->serverConfig->method('getSystemValueBool')
174+
->with('auth_can_create_app_token', true)
175+
->willReturn(true);
134176
$this->session->expects($this->once())
135177
->method('getId')
136178
->willReturn('sessionid');

apps/settings/tests/Settings/Personal/Security/AuthtokensTest.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OCP\AppFramework\Http\TemplateResponse;
1515
use OCP\AppFramework\Services\IInitialState;
1616
use OCP\Authentication\Token\IToken;
17+
use OCP\IConfig;
1718
use OCP\ISession;
1819
use OCP\IUserSession;
1920
use PHPUnit\Framework\MockObject\MockObject;
@@ -24,6 +25,7 @@ class AuthtokensTest extends TestCase {
2425
private ISession&MockObject $session;
2526
private IUserSession&MockObject $userSession;
2627
private IInitialState&MockObject $initialState;
28+
private IConfig&MockObject $serverConfig;
2729
private string $uid;
2830
private Authtokens $section;
2931

@@ -34,14 +36,16 @@ protected function setUp(): void {
3436
$this->session = $this->createMock(ISession::class);
3537
$this->userSession = $this->createMock(IUserSession::class);
3638
$this->initialState = $this->createMock(IInitialState::class);
39+
$this->serverConfig = $this->createMock(IConfig::class);
3740
$this->uid = 'test123';
3841

3942
$this->section = new Authtokens(
4043
$this->authTokenProvider,
4144
$this->session,
4245
$this->userSession,
4346
$this->initialState,
44-
$this->uid
47+
$this->serverConfig,
48+
$this->uid,
4549
);
4650
}
4751

@@ -57,6 +61,9 @@ public function testGetForm(): void {
5761
$sessionToken = new PublicKeyToken();
5862
$sessionToken->setId(100);
5963

64+
$this->serverConfig->method('getSystemValueBool')
65+
->with('auth_can_create_app_token', true)
66+
->willReturn(true);
6067
$this->authTokenProvider->expects($this->once())
6168
->method('getTokenByUser')
6269
->with($this->uid)

0 commit comments

Comments
 (0)