Skip to content

Commit f98eecc

Browse files
authored
Merge pull request #59877 from nextcloud/backport/59767/stable31
2 parents e97fc70 + adb6910 commit f98eecc

3 files changed

Lines changed: 223 additions & 37 deletions

File tree

apps/oauth2/lib/Controller/OauthApiController.php

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use OCP\Authentication\Exceptions\ExpiredTokenException;
2525
use OCP\Authentication\Exceptions\InvalidTokenException;
2626
use OCP\DB\Exception;
27+
use OCP\IDBConnection;
2728
use OCP\IRequest;
2829
use OCP\Security\Bruteforce\IThrottler;
2930
use OCP\Security\ICrypto;
@@ -47,6 +48,7 @@ public function __construct(
4748
private LoggerInterface $logger,
4849
private IThrottler $throttler,
4950
private ITimeFactory $timeFactory,
51+
private IDBConnection $db,
5052
) {
5153
parent::__construct($appName, $request);
5254
}
@@ -177,27 +179,52 @@ public function getToken(
177179

178180
// Rotate the apptoken (so the old one becomes invalid basically)
179181
$newToken = $this->secureRandom->generate(72, ISecureRandom::CHAR_ALPHANUMERIC);
182+
$newCode = $this->secureRandom->generate(128, ISecureRandom::CHAR_ALPHANUMERIC);
183+
$newEncryptedToken = $this->crypto->encrypt($newToken, $newCode);
184+
$redeemedThrottleReason = $grant_type === 'authorization_code'
185+
? 'authorization_code_already_redeemed'
186+
: 'refresh_token_already_redeemed';
180187

181-
$appToken = $this->tokenProvider->rotate(
182-
$appToken,
183-
$decryptedToken,
184-
$newToken
185-
);
188+
$this->db->beginTransaction();
189+
try {
190+
$updatedRows = $this->accessTokenMapper->rotateToken(
191+
$accessToken->getId(),
192+
$code,
193+
$newCode,
194+
$newEncryptedToken,
195+
$grant_type === 'authorization_code',
196+
);
197+
198+
if ($updatedRows !== 1) {
199+
$this->db->rollBack();
200+
$response = new JSONResponse([
201+
'error' => 'invalid_request',
202+
], Http::STATUS_BAD_REQUEST);
203+
$response->throttle(['invalid_request' => $redeemedThrottleReason]);
204+
return $response;
205+
}
186206

187-
// Expiration is in 1 hour again
188-
$appToken->setExpires($this->time->getTime() + 3600);
189-
$this->tokenProvider->updateToken($appToken);
207+
$appToken = $this->tokenProvider->rotate(
208+
$appToken,
209+
$decryptedToken,
210+
$newToken
211+
);
190212

191-
// Generate a new refresh token and encrypt the new apptoken in the DB
192-
$newCode = $this->secureRandom->generate(128, ISecureRandom::CHAR_ALPHANUMERIC);
193-
$accessToken->setHashedCode(hash('sha512', $newCode));
194-
$accessToken->setEncryptedToken($this->crypto->encrypt($newToken, $newCode));
195-
// increase the number of delivered oauth token
196-
// this helps with cleaning up DB access token when authorization code has expired
197-
// and it never delivered any oauth token
198-
$tokenCount = $accessToken->getTokenCount();
199-
$accessToken->setTokenCount($tokenCount + 1);
200-
$this->accessTokenMapper->update($accessToken);
213+
// Expiration is in 1 hour again
214+
$appToken->setExpires($this->time->getTime() + 3600);
215+
$this->tokenProvider->updateToken($appToken);
216+
217+
$this->db->commit();
218+
} catch (\Throwable $e) {
219+
if ($this->db->inTransaction()) {
220+
$this->db->rollBack();
221+
}
222+
// rotate() and updateToken() write the auth token to the cache,
223+
// so if we are past rotate() we must invalidate the new token
224+
$this->tokenProvider->invalidateToken($newToken);
225+
226+
throw $e;
227+
}
201228

202229
$this->throttler->resetDelay($this->request->getRemoteAddress(), 'login', ['user' => $appToken->getUID()]);
203230

apps/oauth2/lib/Db/AccessTokenMapper.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,31 @@ public function cleanupExpiredAuthorizationCode(): void {
8282
->andWhere($qb->expr()->lt('code_created_at', $qb->createNamedParameter($maxTokenCreationTs, IQueryBuilder::PARAM_INT)));
8383
$qb->executeStatement();
8484
}
85+
86+
/**
87+
* Rotate an access token only if it still matches the caller's previously-read state.
88+
*
89+
* @param int $id
90+
* @param string $oldCode
91+
* @param string $newCode
92+
* @param string $encryptedToken
93+
* @param bool $expectAuthorizationCodeState Require the token to still be unused
94+
* @return int Number of updated rows
95+
*/
96+
public function rotateToken(int $id, string $oldCode, string $newCode, string $encryptedToken, bool $expectAuthorizationCodeState): int {
97+
$qb = $this->db->getQueryBuilder();
98+
$qb
99+
->update($this->tableName)
100+
->set('hashed_code', $qb->createNamedParameter(hash('sha512', $newCode)))
101+
->set('encrypted_token', $qb->createNamedParameter($encryptedToken))
102+
->set('token_count', $qb->createFunction('token_count + 1'))
103+
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
104+
->andWhere($qb->expr()->eq('hashed_code', $qb->createNamedParameter(hash('sha512', $oldCode))));
105+
106+
if ($expectAuthorizationCodeState) {
107+
$qb->andWhere($qb->expr()->eq('token_count', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
108+
}
109+
110+
return $qb->executeStatement();
111+
}
85112
}

apps/oauth2/tests/Controller/OauthApiControllerTest.php

Lines changed: 151 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use OCP\AppFramework\Http;
2020
use OCP\AppFramework\Http\JSONResponse;
2121
use OCP\AppFramework\Utility\ITimeFactory;
22+
use OCP\IDBConnection;
2223
use OCP\IRequest;
2324
use OCP\Security\Bruteforce\IThrottler;
2425
use OCP\Security\ICrypto;
@@ -52,6 +53,8 @@ class OauthApiControllerTest extends TestCase {
5253
private $logger;
5354
/** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */
5455
private $timeFactory;
56+
/** @var IDBConnection|\PHPUnit\Framework\MockObject\MockObject */
57+
private $db;
5558
/** @var OauthApiController */
5659
private $oauthApiController;
5760

@@ -68,6 +71,7 @@ protected function setUp(): void {
6871
$this->throttler = $this->createMock(IThrottler::class);
6972
$this->logger = $this->createMock(LoggerInterface::class);
7073
$this->timeFactory = $this->createMock(ITimeFactory::class);
74+
$this->db = $this->createMock(IDBConnection::class);
7175

7276
$this->oauthApiController = new OauthApiController(
7377
'oauth2',
@@ -80,7 +84,8 @@ protected function setUp(): void {
8084
$this->time,
8185
$this->logger,
8286
$this->throttler,
83-
$this->timeFactory
87+
$this->timeFactory,
88+
$this->db,
8489
);
8590
}
8691

@@ -315,6 +320,7 @@ public function testRefreshTokenInvalidAppToken(): void {
315320

316321
public function testRefreshTokenValidAppToken(): void {
317322
$accessToken = new AccessToken();
323+
$accessToken->setId(21);
318324
$accessToken->setClientId(42);
319325
$accessToken->setTokenId(1337);
320326
$accessToken->setEncryptedToken('encryptedToken');
@@ -366,6 +372,18 @@ public function testRefreshTokenValidAppToken(): void {
366372
$this->time->method('getTime')
367373
->willReturn(1000);
368374

375+
$this->db->expects($this->once())
376+
->method('beginTransaction');
377+
378+
$this->db->expects($this->once())
379+
->method('commit');
380+
381+
$this->db->expects($this->never())
382+
->method('rollBack');
383+
384+
$this->tokenProvider->expects($this->never())
385+
->method('invalidateToken');
386+
369387
$this->tokenProvider->expects($this->once())
370388
->method('updateToken')
371389
->with(
@@ -379,13 +397,14 @@ public function testRefreshTokenValidAppToken(): void {
379397
->willReturn('newEncryptedToken');
380398

381399
$this->accessTokenMapper->expects($this->once())
382-
->method('update')
400+
->method('rotateToken')
383401
->with(
384-
$this->callback(function (AccessToken $token) {
385-
return $token->getHashedCode() === hash('sha512', 'random128') &&
386-
$token->getEncryptedToken() === 'newEncryptedToken';
387-
})
388-
);
402+
21,
403+
'validrefresh',
404+
'random128',
405+
'newEncryptedToken',
406+
false,
407+
)->willReturn(1);
389408

390409
$expected = new JSONResponse([
391410
'access_token' => 'random72',
@@ -411,6 +430,7 @@ public function testRefreshTokenValidAppToken(): void {
411430

412431
public function testRefreshTokenValidAppTokenBasicAuth(): void {
413432
$accessToken = new AccessToken();
433+
$accessToken->setId(21);
414434
$accessToken->setClientId(42);
415435
$accessToken->setTokenId(1337);
416436
$accessToken->setEncryptedToken('encryptedToken');
@@ -462,6 +482,18 @@ public function testRefreshTokenValidAppTokenBasicAuth(): void {
462482
$this->time->method('getTime')
463483
->willReturn(1000);
464484

485+
$this->db->expects($this->once())
486+
->method('beginTransaction');
487+
488+
$this->db->expects($this->once())
489+
->method('commit');
490+
491+
$this->db->expects($this->never())
492+
->method('rollBack');
493+
494+
$this->tokenProvider->expects($this->never())
495+
->method('invalidateToken');
496+
465497
$this->tokenProvider->expects($this->once())
466498
->method('updateToken')
467499
->with(
@@ -475,13 +507,14 @@ public function testRefreshTokenValidAppTokenBasicAuth(): void {
475507
->willReturn('newEncryptedToken');
476508

477509
$this->accessTokenMapper->expects($this->once())
478-
->method('update')
510+
->method('rotateToken')
479511
->with(
480-
$this->callback(function (AccessToken $token) {
481-
return $token->getHashedCode() === hash('sha512', 'random128') &&
482-
$token->getEncryptedToken() === 'newEncryptedToken';
483-
})
484-
);
512+
21,
513+
'validrefresh',
514+
'random128',
515+
'newEncryptedToken',
516+
false,
517+
)->willReturn(1);
485518

486519
$expected = new JSONResponse([
487520
'access_token' => 'random72',
@@ -510,6 +543,7 @@ public function testRefreshTokenValidAppTokenBasicAuth(): void {
510543

511544
public function testRefreshTokenExpiredAppToken(): void {
512545
$accessToken = new AccessToken();
546+
$accessToken->setId(21);
513547
$accessToken->setClientId(42);
514548
$accessToken->setTokenId(1337);
515549
$accessToken->setEncryptedToken('encryptedToken');
@@ -561,6 +595,18 @@ public function testRefreshTokenExpiredAppToken(): void {
561595
$this->time->method('getTime')
562596
->willReturn(1000);
563597

598+
$this->db->expects($this->once())
599+
->method('beginTransaction');
600+
601+
$this->db->expects($this->once())
602+
->method('commit');
603+
604+
$this->db->expects($this->never())
605+
->method('rollBack');
606+
607+
$this->tokenProvider->expects($this->never())
608+
->method('invalidateToken');
609+
564610
$this->tokenProvider->expects($this->once())
565611
->method('updateToken')
566612
->with(
@@ -574,13 +620,14 @@ public function testRefreshTokenExpiredAppToken(): void {
574620
->willReturn('newEncryptedToken');
575621

576622
$this->accessTokenMapper->expects($this->once())
577-
->method('update')
623+
->method('rotateToken')
578624
->with(
579-
$this->callback(function (AccessToken $token) {
580-
return $token->getHashedCode() === hash('sha512', 'random128') &&
581-
$token->getEncryptedToken() === 'newEncryptedToken';
582-
})
583-
);
625+
21,
626+
'validrefresh',
627+
'random128',
628+
'newEncryptedToken',
629+
false,
630+
)->willReturn(1);
584631

585632
$expected = new JSONResponse([
586633
'access_token' => 'random72',
@@ -603,4 +650,89 @@ public function testRefreshTokenExpiredAppToken(): void {
603650

604651
$this->assertEquals($expected, $this->oauthApiController->getToken('refresh_token', null, 'validrefresh', 'clientId', 'clientSecret'));
605652
}
653+
654+
public function testRefreshTokenRedeemedConcurrently(): void {
655+
$expected = new JSONResponse([
656+
'error' => 'invalid_request',
657+
], Http::STATUS_BAD_REQUEST);
658+
$expected->throttle(['invalid_request' => 'refresh_token_already_redeemed']);
659+
660+
$accessToken = new AccessToken();
661+
$accessToken->setId(21);
662+
$accessToken->setClientId(42);
663+
$accessToken->setTokenId(1337);
664+
$accessToken->setEncryptedToken('encryptedToken');
665+
666+
$this->accessTokenMapper->method('getByCode')
667+
->with('validrefresh')
668+
->willReturn($accessToken);
669+
670+
$client = new Client();
671+
$client->setClientIdentifier('clientId');
672+
$client->setSecret(bin2hex('hashedClientSecret'));
673+
$this->clientMapper->method('getByUid')
674+
->with(42)
675+
->willReturn($client);
676+
677+
$this->crypto
678+
->method('decrypt')
679+
->with('encryptedToken')
680+
->willReturn('decryptedToken');
681+
682+
$this->crypto
683+
->method('calculateHMAC')
684+
->with('clientSecret')
685+
->willReturn('hashedClientSecret');
686+
687+
$appToken = new PublicKeyToken();
688+
$appToken->setUid('userId');
689+
$this->tokenProvider->method('getTokenById')
690+
->with(1337)
691+
->willReturn($appToken);
692+
693+
$this->secureRandom->method('generate')
694+
->willReturnCallback(function ($len) {
695+
return 'random' . $len;
696+
});
697+
698+
$this->tokenProvider->expects($this->never())
699+
->method('rotate');
700+
701+
$this->time->method('getTime')
702+
->willReturn(1000);
703+
704+
$this->tokenProvider->expects($this->never())
705+
->method('updateToken');
706+
707+
$this->crypto->method('encrypt')
708+
->with('random72', 'random128')
709+
->willReturn('newEncryptedToken');
710+
711+
$this->db->expects($this->once())
712+
->method('beginTransaction');
713+
714+
$this->db->expects($this->never())
715+
->method('commit');
716+
717+
$this->db->expects($this->once())
718+
->method('rollBack');
719+
720+
$this->tokenProvider->expects($this->never())
721+
->method('invalidateToken');
722+
723+
$this->accessTokenMapper->expects($this->once())
724+
->method('rotateToken')
725+
->with(
726+
21,
727+
'validrefresh',
728+
'random128',
729+
'newEncryptedToken',
730+
false,
731+
)->willReturn(0);
732+
733+
$this->throttler->expects($this->never())
734+
->method('resetDelay');
735+
736+
$this->assertEquals($expected, $this->oauthApiController->getToken('refresh_token', null, 'validrefresh', 'clientId', 'clientSecret'));
737+
}
606738
}

0 commit comments

Comments
 (0)