diff --git a/apps/provisioning_api/appinfo/routes.php b/apps/provisioning_api/appinfo/routes.php index 78526ce640234..4f69415e1cb8f 100644 --- a/apps/provisioning_api/appinfo/routes.php +++ b/apps/provisioning_api/appinfo/routes.php @@ -38,6 +38,7 @@ ['root' => '/cloud', 'name' => 'Users#editUser', 'url' => '/users/{userId}', 'verb' => 'PUT'], ['root' => '/cloud', 'name' => 'Users#editUserMultiValue', 'url' => '/users/{userId}/{collectionName}', 'verb' => 'PUT', 'requirements' => ['collectionName' => '^(?!enable$|disable$)[a-zA-Z0-9_]*$']], ['root' => '/cloud', 'name' => 'Users#wipeUserDevices', 'url' => '/users/{userId}/wipe', 'verb' => 'POST'], + ['root' => '/cloud', 'name' => 'Users#invalidateUserTokens', 'url' => '/users/{userId}/invalidate', 'verb' => 'POST'], ['root' => '/cloud', 'name' => 'Users#deleteUser', 'url' => '/users/{userId}', 'verb' => 'DELETE'], ['root' => '/cloud', 'name' => 'Users#enableUser', 'url' => '/users/{userId}/enable', 'verb' => 'PUT'], ['root' => '/cloud', 'name' => 'Users#disableUser', 'url' => '/users/{userId}/disable', 'verb' => 'PUT'], diff --git a/apps/provisioning_api/lib/Controller/UsersController.php b/apps/provisioning_api/lib/Controller/UsersController.php index a8f29843d81bd..f40cb5aaf02cf 100644 --- a/apps/provisioning_api/lib/Controller/UsersController.php +++ b/apps/provisioning_api/lib/Controller/UsersController.php @@ -11,6 +11,7 @@ namespace OCA\Provisioning_API\Controller; use InvalidArgumentException; +use OC\Authentication\Token\Invalidator; use OC\Authentication\Token\RemoteWipe; use OC\KnownUser\KnownUserService; use OC\User\Backend; @@ -71,6 +72,7 @@ public function __construct( private NewUserMailHelper $newUserMailHelper, private ISecureRandom $secureRandom, private RemoteWipe $remoteWipe, + private Invalidator $invalidator, private KnownUserService $knownUserService, private IEventDispatcher $eventDispatcher, private IPhoneNumberUtil $phoneNumberUtil, @@ -1250,6 +1252,51 @@ public function wipeUserDevices(string $userId): DataResponse { return new DataResponse(); } + + /** + * Invalidate all tokens of a user + * + * @param string $userId ID of the user + * + * @return DataResponse, array{}> + * + * @throws OCSException + * + * 200: Invalidated all user tokens successfully + */ + #[NoAdminRequired] + public function invalidateUserTokens(string $userId): DataResponse { + /** @var IUser $currentLoggedInUser */ + $currentLoggedInUser = $this->userSession->getUser(); + + $targetUser = $this->userManager->get($userId); + + if ($targetUser === null) { + throw new OCSException('', OCSController::RESPOND_NOT_FOUND); + } + + if ($targetUser->getUID() === $currentLoggedInUser->getUID()) { + throw new OCSException('', 101); + } + + // If not permitted + $subAdminManager = $this->groupManager->getSubAdmin(); + $isAdmin = $this->groupManager->isAdmin($currentLoggedInUser->getUID()); + $isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID()); + if (!$isAdmin && !($isDelegatedAdmin && !$this->groupManager->isInGroup($targetUser->getUID(), 'admin')) && !$subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser)) { + throw new OCSException('', OCSController::RESPOND_NOT_FOUND); + } + + $this->logger->warning('Invalidating all tokens for user ' . $userId . ' by user ' . $currentLoggedInUser->getUID(), [ + 'app' => 'ocs_api', + 'adminUserId' => $currentLoggedInUser->getUID(), + 'accountId' => $userId, + ]); + + $this->invalidator->invalidateAllUserTokens($targetUser->getUID()); + + return new DataResponse(); + } /** * Delete a user * diff --git a/apps/provisioning_api/openapi-full.json b/apps/provisioning_api/openapi-full.json index 6577efebed90a..062d1f562794a 100644 --- a/apps/provisioning_api/openapi-full.json +++ b/apps/provisioning_api/openapi-full.json @@ -3214,6 +3214,74 @@ } } }, + "/ocs/v2.php/cloud/users/{userId}/invalidate": { + "post": { + "operationId": "users-invalidate-user-tokens", + "summary": "Invalidate all tokens of a user", + "tags": [ + "users" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Invalidated all user tokens successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/cloud/users/{userId}/enable": { "put": { "operationId": "users-enable-user", diff --git a/apps/provisioning_api/openapi.json b/apps/provisioning_api/openapi.json index ef07072460eed..0fb87464edb06 100644 --- a/apps/provisioning_api/openapi.json +++ b/apps/provisioning_api/openapi.json @@ -2151,6 +2151,74 @@ } } }, + "/ocs/v2.php/cloud/users/{userId}/invalidate": { + "post": { + "operationId": "users-invalidate-user-tokens", + "summary": "Invalidate all tokens of a user", + "tags": [ + "users" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Invalidated all user tokens successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/cloud/users/{userId}/enable": { "put": { "operationId": "users-enable-user", diff --git a/apps/provisioning_api/tests/Controller/UsersControllerTest.php b/apps/provisioning_api/tests/Controller/UsersControllerTest.php index 2cb39141d744a..3e4f329a86e39 100644 --- a/apps/provisioning_api/tests/Controller/UsersControllerTest.php +++ b/apps/provisioning_api/tests/Controller/UsersControllerTest.php @@ -9,6 +9,7 @@ namespace OCA\Provisioning_API\Tests\Controller; use Exception; +use OC\Authentication\Token\Invalidator; use OC\Authentication\Token\RemoteWipe; use OC\Group\Manager; use OC\KnownUser\KnownUserService; @@ -73,6 +74,8 @@ class UsersControllerTest extends TestCase { /** @var RemoteWipe|MockObject */ private $remoteWipe; /** @var KnownUserService|MockObject */ + private $invalidator; + /** @var KnownUserService|MockObject */ private $knownUserService; /** @var IEventDispatcher|MockObject */ private $eventDispatcher; @@ -95,6 +98,7 @@ protected function setUp(): void { $this->newUserMailHelper = $this->createMock(NewUserMailHelper::class); $this->secureRandom = $this->createMock(ISecureRandom::class); $this->remoteWipe = $this->createMock(RemoteWipe::class); + $this->invalidator = $this->createMock(Invalidator::class); $this->knownUserService = $this->createMock(KnownUserService::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->phoneNumberUtil = new PhoneNumberUtil(); @@ -119,6 +123,7 @@ protected function setUp(): void { $this->newUserMailHelper, $this->secureRandom, $this->remoteWipe, + $this->invalidator, $this->knownUserService, $this->eventDispatcher, $this->phoneNumberUtil, @@ -513,6 +518,7 @@ public function testAddUserSuccessfulWithDisplayName() { $this->newUserMailHelper, $this->secureRandom, $this->remoteWipe, + $this->invalidator, $this->knownUserService, $this->eventDispatcher, $this->phoneNumberUtil, @@ -3133,6 +3139,34 @@ public function testRemoveFromGroupWithNoTargetGroup() { $this->api->removeFromGroup('TargetUser', ''); } + public function testInvalidateUserTokensSuccess(): void { + $currentUser = $this->createMock(IUser::class); + $targetUser = $this->createMock(IUser::class); + + $this->userSession->method('getUser')->willReturn($currentUser); + $currentUser->method('getUID')->willReturn('currentUserId'); + $targetUser->method('getUID')->willReturn('targetUserId'); + + $this->userManager->method('get')->with('targetUserId')->willReturn($targetUser); + + $this->groupManager->method('isAdmin')->with('currentUserId')->willReturn(true); + + $this->invalidator->expects($this->once())->method('invalidateAllUserTokens')->with('targetUserId'); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Invalidating all tokens for user targetUserId by user currentUserId', [ + 'app' => 'ocs_api', + 'adminUserId' => 'currentUserId', + 'accountId' => 'targetUserId', + ]); + + $response = $this->api->invalidateUserTokens('targetUserId'); + + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertEquals([], $response->getData()); + } public function testRemoveFromGroupWithEmptyTargetGroup() { $this->expectException(\OCP\AppFramework\OCS\OCSException::class); @@ -3785,6 +3819,7 @@ public function testGetCurrentUserLoggedIn() { $this->newUserMailHelper, $this->secureRandom, $this->remoteWipe, + $this->invalidator, $this->knownUserService, $this->eventDispatcher, $this->phoneNumberUtil, @@ -3873,6 +3908,7 @@ public function testGetUser() { $this->newUserMailHelper, $this->secureRandom, $this->remoteWipe, + $this->invalidator, $this->knownUserService, $this->eventDispatcher, $this->phoneNumberUtil, diff --git a/lib/private/Authentication/Events/AUserTokensInvalidationEvent.php b/lib/private/Authentication/Events/AUserTokensInvalidationEvent.php new file mode 100644 index 0000000000000..bbbaad0fbd0a3 --- /dev/null +++ b/lib/private/Authentication/Events/AUserTokensInvalidationEvent.php @@ -0,0 +1,24 @@ +uid; + } +} diff --git a/lib/private/Authentication/Events/TokensInvalidationFinished.php b/lib/private/Authentication/Events/TokensInvalidationFinished.php new file mode 100644 index 0000000000000..33b3c4bffc092 --- /dev/null +++ b/lib/private/Authentication/Events/TokensInvalidationFinished.php @@ -0,0 +1,12 @@ +logger->info("Invalidating all tokens for user: $uid"); + $this->eventDispatcher->dispatch(TokensInvalidationStarted::class, new TokensInvalidationStarted($uid)); + + $tokens = $this->tokenProvider->getTokenByUser($uid); + foreach ($tokens as $token) { + $this->tokenProvider->invalidateTokenById($uid, $token->getId()); + } + + $this->eventDispatcher->dispatch(TokensInvalidationFinished::class, new TokensInvalidationFinished($uid)); + return true; + } +} diff --git a/tests/lib/Authentication/Token/InvalidatorTest.php b/tests/lib/Authentication/Token/InvalidatorTest.php new file mode 100644 index 0000000000000..305d154166095 --- /dev/null +++ b/tests/lib/Authentication/Token/InvalidatorTest.php @@ -0,0 +1,84 @@ +tokenProvider = $this->createMock(ITokenProvider::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->invalidator = new Invalidator( + $this->tokenProvider, + $this->eventDispatcher, + $this->logger + ); + } + + public function testInvalidateAllUserTokens(): void { + $uid = 'user123'; + $tokens = [ + $this->createMock(IToken::class), + $this->createMock(IToken::class), + ]; + + $tokens[0]->method('getId')->willReturn(1111); + $tokens[1]->method('getId')->willReturn(2222); + + $this->tokenProvider + ->expects($this->once()) + ->method('getTokenByUser') + ->with($uid) + ->willReturn($tokens); + + $invokedCount = $this->exactly(2); + $this->tokenProvider + ->expects($invokedCount) + ->method('invalidateTokenById') + ->willReturnCallback(function ($uid, $tokenId) use ($invokedCount) { + if ($invokedCount->getInvocationCount() === 1) { + $this->assertSame(['user123', 1111], [$uid, $tokenId]); + } elseif ($invokedCount->getInvocationCount() === 2) { + $this->assertSame(['user123', 2222], [$uid, $tokenId]); + } + }); + + $this->logger + ->expects($this->once()) + ->method('info') + ->with("Invalidating all tokens for user: $uid"); + + $result = $this->invalidator->invalidateAllUserTokens($uid); + + $this->assertTrue($result); + } +}