Skip to content

Commit 0f5923f

Browse files
committed
fix(oauth): wrap token rotation in a transaction, only rotate if the token hasn't been modified since we have read it
Signed-off-by: Julien Veyssier <julien-nc@posteo.net>
1 parent 8365464 commit 0f5923f

3 files changed

Lines changed: 237 additions & 37 deletions

File tree

apps/oauth2/lib/Controller/OauthApiController.php

Lines changed: 48 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,55 @@ 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+
$tokenRotated = false;
180185

181-
$appToken = $this->tokenProvider->rotate(
182-
$appToken,
183-
$decryptedToken,
184-
$newToken
185-
);
186+
$this->db->beginTransaction();
187+
try {
188+
$appToken = $this->tokenProvider->rotate(
189+
$appToken,
190+
$decryptedToken,
191+
$newToken
192+
);
193+
$tokenRotated = true;
194+
195+
// Expiration is in 1 hour again
196+
$appToken->setExpires($this->time->getTime() + 3600);
197+
$this->tokenProvider->updateToken($appToken);
198+
199+
$updatedRows = $this->accessTokenMapper->rotateToken(
200+
$accessToken->getId(),
201+
$code,
202+
$newCode,
203+
$newEncryptedToken,
204+
$grant_type === 'authorization_code',
205+
);
206+
207+
if ($updatedRows !== 1) {
208+
$this->db->rollBack();
209+
// tokenProvider->rotate() updates the auth token cache, so we have to clear the new token on rollback
210+
$this->tokenProvider->invalidateToken($newToken);
211+
$response = new JSONResponse([
212+
'error' => 'invalid_request',
213+
], Http::STATUS_BAD_REQUEST);
214+
$response->throttle(['invalid_request' => 'token already redeemed']);
215+
return $response;
216+
}
186217

187-
// Expiration is in 1 hour again
188-
$appToken->setExpires($this->time->getTime() + 3600);
189-
$this->tokenProvider->updateToken($appToken);
218+
$this->db->commit();
219+
} catch (\Throwable $e) {
220+
if ($this->db->inTransaction()) {
221+
$this->db->rollBack();
222+
}
190223

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);
224+
if ($tokenRotated) {
225+
// tokenProvider->rotate() updates the auth token cache, so we have to clear the new token on rollback
226+
$this->tokenProvider->invalidateToken($newToken);
227+
}
228+
229+
throw $e;
230+
}
201231

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

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
}

0 commit comments

Comments
 (0)