Skip to content

Commit 6ff023b

Browse files
authored
Merge pull request #43020 from nextcloud/backport/40766/stable27
[stable27] Make OAuth2 authorization code expire
2 parents 947514e + 8dada6a commit 6ff023b

File tree

12 files changed

+366
-43
lines changed

12 files changed

+366
-43
lines changed

apps/oauth2/appinfo/info.xml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<name>OAuth 2.0</name>
66
<summary>Allows OAuth2 compatible authentication from other web applications.</summary>
77
<description>The OAuth2 app allows administrators to configure the built-in authentication workflow to also allow OAuth2 compatible authentication from other web applications.</description>
8-
<version>1.15.1</version>
8+
<version>1.15.2</version>
99
<licence>agpl</licence>
1010
<author>Lukas Reschke</author>
1111
<namespace>OAuth2</namespace>
@@ -19,6 +19,10 @@
1919
<nextcloud min-version="27" max-version="27"/>
2020
</dependencies>
2121

22+
<background-jobs>
23+
<job>OCA\OAuth2\BackgroundJob\CleanupExpiredAuthorizationCode</job>
24+
</background-jobs>
25+
2226
<repair-steps>
2327
<post-migration>
2428
<step>OCA\OAuth2\Migration\SetTokenExpiration</step>

apps/oauth2/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
return array(
99
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
10+
'OCA\\OAuth2\\BackgroundJob\\CleanupExpiredAuthorizationCode' => $baseDir . '/../lib/BackgroundJob/CleanupExpiredAuthorizationCode.php',
1011
'OCA\\OAuth2\\Controller\\LoginRedirectorController' => $baseDir . '/../lib/Controller/LoginRedirectorController.php',
1112
'OCA\\OAuth2\\Controller\\OauthApiController' => $baseDir . '/../lib/Controller/OauthApiController.php',
1213
'OCA\\OAuth2\\Controller\\SettingsController' => $baseDir . '/../lib/Controller/SettingsController.php',
@@ -20,5 +21,6 @@
2021
'OCA\\OAuth2\\Migration\\Version010401Date20181207190718' => $baseDir . '/../lib/Migration/Version010401Date20181207190718.php',
2122
'OCA\\OAuth2\\Migration\\Version010402Date20190107124745' => $baseDir . '/../lib/Migration/Version010402Date20190107124745.php',
2223
'OCA\\OAuth2\\Migration\\Version011601Date20230522143227' => $baseDir . '/../lib/Migration/Version011601Date20230522143227.php',
24+
'OCA\\OAuth2\\Migration\\Version011603Date20230620111039' => $baseDir . '/../lib/Migration/Version011603Date20230620111039.php',
2325
'OCA\\OAuth2\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
2426
);

apps/oauth2/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class ComposerStaticInitOAuth2
2222

