diff --git a/config/config.sample.php b/config/config.sample.php index b51558ea5cc37..8fc2ed8dfd276 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -400,9 +400,17 @@ 'auto_logout' => false, /** - * Enforce token authentication for clients, which blocks requests using the user - * password for enhanced security. Users need to generate tokens in personal settings - * which can be used as passwords on their clients. + * Enforce token authentication for client logins. + * + * When enabled, new client authentication attempts using the user's account + * password are rejected and clients must use an app password or another valid + * token instead. + * + * This setting does not automatically revoke existing browser sessions, + * remember-me logins, or previously issued tokens. To fully enforce this + * policy for already authenticated sessions, invalidate those sessions or + * tokens separately, for example with + * ``occ user:auth-tokens:revoke --sessions``. * * Defaults to ``false`` */ diff --git a/core/Command/User/AuthTokens/Revoke.php b/core/Command/User/AuthTokens/Revoke.php new file mode 100644 index 0000000000000..a1f82ba183ee2 --- /dev/null +++ b/core/Command/User/AuthTokens/Revoke.php @@ -0,0 +1,275 @@ +setName('user:auth-tokens:revoke') + ->setDescription('Revoke authentication tokens by class/type') + ->addArgument( + 'uid', + InputArgument::OPTIONAL, + 'ID of the user to revoke tokens for' + ) + ->addOption( + 'all-users', + null, + InputOption::VALUE_NONE, + 'Revoke tokens for all users' + ) + ->addOption( + 'sessions', + null, + InputOption::VALUE_NONE, + 'Revoke all session tokens, including remembered sessions' + ) + ->addOption( + 'remembered-sessions', + null, + InputOption::VALUE_NONE, + 'Revoke remembered session tokens only' + ) + ->addOption( + 'all-except-app-passwords', + null, + InputOption::VALUE_NONE, + 'Revoke all tokens except permanent app passwords' + ) + ->addOption( + 'all', + null, + InputOption::VALUE_NONE, + 'Revoke all tokens including app passwords' + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Show which tokens would be revoked without deleting them' + ) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Skip confirmation prompt' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $uid = $input->getArgument('uid'); + $allUsers = (bool)$input->getOption('all-users'); + + $modes = [ + 'sessions' => (bool)$input->getOption('sessions'), + 'remembered-sessions' => (bool)$input->getOption('remembered-sessions'), + 'all-except-app-passwords' => (bool)$input->getOption('all-except-app-passwords'), + 'all' => (bool)$input->getOption('all'), + ]; + + $selectedModes = array_filter($modes); + if (count($selectedModes) !== 1) { + throw new RuntimeException('Specify exactly one of --sessions, --remembered-sessions, --all-except-app-passwords, or --all.'); + } + + if ($allUsers && $uid !== null) { + throw new RuntimeException('Do not provide together with --all-users.'); + } + + if (!$allUsers && (!is_string($uid) || $uid === '')) { + throw new RuntimeException('Specify or use --all-users.'); + } + + $dryRun = (bool)$input->getOption('dry-run'); + $force = (bool)$input->getOption('force'); + + // For bulk destructive operations, ask for confirmation unless this is + // a dry-run or the caller explicitly requested non-interactive behavior. + if (!$dryRun && !$force && $input->isInteractive()) { + $modeName = array_key_first($selectedModes); + $scope = $allUsers ? 'ALL users' : "user '$uid'"; + $message = "This will revoke all {$modeName} tokens for {$scope}. Continue? [y/N] "; + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion($message, false); + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Aborted.'); + return Command::SUCCESS; + } + } + + $revoked = 0; + + if ($allUsers) { + if (!$dryRun) { + // Prefer a single bulk DELETE for all-users operations where the + // selected revoke mode maps cleanly to SQL predicates. + $bulkCount = $this->bulkRevoke($modes); + if ($bulkCount !== null) { + $output->writeln("Revoked {$bulkCount} token(s)."); + return Command::SUCCESS; + } + } + + // Dry-run needs to enumerate tokens to report matches, and any mode + // not handled by bulkRevoke() falls back to per-user evaluation. + $this->userManager->callForAllUsers(function (IUser $user) use ($output, $dryRun, $modes, &$revoked): void { + $revoked += $this->revokeForUser($user->getUID(), $modes, $output, $dryRun); + }); + } else { + $user = $this->userManager->get($uid); + if ($user === null) { + $output->writeln('user not found'); + return Command::FAILURE; + } + $revoked = $this->revokeForUser($user->getUID(), $modes, $output, $dryRun); + } + + if ($dryRun) { + $output->writeln("Dry run complete. {$revoked} token(s) would be revoked."); + } else { + $output->writeln("Revoked {$revoked} token(s)."); + } + + return Command::SUCCESS; + } + + /** + * Attempt a bulk DELETE for --all-users instead of per-user iteration. + * + * This operates directly on the mapper for performance (single SQL DELETE + * per mode). The trade-off is that TokenInvalidatedEvent is not dispatched + * for individual tokens. This is acceptable because: + * + * - The event is primarily consumed by the token cache layer, which uses + * a short TTL (TOKEN_CACHE_TTL = 10s) and will self-heal quickly. + * - Dispatching events per-token would require loading every row first, + * negating the performance benefit of the bulk path. + * - Bulk token invalidation already follows this pattern elsewhere in + * the codebase. + * + * @return int|null Number of deleted rows, or null if the caller should + * fall back to per-user iteration. + */ + private function bulkRevoke(array $modes): ?int { + if ($modes['sessions']) { + return $this->mapper->invalidateByType(IToken::TEMPORARY_TOKEN); + } + + if ($modes['remembered-sessions']) { + return $this->mapper->invalidateByTypeAndRemember( + IToken::TEMPORARY_TOKEN, + IToken::REMEMBER + ); + } + + if ($modes['all-except-app-passwords']) { + return $this->mapper->invalidateAllExceptType(IToken::PERMANENT_TOKEN); + } + + if ($modes['all']) { + return $this->mapper->invalidateAllTokens(); + } + + return null; + } + + private function revokeForUser(string $uid, array $modes, OutputInterface $output, bool $dryRun): int { + $tokens = $this->tokenProvider->getTokenByUser($uid); + $count = 0; + + foreach ($tokens as $token) { + if (!$this->matchesSelection($token, $modes)) { + continue; + } + + $count++; + + if ($output->isVerbose()) { + $output->writeln(sprintf( + '%s token %d for user %s (type=%s remember=%s name=%s)', + $dryRun ? 'Would revoke' : 'Revoking', + $token->getId(), + $uid, + self::formatTokenType($token->getType()), + (string)$token->getRemember(), + $token->getName() + )); + } + + if (!$dryRun) { + $this->tokenProvider->invalidateTokenById($uid, $token->getId()); + } + } + + return $count; + } + + private function matchesSelection(IToken $token, array $modes): bool { + if ($modes['all']) { + return true; + } + + $type = $token->getType(); + + if ($modes['sessions']) { + // "sessions" means all temporary tokens, including remembered sessions. + return $type === IToken::TEMPORARY_TOKEN; + } + + if ($modes['remembered-sessions']) { + return $type === IToken::TEMPORARY_TOKEN + && $token->getRemember() === IToken::REMEMBER; + } + + if ($modes['all-except-app-passwords']) { + // Preserve permanent app passwords, revoke every other token type. + return $type !== IToken::PERMANENT_TOKEN; + } + + return false; + } + + private static function formatTokenType(int $type): string { + return match ($type) { + IToken::TEMPORARY_TOKEN => 'temporary', + IToken::PERMANENT_TOKEN => 'permanent', + IToken::WIPE_TOKEN => 'wipe', + IToken::ONETIME_TOKEN => 'onetime', + default => (string)$type, + }; + } +} diff --git a/core/register_command.php b/core/register_command.php index d28c1633c62bb..a7b8d41bb1ad6 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -226,6 +226,7 @@ $application->add(Server::get(Command\User\AuthTokens\Add::class)); $application->add(Server::get(Command\User\AuthTokens\ListCommand::class)); $application->add(Server::get(Command\User\AuthTokens\Delete::class)); + $application->add(Server::get(Command\User\AuthTokens\Revoke::class)); $application->add(Server::get(Verify::class)); $application->add(Server::get(Welcome::class)); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 303871feadc40..85af7174555fe 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1435,6 +1435,7 @@ 'OC\\Core\\Command\\User\\AuthTokens\\Add' => $baseDir . '/core/Command/User/AuthTokens/Add.php', 'OC\\Core\\Command\\User\\AuthTokens\\Delete' => $baseDir . '/core/Command/User/AuthTokens/Delete.php', 'OC\\Core\\Command\\User\\AuthTokens\\ListCommand' => $baseDir . '/core/Command/User/AuthTokens/ListCommand.php', + 'OC\\Core\\Command\\User\\AuthTokens\\Revoke' => $baseDir . '/core/Command/User/AuthTokens/Revoke.php', 'OC\\Core\\Command\\User\\ClearGeneratedAvatarCacheCommand' => $baseDir . '/core/Command/User/ClearGeneratedAvatarCacheCommand.php', 'OC\\Core\\Command\\User\\Delete' => $baseDir . '/core/Command/User/Delete.php', 'OC\\Core\\Command\\User\\Disable' => $baseDir . '/core/Command/User/Disable.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 8dad797c6e962..c7c4f5d75efb3 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1476,6 +1476,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Command\\User\\AuthTokens\\Add' => __DIR__ . '/../../..' . '/core/Command/User/AuthTokens/Add.php', 'OC\\Core\\Command\\User\\AuthTokens\\Delete' => __DIR__ . '/../../..' . '/core/Command/User/AuthTokens/Delete.php', 'OC\\Core\\Command\\User\\AuthTokens\\ListCommand' => __DIR__ . '/../../..' . '/core/Command/User/AuthTokens/ListCommand.php', + 'OC\\Core\\Command\\User\\AuthTokens\\Revoke' => __DIR__ . '/../../..' . '/core/Command/User/AuthTokens/Revoke.php', 'OC\\Core\\Command\\User\\ClearGeneratedAvatarCacheCommand' => __DIR__ . '/../../..' . '/core/Command/User/ClearGeneratedAvatarCacheCommand.php', 'OC\\Core\\Command\\User\\Delete' => __DIR__ . '/../../..' . '/core/Command/User/Delete.php', 'OC\\Core\\Command\\User\\Disable' => __DIR__ . '/../../..' . '/core/Command/User/Disable.php', diff --git a/lib/private/Authentication/Token/PublicKeyTokenMapper.php b/lib/private/Authentication/Token/PublicKeyTokenMapper.php index 9aabd69e57a12..cad2f79be9e23 100644 --- a/lib/private/Authentication/Token/PublicKeyTokenMapper.php +++ b/lib/private/Authentication/Token/PublicKeyTokenMapper.php @@ -61,6 +61,58 @@ public function invalidateLastUsedBefore(string $uid, int $before): int { return $qb->executeStatement(); } + /** + * Delete all tokens of a given type. + * + * @return int Number of deleted rows + */ + public function invalidateByType(int $type): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + + /** + * Delete all tokens of a given type and remember value. + * + * @return int Number of deleted rows + */ + public function invalidateByTypeAndRemember(int $type, int $remember): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('remember', $qb->createNamedParameter($remember, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + + /** + * Delete all tokens except those of a given type. + * + * @return int Number of deleted rows + */ + public function invalidateAllExceptType(int $exceptType): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->neq('type', $qb->createNamedParameter($exceptType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + + /** + * Delete all tokens. + * + * @return int Number of deleted rows + */ + public function invalidateAllTokens(): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + /** * Get the user UID for the given token * diff --git a/tests/Core/Command/User/AuthTokens/RevokeTest.php b/tests/Core/Command/User/AuthTokens/RevokeTest.php new file mode 100644 index 0000000000000..00a3aaef69db0 --- /dev/null +++ b/tests/Core/Command/User/AuthTokens/RevokeTest.php @@ -0,0 +1,307 @@ +userManager = $this->createMock(IUserManager::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->mapper = $this->createMock(PublicKeyTokenMapper::class); + $this->input = $this->createMock(InputInterface::class); + $this->output = $this->createMock(OutputInterface::class); + + $this->command = new Revoke( + $this->userManager, + $this->tokenProvider, + $this->mapper, + ); + } + + public function testExecuteFailsWithoutMode(): void { + $this->input->method('getArgument') + ->with('uid') + ->willReturn('alice'); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users', + 'sessions', + 'remembered-sessions', + 'all-except-app-passwords', + 'all', + 'dry-run', + 'force' => false, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Specify exactly one of --sessions, --remembered-sessions, --all-except-app-passwords, or --all.'); + + self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + } + + public function testSessionsRevokesOnlyTemporaryTokens(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $tempToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 101, + 'getType' => IToken::TEMPORARY_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Firefox', + ]); + + $rememberedTempToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 102, + 'getType' => IToken::TEMPORARY_TOKEN, + 'getRemember' => IToken::REMEMBER, + 'getName' => 'Remembered browser', + ]); + + $permanentToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 201, + 'getType' => IToken::PERMANENT_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Desktop client', + ]); + + $this->input->method('getArgument') + ->with('uid') + ->willReturn('alice'); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users' => false, + 'sessions' => true, + 'remembered-sessions' => false, + 'all-except-app-passwords' => false, + 'all' => false, + 'dry-run' => false, + 'force' => true, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('alice') + ->willReturn($user); + + $this->tokenProvider->expects($this->once()) + ->method('getTokenByUser') + ->with('alice') + ->willReturn([$tempToken, $rememberedTempToken, $permanentToken]); + + $this->tokenProvider->expects($this->exactly(2)) + ->method('invalidateTokenById') + ->willReturnCallback(function (string $uid, int $id): void { + self::assertSame('alice', $uid); + self::assertContains($id, [101, 102]); + }); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with('Revoked 2 token(s).'); + + $result = self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + + self::assertSame(0, $result); + } + + public function testAllExceptAppPasswordsDryRunDoesNotRevoke(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $tempToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 101, + 'getType' => IToken::TEMPORARY_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Firefox', + ]); + + $wipeToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 301, + 'getType' => IToken::WIPE_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Remote wipe', + ]); + + $permanentToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 201, + 'getType' => IToken::PERMANENT_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Desktop client', + ]); + + $this->input->method('getArgument') + ->with('uid') + ->willReturn('alice'); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users' => false, + 'sessions' => false, + 'remembered-sessions' => false, + 'all-except-app-passwords' => true, + 'all' => false, + 'dry-run' => true, + 'force' => true, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('alice') + ->willReturn($user); + + $this->tokenProvider->expects($this->once()) + ->method('getTokenByUser') + ->with('alice') + ->willReturn([$tempToken, $wipeToken, $permanentToken]); + + $this->tokenProvider->expects($this->never()) + ->method('invalidateTokenById'); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with('Dry run complete. 2 token(s) would be revoked.'); + + $result = self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + + self::assertSame(0, $result); + } + + public function testAllUsersBulkRevokeSessionsUsesBulkPath(): void { + $this->input->method('getArgument') + ->with('uid') + ->willReturn(null); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users' => true, + 'sessions' => true, + 'remembered-sessions' => false, + 'all-except-app-passwords' => false, + 'all' => false, + 'dry-run' => false, + 'force' => true, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + // Bulk path should call the mapper directly, not iterate users + $this->mapper->expects($this->once()) + ->method('invalidateByType') + ->with(IToken::TEMPORARY_TOKEN) + ->willReturn(5); + + $this->userManager->expects($this->never()) + ->method('callForAllUsers'); + + $this->tokenProvider->expects($this->never()) + ->method('getTokenByUser'); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with('Revoked 5 token(s).'); + + $result = self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + + self::assertSame(0, $result); + } + + public function testAllUsersDryRunFallsBackToPerUserIteration(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $tempToken = $this->createConfiguredMock(IToken::class, [ + 'getId' => 101, + 'getType' => IToken::TEMPORARY_TOKEN, + 'getRemember' => IToken::DO_NOT_REMEMBER, + 'getName' => 'Firefox', + ]); + + $this->input->method('getArgument') + ->with('uid') + ->willReturn(null); + + $this->input->method('getOption') + ->willReturnCallback(function (string $option) { + return match ($option) { + 'all-users' => true, + 'sessions' => true, + 'remembered-sessions' => false, + 'all-except-app-passwords' => false, + 'all' => false, + 'dry-run' => true, + 'force' => true, + default => throw new \InvalidArgumentException("Unexpected option $option"), + }; + }); + + // Bulk mapper methods should NOT be called in dry-run + $this->mapper->expects($this->never()) + ->method('invalidateByType'); + + // Should fall back to per-user iteration + $this->userManager->expects($this->once()) + ->method('callForAllUsers') + ->willReturnCallback(function (\Closure $callback) use ($user): void { + $callback($user); + }); + + $this->tokenProvider->expects($this->once()) + ->method('getTokenByUser') + ->with('alice') + ->willReturn([$tempToken]); + + $this->tokenProvider->expects($this->never()) + ->method('invalidateTokenById'); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with('Dry run complete. 1 token(s) would be revoked.'); + + $result = self::invokePrivate($this->command, 'execute', [$this->input, $this->output]); + + self::assertSame(0, $result); + } +}