Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/settings/lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* Please Add & Manage your Config Keys in that file and keep the Lexicon up to date!
*/
class ConfigLexicon implements ILexicon {
public const LOGIN_QRCODE_ONETIME = 'qrcode_onetime';
public const USER_SETTINGS_EMAIL = 'email';
public const USER_LIST_SHOW_STORAGE_PATH = 'user_list_show_storage_path';
public const USER_LIST_SHOW_USER_BACKEND = 'user_list_show_user_backend';
Expand All @@ -33,7 +34,9 @@ public function getStrictness(): Strictness {
}

public function getAppConfigs(): array {
return [];
return [
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'),
];
}

public function getUserConfigs(): array {
Expand Down
77 changes: 48 additions & 29 deletions apps/settings/lib/Controller/AuthSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,20 @@
use OC\Authentication\Token\IProvider;
use OC\Authentication\Token\RemoteWipe;
use OCA\Settings\Activity\Provider;
use OCA\Settings\ConfigLexicon;
use OCP\Activity\IManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Authentication\Exceptions\ExpiredTokenException;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\Authentication\Exceptions\WipeTokenException;
use OCP\Authentication\Token\IToken;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUserSession;
Expand All @@ -32,81 +36,96 @@
use Psr\Log\LoggerInterface;

class AuthSettingsController extends Controller {
/** @var IProvider */
private $tokenProvider;

/** @var RemoteWipe */
private $remoteWipe;

/**
* @param string $appName
* @param IRequest $request
* @param IProvider $tokenProvider
* @param ISession $session
* @param ISecureRandom $random
* @param string|null $userId
* @param IUserSession $userSession
* @param IManager $activityManager
* @param RemoteWipe $remoteWipe
* @param LoggerInterface $logger
*/
public function __construct(
string $appName,
IRequest $request,
IProvider $tokenProvider,
private IProvider $tokenProvider,
private ISession $session,
private ISecureRandom $random,
private ?string $userId,
private IUserSession $userSession,
private IManager $activityManager,
RemoteWipe $remoteWipe,
private IAppConfig $appConfig,
private RemoteWipe $remoteWipe,
private LoggerInterface $logger,
private IConfig $serverConfig,
private IL10N $l,
) {
parent::__construct($appName, $request);
$this->tokenProvider = $tokenProvider;
$this->remoteWipe = $remoteWipe;
}

/**
* @NoSubAdminRequired
*
* @param string $name
* @return JSONResponse
* @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
*/
#[NoAdminRequired]
#[PasswordConfirmationRequired]
public function create($name) {
#[PasswordConfirmationRequired(strict: true)]
public function create(string $name = '', bool $qrcodeLogin = false): JSONResponse {
if ($this->checkAppToken()) {
return $this->getServiceNotAvailableResponse();
}

try {
$sessionId = $this->session->getId();
} catch (SessionNotAvailableException $ex) {
} catch (SessionNotAvailableException) {
return $this->getServiceNotAvailableResponse();
}
if ($this->userSession->getImpersonatingUserID() !== null) {
return $this->getServiceNotAvailableResponse();
}

if (!$this->serverConfig->getSystemValueBool('auth_can_create_app_token', true)) {
return $this->getServiceNotAvailableResponse();
}

try {
$sessionToken = $this->tokenProvider->getToken($sessionId);
$loginName = $sessionToken->getLoginName();
try {
$password = $this->tokenProvider->getPassword($sessionToken, $sessionId);
} catch (PasswordlessTokenException $ex) {
} catch (PasswordlessTokenException) {
$password = null;
}
} catch (InvalidTokenException $ex) {
} catch (InvalidTokenException) {
return $this->getServiceNotAvailableResponse();
}

if ($qrcodeLogin) {
if ($this->appConfig->getAppValueBool(ConfigLexicon::LOGIN_QRCODE_ONETIME)) {
// TRANSLATORS Fallback name for the temporary app password when using the QR code login
$name = $this->l->t('One time login');
$type = IToken::ONETIME_TOKEN;
$scope = [];
} else {
// TRANSLATORS Fallback name for the app password when using the QR code login
$name = $this->l->t('QR Code login');
$type = IToken::PERMANENT_TOKEN;
$scope = null;
}
} elseif ($name === '') {
// No name is only allowed for one time logins
return $this->getServiceNotAvailableResponse();
} else {
$type = IToken::PERMANENT_TOKEN;
$scope = null;
}

if (mb_strlen($name) > 128) {
$name = mb_substr($name, 0, 120) . '…';
}

$token = $this->generateRandomDeviceToken();
$deviceToken = $this->tokenProvider->generateToken($token, $this->userId, $loginName, $password, $name, IToken::PERMANENT_TOKEN);
$deviceToken = $this->tokenProvider->generateToken(
$token,
$this->userId,
$loginName,
$password,
$name,
$type,
scope: $scope,
);
$tokenData = $deviceToken->jsonSerialize();
$tokenData['canDelete'] = true;
$tokenData['canRename'] = true;
Expand Down
3 changes: 3 additions & 0 deletions apps/settings/lib/Settings/Personal/Security/Authtokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\IConfig;
use OCP\ISession;
use OCP\IUserSession;
use OCP\Session\Exceptions\SessionNotAvailableException;
Expand All @@ -27,6 +28,7 @@ public function __construct(
private ISession $session,
private IUserSession $userSession,
private IInitialState $initialState,
private IConfig $serverConfig,
private ?string $userId,
) {
}
Expand All @@ -40,6 +42,7 @@ public function getForm(): TemplateResponse {
$this->initialState->provideInitialState(
'can_create_app_token',
$this->userSession->getImpersonatingUserID() === null
&& $this->serverConfig->getSystemValueBool('auth_can_create_app_token', true)
);

return new TemplateResponse('settings', 'settings/personal/security/authtokens');
Expand Down
6 changes: 4 additions & 2 deletions apps/settings/src/components/AuthTokenList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import AuthToken from './AuthToken.vue'
import { useAuthTokenStore } from '../store/authtoken.ts'
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'

export default defineComponent({
name: 'AuthTokenList',
Expand All @@ -48,7 +48,9 @@ export default defineComponent({

computed: {
sortedTokens() {
return [...this.authTokenStore.tokens].sort((t1, t2) => t2.lastActivity - t1.lastActivity)
return [...this.authTokenStore.tokens]
.filter((t) => t.type !== TokenType.ONETIME_TOKEN)
.sort((t1, t2) => t2.lastActivity - t1.lastActivity)
},
},

Expand Down
7 changes: 4 additions & 3 deletions apps/settings/src/store/authtoken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { addPasswordConfirmationInterceptors, confirmPassword, PwdConfirmationMode } from '@nextcloud/password-confirmation'
import { generateUrl } from '@nextcloud/router'
import { defineStore } from 'pinia'
import logger from '../logger.ts'

const BASE_URL = generateUrl('/settings/personal/authtokens')
addPasswordConfirmationInterceptors(axios)

/**
*
Expand All @@ -31,6 +32,7 @@ export enum TokenType {
TEMPORARY_TOKEN = 0,
PERMANENT_TOKEN = 1,
WIPING_TOKEN = 2,
ONETIME_TOKEN = 3,
}

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

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

const { data } = await axios.post<ITokenResponse>(BASE_URL, { name })
this.tokens.push(data.deviceToken)
logger.debug('App token created')
return data
Expand Down
44 changes: 43 additions & 1 deletion apps/settings/tests/Controller/AuthSettingsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
use OCP\Activity\IEvent;
use OCP\Activity\IManager;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Services\IAppConfig;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUserSession;
Expand All @@ -35,7 +38,10 @@ class AuthSettingsControllerTest extends TestCase {
private IUserSession&MockObject $userSession;
private ISecureRandom&MockObject $secureRandom;
private IManager&MockObject $activityManager;
private IAppConfig&MockObject $appConfig;
private RemoteWipe&MockObject $remoteWipe;
private IConfig&MockObject $serverConfig;
private IL10N&MockObject $l;
private string $uid = 'jane';
private AuthSettingsController $controller;

Expand All @@ -48,9 +54,12 @@ protected function setUp(): void {
$this->userSession = $this->createMock(IUserSession::class);
$this->secureRandom = $this->createMock(ISecureRandom::class);
$this->activityManager = $this->createMock(IManager::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->remoteWipe = $this->createMock(RemoteWipe::class);
$this->serverConfig = $this->createMock(IConfig::class);
/** @var LoggerInterface&MockObject $logger */
$logger = $this->createMock(LoggerInterface::class);
$this->l = $this->createMock(IL10N::class);

$this->controller = new AuthSettingsController(
'core',
Expand All @@ -61,8 +70,11 @@ protected function setUp(): void {
$this->uid,
$this->userSession,
$this->activityManager,
$this->appConfig,
$this->remoteWipe,
$logger
$logger,
$this->serverConfig,
$this->l,
);
}

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

$this->serverConfig->method('getSystemValueBool')
->with('auth_can_create_app_token', true)
->willReturn(true);
$this->session->expects($this->once())
->method('getId')
->willReturn('sessionid');
Expand Down Expand Up @@ -115,6 +130,30 @@ public function testCreate(): void {
$this->assertEquals($expected, $response->getData());
}

public function testCreateDisabledBySystemConfig(): void {
$name = 'Nexus 4';

$this->serverConfig->method('getSystemValueBool')
->with('auth_can_create_app_token', true)
->willReturn(false);
$this->session->expects($this->once())
->method('getId')
->willReturn('sessionid');
$this->tokenProvider->expects($this->never())
->method('getToken');
$this->tokenProvider->expects($this->never())
->method('getPassword');


$this->tokenProvider->expects($this->never())
->method('generateToken');

$expected = new JSONResponse();
$expected->setStatus(Http::STATUS_SERVICE_UNAVAILABLE);

$this->assertEquals($expected, $this->controller->create($name));
}

public function testCreateSessionNotAvailable(): void {
$name = 'personal phone';

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

$this->serverConfig->method('getSystemValueBool')
->with('auth_can_create_app_token', true)
->willReturn(true);
$this->session->expects($this->once())
->method('getId')
->willReturn('sessionid');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Authentication\Token\IToken;
use OCP\IConfig;
use OCP\ISession;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
Expand All @@ -24,6 +25,7 @@ class AuthtokensTest extends TestCase {
private ISession&MockObject $session;
private IUserSession&MockObject $userSession;
private IInitialState&MockObject $initialState;
private IConfig&MockObject $serverConfig;
private string $uid;
private Authtokens $section;

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

$this->section = new Authtokens(
$this->authTokenProvider,
$this->session,
$this->userSession,
$this->initialState,
$this->uid
$this->serverConfig,
$this->uid,
);
}

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

$this->serverConfig->method('getSystemValueBool')
->with('auth_can_create_app_token', true)
->willReturn(true);
$this->authTokenProvider->expects($this->once())
->method('getTokenByUser')
->with($this->uid)
Expand Down
Loading
Loading