Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d414f00
docs(config.sample.php): token_auth_enforced does not revoke existing…
joshtrichards Apr 10, 2026
846cfba
feat(PublicKeyTokenMapper): bulk token invalidation methods
joshtrichards Apr 10, 2026
c4091e2
feat(AuthTokens): New occ command to revoke tokens by class/type
joshtrichards Apr 10, 2026
b2777f2
chore(config.sample.php): mention new `occ user:auth-tokens:revoke` i…
joshtrichards Apr 10, 2026
9871a08
chore(AuthTokens): register new revoke command
joshtrichards Apr 10, 2026
a5da44d
chore(AuthTokens): tighten up token_auth_enforced doc wording
joshtrichards Apr 10, 2026
3dd619c
chore(AuthTokens): streamline new revoke command
joshtrichards Apr 10, 2026
10906c4
feat(PublicKeyTokenMapper): add invalidateByType helper
joshtrichards Apr 10, 2026
cd5122a
docs(config.sample.php): make occ command in token_auth_enforced spec…
joshtrichards Apr 10, 2026
b448ca8
test(AuthTokens): add basic tests for new Revoke command
joshtrichards Apr 10, 2026
145816f
test(AuthTokens): add bulk / all-users path tests to for revoke cmd
joshtrichards Apr 10, 2026
2a563fe
docs(AuthTokens): add comment on bulkRevoke about skipped events
joshtrichards Apr 10, 2026
2718476
chore: lint Revoke
joshtrichards Apr 10, 2026
db2e825
chore: tidy bulkRevoke comment
joshtrichards Apr 10, 2026
daa4350
chore(AuthTokens): add revoke cmd to autoload_classmap.php
joshtrichards Apr 10, 2026
37fd1e6
chore(AuthTokens): add revoke cmd to autoload_static.php
joshtrichards Apr 10, 2026
f71b67a
chore: fixup autoload_classmap.php
joshtrichards Apr 10, 2026
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
14 changes: 11 additions & 3 deletions config/config.sample.php
Original file line number Diff line number Diff line change
Expand Up @@ -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``
*/
Expand Down
275 changes: 275 additions & 0 deletions core/Command/User/AuthTokens/Revoke.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OC\Core\Command\User\AuthTokens;

use OC\Authentication\Token\IProvider;
use OC\Authentication\Token\PublicKeyTokenMapper;
use OC\Core\Command\Base;
use OCP\Authentication\Token\IToken;
use OCP\IUser;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;

class Revoke extends Base {
public function __construct(
protected IUserManager $userManager,
protected IProvider $tokenProvider,
protected PublicKeyTokenMapper $mapper,
) {
parent::__construct();
}

protected function configure(): void {
parent::configure();

$this
->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 <uid> together with --all-users.');
}

if (!$allUsers && (!is_string($uid) || $uid === '')) {
throw new RuntimeException('Specify <uid> 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 = "<question>This will revoke all {$modeName} tokens for {$scope}. Continue? [y/N]</question> ";

/** @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("<info>Revoked {$bulkCount} token(s).</info>");
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('<error>user not found</error>');
return Command::FAILURE;
}
$revoked = $this->revokeForUser($user->getUID(), $modes, $output, $dryRun);
}

if ($dryRun) {
$output->writeln("<info>Dry run complete. {$revoked} token(s) would be revoked.</info>");
} else {
$output->writeln("<info>Revoked {$revoked} token(s).</info>");
}

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()),

Check failure on line 227 in core/Command/User/AuthTokens/Revoke.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedInterfaceMethod

core/Command/User/AuthTokens/Revoke.php:227:36: UndefinedInterfaceMethod: Method OCP\Authentication\Token\IToken::getType does not exist (see https://psalm.dev/181)
(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();

Check failure on line 246 in core/Command/User/AuthTokens/Revoke.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedInterfaceMethod

core/Command/User/AuthTokens/Revoke.php:246:19: UndefinedInterfaceMethod: Method OCP\Authentication\Token\IToken::getType does not exist (see https://psalm.dev/181)

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,
};
}
}
1 change: 1 addition & 0 deletions core/register_command.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
52 changes: 52 additions & 0 deletions lib/private/Authentication/Token/PublicKeyTokenMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Loading
Loading