From c7669baca753c5c2ee2451adf11d02e8fd570253 Mon Sep 17 00:00:00 2001 From: Charles Taborin Date: Fri, 30 Jan 2026 16:34:57 +0100 Subject: [PATCH 1/3] feat(settings): add event to customize app password token generation Signed-off-by: Charles Taborin --- AUTHORS | 1 + .../lib/Controller/AuthSettingsController.php | 9 +++ .../Controller/AuthSettingsControllerTest.php | 74 +++++++++++++++---- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 13 ++-- .../Events/AfterAuthTokenCreatedEvent.php | 44 +++++++++++ 6 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php diff --git a/AUTHORS b/AUTHORS index fe478401fddb4..a816c2d1698be 100644 --- a/AUTHORS +++ b/AUTHORS @@ -86,6 +86,7 @@ - Carlos Cerrillo - Carlos Ferreira - Carsten Wiedmann + - Charles Taborin - Chih-Hsuan Yen - Christian <16852529+cviereck@users.noreply.github.com> - Christian Berendt diff --git a/apps/settings/lib/Controller/AuthSettingsController.php b/apps/settings/lib/Controller/AuthSettingsController.php index 7d1ff16027d17..ad63c1f3208c1 100644 --- a/apps/settings/lib/Controller/AuthSettingsController.php +++ b/apps/settings/lib/Controller/AuthSettingsController.php @@ -15,6 +15,7 @@ use OC\Authentication\Token\RemoteWipe; use OCA\Settings\Activity\Provider; use OCA\Settings\ConfigLexicon; +use OCP\Authentication\Events\AfterAuthTokenCreatedEvent; use OCP\Activity\IManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -26,6 +27,7 @@ use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\Authentication\Exceptions\WipeTokenException; use OCP\Authentication\Token\IToken; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; @@ -46,6 +48,7 @@ public function __construct( private ?string $userId, private IUserSession $userSession, private IManager $activityManager, + private IEventDispatcher $eventDispatcher, private IAppConfig $appConfig, private RemoteWipe $remoteWipe, private LoggerInterface $logger, @@ -117,6 +120,12 @@ public function create(string $name = '', bool $qrcodeLogin = false): JSONRespon } $token = $this->generateRandomDeviceToken(); + + // Allow apps to post-process the generated token before persisting it + $event = new AfterAuthTokenCreatedEvent($token); + $this->eventDispatcher->dispatchTyped($event); + $token = $event->getToken(); + $deviceToken = $this->tokenProvider->generateToken( $token, $this->userId, diff --git a/apps/settings/tests/Controller/AuthSettingsControllerTest.php b/apps/settings/tests/Controller/AuthSettingsControllerTest.php index 5d75a1aa09aef..8a34a40dbdfa7 100644 --- a/apps/settings/tests/Controller/AuthSettingsControllerTest.php +++ b/apps/settings/tests/Controller/AuthSettingsControllerTest.php @@ -16,10 +16,12 @@ use OC\Authentication\Token\PublicKeyToken; use OC\Authentication\Token\RemoteWipe; use OCA\Settings\Controller\AuthSettingsController; +use OCP\Authentication\Events\AfterAuthTokenCreatedEvent; use OCP\Activity\IEvent; use OCP\Activity\IManager; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Services\IAppConfig; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; @@ -38,6 +40,7 @@ class AuthSettingsControllerTest extends TestCase { private IUserSession&MockObject $userSession; private ISecureRandom&MockObject $secureRandom; private IManager&MockObject $activityManager; + private IEventDispatcher&MockObject $eventDispatcher; private IAppConfig&MockObject $appConfig; private RemoteWipe&MockObject $remoteWipe; private IConfig&MockObject $serverConfig; @@ -54,12 +57,13 @@ protected function setUp(): void { $this->userSession = $this->createMock(IUserSession::class); $this->secureRandom = $this->createMock(ISecureRandom::class); $this->activityManager = $this->createMock(IManager::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->appConfig = $this->createMock(IAppConfig::class); $this->remoteWipe = $this->createMock(RemoteWipe::class); $this->serverConfig = $this->createMock(IConfig::class); + $this->l = $this->createMock(IL10N::class); /** @var LoggerInterface&MockObject $logger */ $logger = $this->createMock(LoggerInterface::class); - $this->l = $this->createMock(IL10N::class); $this->controller = new AuthSettingsController( 'core', @@ -70,6 +74,7 @@ protected function setUp(): void { $this->uid, $this->userSession, $this->activityManager, + $this->eventDispatcher, $this->appConfig, $this->remoteWipe, $logger, @@ -108,6 +113,13 @@ public function testCreate(): void { ->willReturn('XXXXX'); $newToken = 'XXXXX-XXXXX-XXXXX-XXXXX-XXXXX'; + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (AfterAuthTokenCreatedEvent $event) use ($newToken) { + $this->assertSame($newToken, $event->getToken()); + return true; + })); + $this->tokenProvider->expects($this->once()) ->method('generateToken') ->with($newToken, $this->uid, 'User13', $password, $name, IToken::PERMANENT_TOKEN) @@ -130,28 +142,64 @@ public function testCreate(): void { $this->assertEquals($expected, $response->getData()); } - public function testCreateDisabledBySystemConfig(): void { - $name = 'Nexus 4'; + public function testCreateTokenModifiedByEvent(): void { + $name = 'Pixel 8'; + $sessionToken = $this->createMock(IToken::class); + $deviceToken = $this->createMock(IToken::class); + $password = 'secret'; $this->serverConfig->method('getSystemValueBool') ->with('auth_can_create_app_token', true) - ->willReturn(false); + ->willReturn(true); $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->once()) + ->method('getToken') + ->with('sessionid') + ->willReturn($sessionToken); + $this->tokenProvider->expects($this->once()) + ->method('getPassword') + ->with($sessionToken, 'sessionid') + ->willReturn($password); + $sessionToken->expects($this->once()) + ->method('getLoginName') + ->willReturn('User99'); + $this->secureRandom->expects($this->exactly(5)) + ->method('generate') + ->with(5, ISecureRandom::CHAR_HUMAN_READABLE) + ->willReturnOnConsecutiveCalls('AAAAA', 'BBBBB', 'CCCCC', 'DDDDD', 'EEEEE'); + $initialToken = 'AAAAA-BBBBB-CCCCC-DDDDD-EEEEE'; - $this->tokenProvider->expects($this->never()) - ->method('generateToken'); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (AfterAuthTokenCreatedEvent $event) use ($initialToken) { + $this->assertSame($initialToken, $event->getToken()); + $event->setToken('custom-token'); + return true; + })); - $expected = new JSONResponse(); - $expected->setStatus(Http::STATUS_SERVICE_UNAVAILABLE); + $this->tokenProvider->expects($this->once()) + ->method('generateToken') + ->with('custom-token', $this->uid, 'User99', $password, $name, IToken::PERMANENT_TOKEN, null) + ->willReturn($deviceToken); - $this->assertEquals($expected, $this->controller->create($name)); + $deviceToken->expects($this->once()) + ->method('jsonSerialize') + ->willReturn(['dummy' => 'dummy', 'canDelete' => true]); + + $this->mockActivityManager(); + + $expected = [ + 'token' => 'custom-token', + 'deviceToken' => ['dummy' => 'dummy', 'canDelete' => true, 'canRename' => true], + 'loginName' => 'User99', + ]; + + $response = $this->controller->create($name); + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertEquals($expected, $response->getData()); } public function testCreateSessionNotAvailable(): void { diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 0cec259303926..39cd79a4cfa64 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -159,6 +159,7 @@ 'OCP\\App\\Events\\AppUpdateEvent' => $baseDir . '/lib/public/App/Events/AppUpdateEvent.php', 'OCP\\App\\IAppManager' => $baseDir . '/lib/public/App/IAppManager.php', 'OCP\\App\\ManagerEvent' => $baseDir . '/lib/public/App/ManagerEvent.php', + 'OCP\\Authentication\\Events\\AfterAuthTokenCreatedEvent' => $baseDir . '/lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php', 'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => $baseDir . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php', 'OCP\\Authentication\\Events\\LoginFailedEvent' => $baseDir . '/lib/public/Authentication/Events/LoginFailedEvent.php', 'OCP\\Authentication\\Events\\TokenInvalidatedEvent' => $baseDir . '/lib/public/Authentication/Events/TokenInvalidatedEvent.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index c07b6d127cc0b..0838740fb1514 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -11,32 +11,32 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 ); public static $prefixLengthsPsr4 = array ( - 'O' => + 'O' => array ( 'OC\\Core\\' => 8, 'OC\\' => 3, 'OCP\\' => 4, ), - 'N' => + 'N' => array ( 'NCU\\' => 4, ), ); public static $prefixDirsPsr4 = array ( - 'OC\\Core\\' => + 'OC\\Core\\' => array ( 0 => __DIR__ . '/../../..' . '/core', ), - 'OC\\' => + 'OC\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/private', ), - 'OCP\\' => + 'OCP\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/public', ), - 'NCU\\' => + 'NCU\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/unstable', ), @@ -200,6 +200,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\App\\Events\\AppUpdateEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppUpdateEvent.php', 'OCP\\App\\IAppManager' => __DIR__ . '/../../..' . '/lib/public/App/IAppManager.php', 'OCP\\App\\ManagerEvent' => __DIR__ . '/../../..' . '/lib/public/App/ManagerEvent.php', + 'OCP\\Authentication\\Events\\AfterAuthTokenCreatedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php', 'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php', 'OCP\\Authentication\\Events\\LoginFailedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/LoginFailedEvent.php', 'OCP\\Authentication\\Events\\TokenInvalidatedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/TokenInvalidatedEvent.php', diff --git a/lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php b/lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php new file mode 100644 index 0000000000000..51ab0c6659054 --- /dev/null +++ b/lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php @@ -0,0 +1,44 @@ +token; + } + + /** + * @since 32.0.0 + */ + public function setToken(string $token): void { + $this->token = $token; + } +} \ No newline at end of file From 56e6e770ad9f919284a10dd5e4f9678f6e704172 Mon Sep 17 00:00:00 2001 From: Charles Taborin Date: Fri, 6 Feb 2026 09:49:33 +0100 Subject: [PATCH 2/3] test(auth): restore test for token creation disabled by config --- .../Controller/AuthSettingsControllerTest.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/settings/tests/Controller/AuthSettingsControllerTest.php b/apps/settings/tests/Controller/AuthSettingsControllerTest.php index 8a34a40dbdfa7..f96f0b533442b 100644 --- a/apps/settings/tests/Controller/AuthSettingsControllerTest.php +++ b/apps/settings/tests/Controller/AuthSettingsControllerTest.php @@ -142,6 +142,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->eventDispatcher->expects($this->never()) + ->method('dispatchTyped'); + $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 testCreateTokenModifiedByEvent(): void { $name = 'Pixel 8'; $sessionToken = $this->createMock(IToken::class); From 82b2c04a5449414f42ac87d7bf9b98217f153d00 Mon Sep 17 00:00:00 2001 From: Charles Taborin Date: Fri, 6 Feb 2026 09:52:08 +0100 Subject: [PATCH 3/3] fix(auth): update copyright year and version annotations --- .../Events/AfterAuthTokenCreatedEvent.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php b/lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php index 51ab0c6659054..b549be6c3b95d 100644 --- a/lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php +++ b/lib/public/Authentication/Events/AfterAuthTokenCreatedEvent.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCP\Authentication\Events; @@ -15,12 +15,12 @@ * * Apps may override the token value to enforce custom policies (length, charset, format). * - * @since 32.0.0 + * @since 34.0.0 */ class AfterAuthTokenCreatedEvent extends Event { /** - * @since 32.0.0 + * @since 34.0.0 */ public function __construct( private string $token, @@ -29,14 +29,14 @@ public function __construct( } /** - * @since 32.0.0 + * @since 34.0.0 */ public function getToken(): string { return $this->token; } /** - * @since 32.0.0 + * @since 34.0.0 */ public function setToken(string $token): void { $this->token = $token;