From 93841005391a09f7f7a9db8497e6d559b1add7fd Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Tue, 20 Oct 2026 19:05:59 -0400 Subject: [PATCH] feat(auth): make AppPasswordCreatedEvent public and dispatch it everywhere The event moves from OC\Authentication\Events to OCP\Authentication\Events so apps can react to every new app password, for example to remember which session and address created it. It is now also dispatched when an app password is created in the personal security settings and when a login flow v2 is completed. The settings app already publishes the "app password created" activity from its listener for this event, so its controller no longer publishes it directly. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- apps/settings/lib/AppInfo/Application.php | 2 +- .../lib/Controller/AuthSettingsController.php | 5 ++++- .../AppPasswordCreatedActivityListener.php | 2 +- .../Controller/AuthSettingsControllerTest.php | 9 ++++++++- core/Command/User/AuthTokens/Add.php | 2 +- core/Controller/AppPasswordController.php | 2 +- core/Controller/ClientFlowLoginController.php | 2 +- core/Service/LoginFlowV2Service.php | 2 ++ lib/composer/composer/autoload_classmap.php | 2 +- lib/composer/composer/autoload_static.php | 2 +- .../Events/AppPasswordCreatedEvent.php | 13 ++++++++++++- tests/Core/Service/LoginFlowV2ServiceUnitTest.php | 15 ++++++++++++--- 12 files changed, 45 insertions(+), 13 deletions(-) rename lib/{private => public}/Authentication/Events/AppPasswordCreatedEvent.php (68%) diff --git a/apps/settings/lib/AppInfo/Application.php b/apps/settings/lib/AppInfo/Application.php index 5faf7b5110b0c..74f09baadf820 100644 --- a/apps/settings/lib/AppInfo/Application.php +++ b/apps/settings/lib/AppInfo/Application.php @@ -10,7 +10,6 @@ namespace OCA\Settings\AppInfo; use OC\AppFramework\Utility\TimeFactory; -use OC\Authentication\Events\AppPasswordCreatedEvent; use OC\Authentication\Token\IProvider; use OC\Settings\Manager; use OCA\Settings\ConfigLexicon; @@ -89,6 +88,7 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Authentication\Events\AppPasswordCreatedEvent; use OCP\Defaults; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; diff --git a/apps/settings/lib/Controller/AuthSettingsController.php b/apps/settings/lib/Controller/AuthSettingsController.php index ae2f2e3e2056f..8e0deeaa10fdc 100644 --- a/apps/settings/lib/Controller/AuthSettingsController.php +++ b/apps/settings/lib/Controller/AuthSettingsController.php @@ -25,10 +25,12 @@ use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Services\IAppConfig; +use OCP\Authentication\Events\AppPasswordCreatedEvent; use OCP\Authentication\Exceptions\ExpiredTokenException; 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; @@ -54,6 +56,7 @@ public function __construct( private IConfig $serverConfig, private IL10N $l, private TokenScopes $tokenScopes, + private IEventDispatcher $eventDispatcher, ) { parent::__construct($appName, $request); } @@ -132,7 +135,7 @@ public function create(string $name = '', bool $qrcodeLogin = false): JSONRespon $tokenData['canDelete'] = true; $tokenData['canRename'] = true; - $this->publishActivity(Provider::APP_TOKEN_CREATED, $deviceToken->getId(), ['name' => $deviceToken->getName()]); + $this->eventDispatcher->dispatchTyped(new AppPasswordCreatedEvent($deviceToken)); return new JSONResponse([ 'token' => $token, diff --git a/apps/settings/lib/Listener/AppPasswordCreatedActivityListener.php b/apps/settings/lib/Listener/AppPasswordCreatedActivityListener.php index 72310feef170d..09928eb62c48c 100644 --- a/apps/settings/lib/Listener/AppPasswordCreatedActivityListener.php +++ b/apps/settings/lib/Listener/AppPasswordCreatedActivityListener.php @@ -10,9 +10,9 @@ namespace OCA\Settings\Listener; use BadMethodCallException; -use OC\Authentication\Events\AppPasswordCreatedEvent; use OCA\Settings\Activity\Provider; use OCP\Activity\IManager as IActivityManager; +use OCP\Authentication\Events\AppPasswordCreatedEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\IUserSession; diff --git a/apps/settings/tests/Controller/AuthSettingsControllerTest.php b/apps/settings/tests/Controller/AuthSettingsControllerTest.php index b4b2201e7a78e..ba1b023c96117 100644 --- a/apps/settings/tests/Controller/AuthSettingsControllerTest.php +++ b/apps/settings/tests/Controller/AuthSettingsControllerTest.php @@ -25,7 +25,9 @@ use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Services\IAppConfig; +use OCP\Authentication\Events\AppPasswordCreatedEvent; use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; @@ -48,6 +50,7 @@ class AuthSettingsControllerTest extends TestCase { private RemoteWipe&MockObject $remoteWipe; private IConfig&MockObject $serverConfig; private IL10N&MockObject $l; + private IEventDispatcher&MockObject $eventDispatcher; private string $uid = 'jane'; private AuthSettingsController $controller; @@ -66,6 +69,7 @@ protected function setUp(): void { /** @var LoggerInterface&MockObject $logger */ $logger = $this->createMock(LoggerInterface::class); $this->l = $this->createMock(IL10N::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->controller = new AuthSettingsController( 'core', @@ -82,6 +86,7 @@ protected function setUp(): void { $this->serverConfig, $this->l, new TokenScopes(), + $this->eventDispatcher, ); } @@ -124,7 +129,9 @@ public function testCreate(): void { ->method('jsonSerialize') ->willReturn(['dummy' => 'dummy', 'canDelete' => true]); - $this->mockActivityManager(); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(static fn (AppPasswordCreatedEvent $event): bool => $event->getToken() === $deviceToken)); $expected = [ 'token' => $newToken, diff --git a/core/Command/User/AuthTokens/Add.php b/core/Command/User/AuthTokens/Add.php index c491ced011bf6..19319c828cfb6 100644 --- a/core/Command/User/AuthTokens/Add.php +++ b/core/Command/User/AuthTokens/Add.php @@ -9,10 +9,10 @@ namespace OC\Core\Command\User\AuthTokens; -use OC\Authentication\Events\AppPasswordCreatedEvent; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\IToken; use OC\User\Manager as UserManager; +use OCP\Authentication\Events\AppPasswordCreatedEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\Security\ISecureRandom; use Symfony\Component\Console\Command\Command; diff --git a/core/Controller/AppPasswordController.php b/core/Controller/AppPasswordController.php index f2835b1cbe0eb..65771415bc0a8 100644 --- a/core/Controller/AppPasswordController.php +++ b/core/Controller/AppPasswordController.php @@ -9,7 +9,6 @@ namespace OC\Core\Controller; -use OC\Authentication\Events\AppPasswordCreatedEvent; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\IToken; use OC\User\Session; @@ -23,6 +22,7 @@ use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCSController; +use OCP\Authentication\Events\AppPasswordCreatedEvent; use OCP\Authentication\Exceptions\CredentialsUnavailableException; use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\Authentication\Exceptions\PasswordUnavailableException; diff --git a/core/Controller/ClientFlowLoginController.php b/core/Controller/ClientFlowLoginController.php index 06a6db515a123..eb5334b59ef81 100644 --- a/core/Controller/ClientFlowLoginController.php +++ b/core/Controller/ClientFlowLoginController.php @@ -7,7 +7,6 @@ namespace OC\Core\Controller; -use OC\Authentication\Events\AppPasswordCreatedEvent; use OC\Authentication\Exceptions\PasswordlessTokenException; use OC\Authentication\Token\IProvider; use OCA\OAuth2\Db\AccessToken; @@ -29,6 +28,7 @@ use OCP\AppFramework\Http\StandaloneTemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Events\AppPasswordCreatedEvent; use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\Authentication\Token\IToken; use OCP\Defaults; diff --git a/core/Service/LoginFlowV2Service.php b/core/Service/LoginFlowV2Service.php index 47707b909ecf6..54388b92745ca 100644 --- a/core/Service/LoginFlowV2Service.php +++ b/core/Service/LoginFlowV2Service.php @@ -20,6 +20,7 @@ use OC\Core\Exception\LoginFlowV2NotFoundException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Events\AppPasswordCreatedEvent; use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; @@ -179,6 +180,7 @@ public function flowDone(string $loginToken, string $sessionId, string $server, $data->setAppPassword($this->encryptPassword($appPassword, $data->getPublicKey())); $this->mapper->update($data); + $this->eventDispatcher->dispatchTyped(new AppPasswordCreatedEvent($generatedToken)); return true; } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 4467e4e6179c2..b09a39965e0ea 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -215,6 +215,7 @@ 'OCP\\App\\IAppManager' => $baseDir . '/lib/public/App/IAppManager.php', 'OCP\\App\\ManagerEvent' => $baseDir . '/lib/public/App/ManagerEvent.php', 'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => $baseDir . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php', + 'OCP\\Authentication\\Events\\AppPasswordCreatedEvent' => $baseDir . '/lib/public/Authentication/Events/AppPasswordCreatedEvent.php', 'OCP\\Authentication\\Events\\LoginFailedEvent' => $baseDir . '/lib/public/Authentication/Events/LoginFailedEvent.php', 'OCP\\Authentication\\Events\\TokenInvalidatedEvent' => $baseDir . '/lib/public/Authentication/Events/TokenInvalidatedEvent.php', 'OCP\\Authentication\\Exceptions\\CredentialsUnavailableException' => $baseDir . '/lib/public/Authentication/Exceptions/CredentialsUnavailableException.php', @@ -1300,7 +1301,6 @@ 'OC\\Archive\\TAR' => $baseDir . '/lib/private/Archive/TAR.php', 'OC\\Archive\\ZIP' => $baseDir . '/lib/private/Archive/ZIP.php', 'OC\\Authentication\\Events\\ARemoteWipeEvent' => $baseDir . '/lib/private/Authentication/Events/ARemoteWipeEvent.php', - 'OC\\Authentication\\Events\\AppPasswordCreatedEvent' => $baseDir . '/lib/private/Authentication/Events/AppPasswordCreatedEvent.php', 'OC\\Authentication\\Events\\LoginFailed' => $baseDir . '/lib/private/Authentication/Events/LoginFailed.php', 'OC\\Authentication\\Events\\RemoteWipeFinished' => $baseDir . '/lib/private/Authentication/Events/RemoteWipeFinished.php', 'OC\\Authentication\\Events\\RemoteWipeStarted' => $baseDir . '/lib/private/Authentication/Events/RemoteWipeStarted.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index dac59af04087a..08d4195c60d68 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -256,6 +256,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\App\\IAppManager' => __DIR__ . '/../../..' . '/lib/public/App/IAppManager.php', 'OCP\\App\\ManagerEvent' => __DIR__ . '/../../..' . '/lib/public/App/ManagerEvent.php', 'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php', + 'OCP\\Authentication\\Events\\AppPasswordCreatedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/AppPasswordCreatedEvent.php', 'OCP\\Authentication\\Events\\LoginFailedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/LoginFailedEvent.php', 'OCP\\Authentication\\Events\\TokenInvalidatedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/TokenInvalidatedEvent.php', 'OCP\\Authentication\\Exceptions\\CredentialsUnavailableException' => __DIR__ . '/../../..' . '/lib/public/Authentication/Exceptions/CredentialsUnavailableException.php', @@ -1341,7 +1342,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Archive\\TAR' => __DIR__ . '/../../..' . '/lib/private/Archive/TAR.php', 'OC\\Archive\\ZIP' => __DIR__ . '/../../..' . '/lib/private/Archive/ZIP.php', 'OC\\Authentication\\Events\\ARemoteWipeEvent' => __DIR__ . '/../../..' . '/lib/private/Authentication/Events/ARemoteWipeEvent.php', - 'OC\\Authentication\\Events\\AppPasswordCreatedEvent' => __DIR__ . '/../../..' . '/lib/private/Authentication/Events/AppPasswordCreatedEvent.php', 'OC\\Authentication\\Events\\LoginFailed' => __DIR__ . '/../../..' . '/lib/private/Authentication/Events/LoginFailed.php', 'OC\\Authentication\\Events\\RemoteWipeFinished' => __DIR__ . '/../../..' . '/lib/private/Authentication/Events/RemoteWipeFinished.php', 'OC\\Authentication\\Events\\RemoteWipeStarted' => __DIR__ . '/../../..' . '/lib/private/Authentication/Events/RemoteWipeStarted.php', diff --git a/lib/private/Authentication/Events/AppPasswordCreatedEvent.php b/lib/public/Authentication/Events/AppPasswordCreatedEvent.php similarity index 68% rename from lib/private/Authentication/Events/AppPasswordCreatedEvent.php rename to lib/public/Authentication/Events/AppPasswordCreatedEvent.php index b6c6c887d8986..a800d4de211ee 100644 --- a/lib/private/Authentication/Events/AppPasswordCreatedEvent.php +++ b/lib/public/Authentication/Events/AppPasswordCreatedEvent.php @@ -7,18 +7,29 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OC\Authentication\Events; +namespace OCP\Authentication\Events; use OCP\Authentication\Token\IToken; use OCP\EventDispatcher\Event; +/** + * Dispatched after an app password was created, from any place that creates one + * + * @since 36.0.0 + */ class AppPasswordCreatedEvent extends Event { + /** + * @since 36.0.0 + */ public function __construct( private IToken $token, ) { parent::__construct(); } + /** + * @since 36.0.0 + */ public function getToken(): IToken { return $this->token; } diff --git a/tests/Core/Service/LoginFlowV2ServiceUnitTest.php b/tests/Core/Service/LoginFlowV2ServiceUnitTest.php index 300626b9bc5d4..e85960ece8d74 100644 --- a/tests/Core/Service/LoginFlowV2ServiceUnitTest.php +++ b/tests/Core/Service/LoginFlowV2ServiceUnitTest.php @@ -20,6 +20,7 @@ use OC\Core\Service\LoginFlowV2Service; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Events\AppPasswordCreatedEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\Log\Audit\CriticalActionPerformedEvent; @@ -392,10 +393,12 @@ public function testFlowDone(): void { ) ->willReturn($appToken); - $this->eventDispatcher->expects($this->once()) + $events = []; + $this->eventDispatcher->expects($this->exactly(2)) ->method('dispatchTyped') - ->with($this->callback(fn (CriticalActionPerformedEvent $event): bool => $event->getOperation() === 'auth.app_password.created' - && $event->getParameters() === ['tokenId' => 7, 'tokenName' => 'client_name'])); + ->willReturnCallback(static function (object $event) use (&$events): void { + $events[] = $event; + }); $result = $this->subjectUnderTest->flowDone( 'login_token', @@ -405,6 +408,12 @@ public function testFlowDone(): void { ); $this->assertTrue($result); + $this->assertInstanceOf(CriticalActionPerformedEvent::class, $events[0]); + $this->assertSame('auth.app_password.created', $events[0]->getOperation()); + $this->assertSame(['tokenId' => 7, 'tokenName' => 'client_name'], $events[0]->getParameters()); + $this->assertInstanceOf(AppPasswordCreatedEvent::class, $events[1]); + $this->assertSame($appToken, $events[1]->getToken()); + // app password is encrypted and must look like: // ZACZOOzxTpKz4+KXL5kZ/gCK0xvkaVi/8yzupAn6Ui6+5qCSKvfPKGgeDRKs0sivvSLzk/XSp811SZCZmH0Y3g== $this->assertMatchesRegularExpression('/[a-zA-Z\/0-9+=]+/', $loginFlowV2->getAppPassword());