2323
public static $classMap = array (
2424
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
25+
'OCA\\OAuth2\\BackgroundJob\\CleanupExpiredAuthorizationCode' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupExpiredAuthorizationCode.php',
2526
'OCA\\OAuth2\\Controller\\LoginRedirectorController' => __DIR__ . '/..' . '/../lib/Controller/LoginRedirectorController.php',
2627
'OCA\\OAuth2\\Controller\\OauthApiController' => __DIR__ . '/..' . '/../lib/Controller/OauthApiController.php',
2728
'OCA\\OAuth2\\Controller\\SettingsController' => __DIR__ . '/..' . '/../lib/Controller/SettingsController.php',
@@ -35,6 +36,7 @@ class ComposerStaticInitOAuth2
3536
'OCA\\OAuth2\\Migration\\Version010401Date20181207190718' => __DIR__ . '/..' . '/../lib/Migration/Version010401Date20181207190718.php',
3637
'OCA\\OAuth2\\Migration\\Version010402Date20190107124745' => __DIR__ . '/..' . '/../lib/Migration/Version010402Date20190107124745.php',
3738
'OCA\\OAuth2\\Migration\\Version011601Date20230522143227' => __DIR__ . '/..' . '/../lib/Migration/Version011601Date20230522143227.php',
39+
'OCA\\OAuth2\\Migration\\Version011603Date20230620111039' => __DIR__ . '/..' . '/../lib/Migration/Version011603Date20230620111039.php',
3840
'OCA\\OAuth2\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
3941
);
4042

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* @copyright Copyright (c) 2024 Julien Veyssier <julien-nc@posteo.net>
7+
*
8+
* @author Julien Veyssier <julien-nc@posteo.net>
9+
*
10+
* @license GNU AGPL version 3 or any later version
11+
*
12+
* This program is free software: you can redistribute it and/or modify
13+
* it under the terms of the GNU Affero General Public License as
14+
* published by the Free Software Foundation, either version 3 of the
15+
* License, or (at your option) any later version.
16+
*
17+
* This program is distributed in the hope that it will be useful,
18+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
* GNU Affero General Public License for more details.
21+
*
22+
* You should have received a copy of the GNU Affero General Public License
23+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
24+
*/
25+
26+
27+
namespace OCA\OAuth2\BackgroundJob;
28+
29+
use OCA\OAuth2\Db\AccessTokenMapper;
30+
use OCP\AppFramework\Utility\ITimeFactory;
31+
use OCP\BackgroundJob\IJob;
32+
use OCP\BackgroundJob\TimedJob;
33+
use OCP\DB\Exception;
34+
use Psr\Log\LoggerInterface;
35+
36+
class CleanupExpiredAuthorizationCode extends TimedJob {
37+
38+
public function __construct(
39+
ITimeFactory $timeFactory,
40+
private AccessTokenMapper $accessTokenMapper,
41+
private LoggerInterface $logger,
42+
43+
) {
44+
parent::__construct($timeFactory);
45+
// 30 days
46+
$this->setInterval(60 * 60 * 24 * 30);
47+
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
48+
}
49+
50+
/**
51+
* @param mixed $argument
52+
* @inheritDoc
53+
*/
54+
protected function run($argument): void {
55+
try {
56+
$this->accessTokenMapper->cleanupExpiredAuthorizationCode();
57+
} catch (Exception $e) {
58+
$this->logger->warning('Failed to cleanup tokens with expired authorization code', ['exception' => $e]);
59+
}
60+
}
61+
}

apps/oauth2/lib/Controller/OauthApiController.php

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,15 @@
3939
use OCP\AppFramework\Http;
4040
use OCP\AppFramework\Http\JSONResponse;
4141
use OCP\AppFramework\Utility\ITimeFactory;
42+
use OCP\DB\Exception;
4243
use OCP\IRequest;
4344
use OCP\Security\ICrypto;
4445
use OCP\Security\ISecureRandom;
4546
use Psr\Log\LoggerInterface;
4647

