Skip to content

Commit ac2a9d5

Browse files
authored
Merge pull request #60768 from nextcloud/backport/60644/stable33
[stable33] fix(settings): confirm app-token revoke and preserve wipe state
2 parents 1d22e4b + e7071d8 commit ac2a9d5

27 files changed

Lines changed: 640 additions & 81 deletions

apps/oauth2/lib/Controller/SettingsController.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,23 @@
88
*/
99
namespace OCA\OAuth2\Controller;
1010

11+
use OC\Authentication\Token\IProvider as IAuthTokenProvider;
1112
use OCA\OAuth2\Db\AccessTokenMapper;
1213
use OCA\OAuth2\Db\Client;
1314
use OCA\OAuth2\Db\ClientMapper;
1415
use OCP\AppFramework\Controller;
1516
use OCP\AppFramework\Http;
1617
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
1718
use OCP\AppFramework\Http\JSONResponse;
18-
use OCP\Authentication\Token\IProvider as IAuthTokenProvider;
19+
use OCP\Authentication\Exceptions\InvalidTokenException;
20+
use OCP\Authentication\Exceptions\WipeTokenException;
1921
use OCP\IL10N;
2022
use OCP\IRequest;
2123
use OCP\IUser;
2224
use OCP\IUserManager;
2325
use OCP\Security\ICrypto;
2426
use OCP\Security\ISecureRandom;
27+
use Psr\Log\LoggerInterface;
2528