4748
class OauthApiController extends Controller {
49+
// the authorization code expires after 10 minutes
50+
public const AUTHORIZATION_CODE_EXPIRES_AFTER = 10 * 60;
4851

4952
public function __construct(
5053
string $appName,
@@ -54,9 +57,9 @@ public function __construct(
5457
private ClientMapper $clientMapper,
5558
private TokenProvider $tokenProvider,
5659
private ISecureRandom $secureRandom,
57-
private ITimeFactory $time,
60+
private ITimeFactory $timeFactory,
5861
private LoggerInterface $logger,
59-
private Throttler $throttler
62+
private Throttler $throttler,
6063
) {
6164
parent::__construct($appName, $request);
6265
}
@@ -67,13 +70,17 @@ public function __construct(
6770
* @BruteForceProtection(action=oauth2GetToken)
6871
*
6972
* @param string $grant_type
70-
* @param string $code
71-
* @param string $refresh_token
72-
* @param string $client_id
73-
* @param string $client_secret
73+
* @param string|null $code
74+
* @param string|null $refresh_token
75+
* @param string|null $client_id
76+
* @param string|null $client_secret
7477
* @return JSONResponse
78+
* @throws Exception
7579
*/
76-
public function getToken($grant_type, $code, $refresh_token, $client_id, $client_secret): JSONResponse {
80+
public function getToken(
81+
string $grant_type, ?string $code, ?string $refresh_token,
82+
?string $client_id, ?string $client_secret
83+
): JSONResponse {
7784

7885
// We only handle two types
7986
if ($grant_type !== 'authorization_code' && $grant_type !== 'refresh_token') {
@@ -99,6 +106,33 @@ public function getToken($grant_type, $code, $refresh_token, $client_id, $client
99106
return $response;
100107
}
101108

109+
if ($grant_type === 'authorization_code') {
110+
// check this token is in authorization code state
111+
$deliveredTokenCount = $accessToken->getTokenCount();
112+
if ($deliveredTokenCount > 0) {
113+
$response = new JSONResponse([
114+
'error' => 'invalid_request',
115+
], Http::STATUS_BAD_REQUEST);
116+
$response->throttle(['invalid_request' => 'authorization_code_received_for_active_token']);
117+
return $response;
118+
}
119+
120+
// check authorization code expiration
121+
$now = $this->timeFactory->now()->getTimestamp();
122+
$codeCreatedAt = $accessToken->getCodeCreatedAt();
123+
if ($codeCreatedAt < $now - self::AUTHORIZATION_CODE_EXPIRES_AFTER) {
124+
// we know this token is not useful anymore
125+
$this->accessTokenMapper->delete($accessToken);
126+
127+
$response = new JSONResponse([
128+
'error' => 'invalid_request',
129+
], Http::STATUS_BAD_REQUEST);
130+
$expiredSince = $now - self::AUTHORIZATION_CODE_EXPIRES_AFTER - $codeCreatedAt;
131+
$response->throttle(['invalid_request' => 'authorization_code_expired', 'expired_since' => $expiredSince]);
132+
return $response;
133+
}
134+
}
135+
102136
try {
103137
$client = $this->clientMapper->getByUid($accessToken->getClientId());
104138
} catch (ClientNotFoundException $e) {
@@ -159,13 +193,18 @@ public function getToken($grant_type, $code, $refresh_token, $client_id, $client
159193
);
160194

161195
// Expiration is in 1 hour again
162-
$appToken->setExpires($this->time->getTime() + 3600);
196+
$appToken->setExpires($this->timeFactory->getTime() + 3600);
163197
$this->tokenProvider->updateToken($appToken);
164198

165199
// Generate a new refresh token and encrypt the new apptoken in the DB
166200
$newCode = $this->secureRandom->generate(128, ISecureRandom::CHAR_ALPHANUMERIC);
167201
$accessToken->setHashedCode(hash('sha512', $newCode));
168202
$accessToken->setEncryptedToken($this->crypto->encrypt($newToken, $newCode));
203+
// increase the number of delivered oauth token
204+
// this helps with cleaning up DB access token when authorization code has expired
205+
// and it never delivered any oauth token
206+
$tokenCount = $accessToken->getTokenCount();
207+
$accessToken->setTokenCount($tokenCount + 1);
169208
$this->accessTokenMapper->update($accessToken);
170209

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

apps/oauth2/lib/Db/AccessToken.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
* @method void setEncryptedToken(string $token)
3535
* @method string getHashedCode()
3636
* @method void setHashedCode(string $token)
37+
* @method int getCodeCreatedAt()
38+
* @method void setCodeCreatedAt(int $createdAt)
39+
* @method int getTokenCount()
40+
* @method void setTokenCount(int $tokenCount)
3741
*/
3842
class AccessToken extends Entity {
3943
/** @var int */
@@ -44,12 +48,18 @@ class AccessToken extends Entity {
4448
protected $hashedCode;
4549
/** @var string */
4650
protected $encryptedToken;
51+
/** @var int */
52+
protected $codeCreatedAt;
53+
/** @var int */
54+
protected $tokenCount;
4755

4856
public function __construct() {
4957
$this->addType('id', 'int');
5058
$this->addType('tokenId', 'int');
5159
$this->addType('clientId', 'int');
5260
$this->addType('hashedCode', 'string');
5361
$this->addType('encryptedToken', 'string');
62+
$this->addType('codeCreatedAt', 'int');
63+
$this->addType('tokenCount', 'int');
5464
}
5565
}

apps/oauth2/lib/Db/AccessTokenMapper.php

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@
2828
*/
2929
namespace OCA\OAuth2\Db;
3030

31+
use OCA\OAuth2\Controller\OauthApiController;
3132
use OCA\OAuth2\Exceptions\AccessTokenNotFoundException;
3233
use OCP\AppFramework\Db\IMapperException;
3334
use OCP\AppFramework\Db\QBMapper;
35+
use OCP\AppFramework\Utility\ITimeFactory;
36+
use OCP\DB\Exception;
3437
use OCP\DB\QueryBuilder\IQueryBuilder;
3538
use OCP\IDBConnection;
3639

@@ -39,10 +42,10 @@
3942
*/
4043
class AccessTokenMapper extends QBMapper {
4144

42-
/**
43-
* @param IDBConnection $db
44-
*/
45-
public function __construct(IDBConnection $db) {
45+
public function __construct(
46+
IDBConnection $db,
47+
private ITimeFactory $timeFactory,
48+
) {
4649
parent::__construct($db, 'oauth2_access_tokens');
4750
}
4851

@@ -79,4 +82,24 @@ public function deleteByClientId(int $id) {
7982
->where($qb->expr()->eq('client_id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
8083
$qb->executeStatement();
8184
}
85+
86+
/**
87+
* Delete access tokens that have an expired authorization code
88+
* -> those that are old enough
89+
* and which never delivered any oauth token (still in authorization state)
90+
*
91+
* @return void
92+
* @throws Exception
93+
*/
94+
public function cleanupExpiredAuthorizationCode(): void {
95+
$now = $this->timeFactory->now()->getTimestamp();
96+
$maxTokenCreationTs = $now - OauthApiController::AUTHORIZATION_CODE_EXPIRES_AFTER;
97+
98+
$qb = $this->db->getQueryBuilder();
99+
$qb
100+
->delete($this->tableName)
101+
->where($qb->expr()->eq('token_count', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
102+
->andWhere($qb->expr()->lt('code_created_at', $qb->createNamedParameter($maxTokenCreationTs, IQueryBuilder::PARAM_INT)));
103+
$qb->executeStatement();
104+
}
82105
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* @copyright Copyright 2023, Julien Veyssier <julien-nc@posteo.net>
7+
*
8+
* @author Julien Veyssier <julien-nc@posteo.net>
9+
*
10+
* @license GNU AGPL version 3 or any later version
11+
*
12+
* This program is free software: you can redistribute it and/or modify
13+
* it under the terms of the GNU Affero General Public License as
14+
* published by the Free Software Foundation, either version 3 of the
15+
* License, or (at your option) any later version.
16+
*
17+
* This program is distributed in the hope that it will be useful,
18+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
* GNU Affero General Public License for more details.
21+
*
22+
* You should have received a copy of the GNU Affero General Public License
23+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
24+
*
25+
*/
26+
namespace OCA\OAuth2\Migration;
27+
28+
use Closure;
29+
use OCP\DB\ISchemaWrapper;
30+
use OCP\DB\QueryBuilder\IQueryBuilder;
31+
use OCP\DB\Types;
32+
use OCP\IDBConnection;
33+
use OCP\Migration\IOutput;
34+
use OCP\Migration\SimpleMigrationStep;
35+
36+
class Version011603Date20230620111039 extends SimpleMigrationStep {
37+
38+
public function __construct(
39+
private IDBConnection $connection,
40+
) {
41+
}
42+
43+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
44+
/** @var ISchemaWrapper $schema */
45+
$schema = $schemaClosure();
46+
47+
if ($schema->hasTable('oauth2_access_tokens')) {
48+
$table = $schema->getTable('oauth2_access_tokens');
49+
$dbChanged = false;
50+
if (!$table->hasColumn('code_created_at')) {
51+
$table->addColumn('code_created_at', Types::BIGINT, [
52+
'notnull' => true,
53+
'default' => 0,
54+
'unsigned' => true,
55+
]);
56+
$dbChanged = true;
57+
}
58+
if (!$table->hasColumn('token_count')) {
59+
$table->addColumn('token_count', Types::BIGINT, [
60+
'notnull' => true,
61+
'default' => 0,
62+
'unsigned' => true,
63+
]);
64+
$dbChanged = true;
65+
}
66+
if (!$table->hasIndex('oauth2_tk_c_created_idx')) {
67+
$table->addIndex(['token_count', 'code_created_at'], 'oauth2_tk_c_created_idx');
68+
$dbChanged = true;
69+
}
70+
if ($dbChanged) {
71+
return $schema;
72+
}
73+
}
74+
75+
return null;
76+
}
77+
78+
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
79+
// we consider that existing access_tokens have already produced at least one oauth token
80+
// which prevents cleaning them up
81+
$qbUpdate = $this->connection->getQueryBuilder();
82+
$qbUpdate->update('oauth2_access_tokens')
83+
->set('token_count', $qbUpdate->createNamedParameter(1, IQueryBuilder::PARAM_INT));
84+
$qbUpdate->executeStatement();
85+
}
86+
}

0 commit comments

Comments
 (0)