2629
class SettingsController extends Controller {
2730

@@ -37,6 +40,7 @@ public function __construct(
3740
private IAuthTokenProvider $tokenProvider,
3841
private IUserManager $userManager,
3942
private ICrypto $crypto,
43+
private LoggerInterface $logger,
4044
) {
4145
parent::__construct($appName, $request);
4246
}
@@ -73,7 +77,26 @@ public function deleteClient(int $id): JSONResponse {
7377
$client = $this->clientMapper->getByUid($id);
7478

7579
$this->userManager->callForSeenUsers(function (IUser $user) use ($client): void {
76-
$this->tokenProvider->invalidateTokensOfUser($user->getUID(), $client->getName());
80+
// Skip tokens that are marked for remote wipe so revoking the
81+
// OAuth2 client does not silently cancel a pending wipe.
82+
$tokens = $this->tokenProvider->getTokenByUser($user->getUID());
83+
foreach ($tokens as $token) {
84+
if ($token->getName() !== $client->getName()) {
85+
continue;
86+
}
87+
try {
88+
$this->tokenProvider->getTokenById($token->getId());
89+
} catch (WipeTokenException $e) {
90+
$this->logger->info('Preserving token {tokenId} of user {uid}: marked for remote wipe, OAuth2 client revoke would cancel the wipe.', [
91+
'tokenId' => $token->getId(),
92+
'uid' => $user->getUID(),
93+
]);
94+
continue;
95+
} catch (InvalidTokenException $e) {
96+
// Token already invalid; let invalidateTokenById handle it.
97+
}
98+
$this->tokenProvider->invalidateTokenById($user->getUID(), $token->getId());
99+
}
77100
});
78101

79102
$this->accessTokenMapper->deleteByClientId($id);

apps/oauth2/tests/Controller/SettingsControllerTest.php

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,24 @@
66
*/
77
namespace OCA\OAuth2\Tests\Controller;
88

9+
use OC\Authentication\Token\IProvider as IAuthTokenProvider;
910
use OCA\OAuth2\Controller\SettingsController;
1011
use OCA\OAuth2\Db\AccessTokenMapper;
1112
use OCA\OAuth2\Db\Client;
1213
use OCA\OAuth2\Db\ClientMapper;
1314
use OCP\AppFramework\Http;
1415
use OCP\AppFramework\Http\JSONResponse;
15-
use OCP\Authentication\Token\IProvider as IAuthTokenProvider;
16+
use OCP\Authentication\Exceptions\WipeTokenException;
17+
use OCP\Authentication\Token\IToken;
1618
use OCP\IL10N;
1719
use OCP\IRequest;
1820
use OCP\IUser;
1921
use OCP\IUserManager;
2022
use OCP\Security\ICrypto;
2123
use OCP\Security\ISecureRandom;
2224
use OCP\Server;
25+
use PHPUnit\Framework\MockObject\MockObject;
26+
use Psr\Log\LoggerInterface;
2327
use Test\TestCase;
2428

2529
#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
@@ -42,6 +46,7 @@ class SettingsControllerTest extends TestCase {
4246
private $l;
4347
/** @var ICrypto|\PHPUnit\Framework\MockObject\MockObject */
4448
private $crypto;
49+
private LoggerInterface&MockObject $logger;
4550

4651
protected function setUp(): void {
4752
parent::setUp();
@@ -53,6 +58,7 @@ protected function setUp(): void {
5358
$this->authTokenProvider = $this->createMock(IAuthTokenProvider::class);
5459
$this->userManager = $this->createMock(IUserManager::class);
5560
$this->crypto = $this->createMock(ICrypto::class);
61+
$this->logger = $this->createMock(LoggerInterface::class);
5662
$this->l = $this->createMock(IL10N::class);
5763
$this->l->method('t')
5864
->willReturnArgument(0);
@@ -65,7 +71,8 @@ protected function setUp(): void {
6571
$this->l,
6672
$this->authTokenProvider,
6773
$this->userManager,
68-
$this->crypto
74+
$this->crypto,
75+
$this->logger,
6976
);
7077

7178
}
@@ -132,11 +139,15 @@ public function testDeleteClient(): void {
132139
$user1->updateLastLoginTimestamp();
133140
$tokenProviderMock = $this->getMockBuilder(IAuthTokenProvider::class)->getMock();
134141

135-
// expect one call per user and ensure the correct client name
142+
// One getTokenByUser call per user; we return no matching tokens here
143+
// so invalidateTokenById is never invoked.
136144
$tokenProviderMock
137145
->expects($this->exactly($count + 1))
138-
->method('invalidateTokensOfUser')
139-
->with($this->isType('string'), 'My Client Name');
146+
->method('getTokenByUser')
147+
->willReturn([]);
148+
$tokenProviderMock
149+
->expects($this->never())
150+
->method('invalidateTokenById');
140151

141152
$client = new Client();
142153
$client->setId(123);
@@ -167,7 +178,8 @@ public function testDeleteClient(): void {
167178
$this->l,
168179
$tokenProviderMock,
169180
$userManager,
170-
$this->crypto
181+
$this->crypto,
182+
$this->logger,
171183
);
172184

173185
$result = $settingsController->deleteClient(123);
@@ -177,6 +189,96 @@ public function testDeleteClient(): void {
177189
$user1->delete();
178190
}
179191

192+
public function testDeleteClientPreservesWipePendingToken(): void {
193+
$userManager = Server::get(IUserManager::class);
194+
$user = $userManager->createUser('test_wipe_preserve', 'test_wipe_preserve');
195+
$user->updateLastLoginTimestamp();
196+
197+
$client = new Client();
198+
$client->setId(456);
199+
$client->setName('My Client Name');
200+
$client->setRedirectUri('https://example.com/');
201+
$client->setSecret(bin2hex('MyHashedSecret'));
202+
$client->setClientIdentifier('MyClientIdentifier');
203+
204+
// Token marked for wipe with a matching client name: must NOT be invalidated.
205+
$wipeToken = $this->createMock(IToken::class);
206+
$wipeToken->method('getId')->willReturn(11);
207+
$wipeToken->method('getName')->willReturn('My Client Name');
208+
209+
// Regular token with matching name: must be invalidated.
210+
$regularToken = $this->createMock(IToken::class);
211+
$regularToken->method('getId')->willReturn(12);
212+
$regularToken->method('getName')->willReturn('My Client Name');
213+
214+
// Non-matching name: must be left alone.
215+
$otherToken = $this->createMock(IToken::class);
216+
$otherToken->method('getId')->willReturn(13);
217+
$otherToken->method('getName')->willReturn('Some Other Client');
218+
219+
$tokenProviderMock = $this->getMockBuilder(IAuthTokenProvider::class)->getMock();
220+
$tokenProviderMock
221+
->method('getTokenByUser')
222+
->willReturnCallback(function (string $uid) use ($wipeToken, $regularToken, $otherToken) {
223+
return $uid === 'test_wipe_preserve'
224+
? [$wipeToken, $regularToken, $otherToken]
225+
: [];
226+
});
227+
// Wipe state is signalled via WipeTokenException from getTokenById.
228+
$tokenProviderMock
229+
->method('getTokenById')
230+
->willReturnCallback(function (int $id) use ($wipeToken, $regularToken) {
231+
if ($id === 11) {
232+
throw new WipeTokenException($wipeToken);
233+
}
234+
return $regularToken;
235+
});
236+
$tokenProviderMock
237+
->expects($this->once())
238+
->method('invalidateTokenById')
239+
->with('test_wipe_preserve', 12);
240+
241+
$this->clientMapper
242+
->method('getByUid')
243+
->with(456)
244+
->willReturn($client);
245+
$this->accessTokenMapper
246+
->expects($this->once())
247+
->method('deleteByClientId')
248+
->with(456);
249+
$this->clientMapper
250+
->expects($this->once())
251+
->method('delete')
252+
->with($client);
253+
254+
$logger = $this->createMock(LoggerInterface::class);
255+
$logger->expects($this->atLeastOnce())
256+
->method('info')
257+
->with($this->stringContains('Preserving token'), $this->callback(function (array $context) {
258+
return ($context['tokenId'] ?? null) === 11
259+
&& ($context['uid'] ?? null) === 'test_wipe_preserve';
260+
}));
261+
262+
$settingsController = new SettingsController(
263+
'oauth2',
264+
$this->request,
265+
$this->clientMapper,
266+
$this->secureRandom,
267+
$this->accessTokenMapper,
268+
$this->l,
269+
$tokenProviderMock,
270+
$userManager,
271+
$this->crypto,
272+
$logger,
273+
);
274+
275+
$result = $settingsController->deleteClient(456);
276+
$this->assertInstanceOf(JSONResponse::class, $result);
277+
$this->assertEquals([], $result->getData());
278+
279+
$user->delete();
280+
}
281+
180282
public function testInvalidRedirectUri(): void {
181283
$result = $this->settingsController->addClient('test', 'invalidurl');
182284

apps/settings/lib/Activity/Provider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class Provider implements IProvider {
2727
public const EMAIL_CHANGED = 'email_changed';
2828
public const APP_TOKEN_CREATED = 'app_token_created';
2929
public const APP_TOKEN_DELETED = 'app_token_deleted';
30+
public const APP_TOKEN_DELETED_WIPE_CANCELLED = 'app_token_deleted_wipe_cancelled';
3031
public const APP_TOKEN_RENAMED = 'app_token_renamed';
3132
public const APP_TOKEN_FILESYSTEM_GRANTED = 'app_token_filesystem_granted';
3233
public const APP_TOKEN_FILESYSTEM_REVOKED = 'app_token_filesystem_revoked';
@@ -85,6 +86,8 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null):
8586
}
8687
} elseif ($event->getSubject() === self::APP_TOKEN_DELETED) {
8788
$subject = $this->l->t('You deleted app password "{token}"');
89+
} elseif ($event->getSubject() === self::APP_TOKEN_DELETED_WIPE_CANCELLED) {
90+
$subject = $this->l->t('You deleted app password "{token}" and cancelled its pending remote wipe');
8891
} elseif ($event->getSubject() === self::APP_TOKEN_RENAMED) {
8992
$subject = $this->l->t('You renamed app password "{token}" to "{newToken}"');
9093
} elseif ($event->getSubject() === self::APP_TOKEN_FILESYSTEM_GRANTED) {
@@ -124,6 +127,7 @@ protected function getParameters(IEvent $event): array {
124127
];
125128
case self::APP_TOKEN_CREATED:
126129
case self::APP_TOKEN_DELETED:
130+
case self::APP_TOKEN_DELETED_WIPE_CANCELLED:
127131
case self::APP_TOKEN_FILESYSTEM_GRANTED:
128132
case self::APP_TOKEN_FILESYSTEM_REVOKED:
129133
return [

apps/settings/lib/Controller/AuthSettingsController.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,17 +180,21 @@ public function destroy($id) {
180180
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
181181
}
182182

183+
$subject = Provider::APP_TOKEN_DELETED;
183184
try {
184185
$token = $this->findTokenByIdAndUser($id);
185186
} catch (WipeTokenException $e) {
186-
//continue as we can destroy tokens in wipe
187+
// Deleting a wipe-pending token cancels the pending wipe; the device
188+
// may already be uninstalled so we allow it, but record it under a
189+
// distinct subject so the audit trail captures the consequence.
187190
$token = $e->getToken();
191+
$subject = Provider::APP_TOKEN_DELETED_WIPE_CANCELLED;
188192
} catch (InvalidTokenException $e) {
189193
return new JSONResponse([], Http::STATUS_NOT_FOUND);
190194
}
191195

192196
$this->tokenProvider->invalidateTokenById($this->userId, $token->getId());
193-
$this->publishActivity(Provider::APP_TOKEN_DELETED, $token->getId(), ['name' => $token->getName()]);
197+
$this->publishActivity($subject, $token->getId(), ['name' => $token->getName()]);
194198
return [];
195199
}
196200

0 commit comments

Comments
 (0)