From 848dea7f19fdbc8a6659b863851cbe89ce038986 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 18 Oct 2024 10:30:40 +0200 Subject: [PATCH] encrypt oauth client id/secret and user tokens Signed-off-by: Julien Veyssier --- appinfo/info.xml | 2 +- lib/Controller/ConfigController.php | 6 +- lib/Controller/GitlabAPIController.php | 6 +- lib/Db/GitlabAccount.php | 62 ++++++++++-- lib/Migration/MultiAccountRepairStep.php | 4 +- .../Version030103Date20241017150114.php | 97 +++++++++++++++++++ lib/Search/GitlabSearchIssuesProvider.php | 2 +- .../GitlabSearchMergeRequestsProvider.php | 2 +- lib/Search/GitlabSearchReposProvider.php | 2 +- lib/Service/ConfigService.php | 27 +++++- lib/Service/GitlabAPIService.php | 12 +-- psalm.xml | 1 + .../Controller/GitlabAPIControllerTest.php | 2 +- 13 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 lib/Migration/Version030103Date20241017150114.php diff --git a/appinfo/info.xml b/appinfo/info.xml index d00c1ce..6c3bdb6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ Integration of GitLab software development management service - 3.1.2 + 3.1.3 agpl Julien Veyssier Gitlab diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php index 6338b87..979d669 100644 --- a/lib/Controller/ConfigController.php +++ b/lib/Controller/ConfigController.php @@ -71,7 +71,7 @@ public function addAccount(string $url, string $token) { $account = new GitlabAccount(); $account->setUserId($this->userId); $account->setUrl($url); - $account->setToken($token); + $account->setEncryptedToken($token); $account->setTokenType('personal'); try { @@ -197,9 +197,9 @@ public function oauthRedirect(string $code = '', string $state = ''): RedirectRe $account = new GitlabAccount(); $account->setUserId($this->userId); $account->setUrl($adminOauthUrl); - $account->setToken($result['access_token']); + $account->setEncryptedToken($result['access_token']); $account->setTokenType('oauth'); - $account->setRefreshToken($result['refresh_token'] ?? ''); + $account->setEncryptedRefreshToken($result['refresh_token'] ?? ''); if (isset($result['expires_in'])) { $nowTs = (new Datetime())->getTimestamp(); $expiresAt = $nowTs + (int)$result['expires_in']; diff --git a/lib/Controller/GitlabAPIController.php b/lib/Controller/GitlabAPIController.php index ac60178..67dd4b7 100644 --- a/lib/Controller/GitlabAPIController.php +++ b/lib/Controller/GitlabAPIController.php @@ -48,7 +48,7 @@ public function __construct( public function getUserAvatar(int $accountId, int $userId) { try { $account = $this->accountMapper->findById($this->userId, $accountId); - if ($account->getToken() === '') { + if ($account->getClearToken() === '') { return new DataResponse('', 400); } @@ -83,7 +83,7 @@ public function getUserAvatar(int $accountId, int $userId) { public function getProjectAvatar(int $accountId, int $projectId) { try { $account = $this->accountMapper->findById($this->userId, $accountId); - if ($account->getToken() === '') { + if ($account->getClearToken() === '') { return new DataResponse('', 400); } @@ -117,7 +117,7 @@ public function getProjectAvatar(int $accountId, int $projectId) { public function getTodos(int $accountId, ?string $since = null): DataResponse { try { $account = $this->accountMapper->findById($this->userId, $accountId); - if ($account->getToken() === '') { + if ($account->getClearToken() === '') { return new DataResponse('', 400); } diff --git a/lib/Db/GitlabAccount.php b/lib/Db/GitlabAccount.php index 032e346..7b43d81 100644 --- a/lib/Db/GitlabAccount.php +++ b/lib/Db/GitlabAccount.php @@ -6,6 +6,7 @@ use JsonSerializable; use OCP\AppFramework\Db\Entity; +use OCP\Security\ICrypto; /** * @method void setUserId(string $userId) @@ -25,14 +26,29 @@ * @method void setUserInfoDisplayName(string $userInfoDisplayName) */ class GitlabAccount extends Entity implements JsonSerializable { - protected string $userId = ''; - protected string $url = ''; - protected string $token = ''; - protected string $tokenType = ''; - protected ?int $tokenExpiresAt = null; - protected ?string $refreshToken = null; - protected ?string $userInfoName = null; - protected ?string $userInfoDisplayName = null; + protected $userId; + protected $url; + protected $token; + protected $tokenType; + protected $tokenExpiresAt; + protected $refreshToken; + protected $userInfoName; + protected $userInfoDisplayName; + + private ICrypto $crypto; + + public function __construct() { + $this->addType('id', 'integer'); + $this->addType('url', 'string'); + $this->addType('token', 'string'); + $this->addType('token_type', 'string'); + $this->addType('token_expires_at', 'integer'); + $this->addType('refresh_token', 'string'); + $this->addType('user_info_name', 'string'); + $this->addType('user_info_display_name', 'string'); + + $this->crypto = \OC::$server->get(ICrypto::class); + } public function jsonSerialize(): array { return [ @@ -46,4 +62,34 @@ public function jsonSerialize(): array { 'userInfoDisplayName' => $this->userInfoDisplayName, ]; } + + public function getClearToken(): string { + if (is_string($this->token) && $this->token !== '') { + return $this->crypto->decrypt($this->token); + } + return $this->token; + } + + public function setEncryptedToken(string $token): void { + if ($token !== '') { + $this->setter('token', [$this->crypto->encrypt($token)]); + } else { + $this->setter('token', [$token]); + } + } + + public function getClearRefreshToken(): ?string { + if (is_string($this->refreshToken) && $this->refreshToken !== '') { + return $this->crypto->decrypt($this->refreshToken); + } + return $this->refreshToken; + } + + public function setEncryptedRefreshToken(?string $refreshToken): void { + if (is_string($refreshToken) && $refreshToken !== '') { + $this->setter('refreshToken', [$this->crypto->encrypt($refreshToken)]); + } else { + $this->setter('refreshToken', [$refreshToken]); + } + } } diff --git a/lib/Migration/MultiAccountRepairStep.php b/lib/Migration/MultiAccountRepairStep.php index 9a2c5e4..cffb16d 100644 --- a/lib/Migration/MultiAccountRepairStep.php +++ b/lib/Migration/MultiAccountRepairStep.php @@ -30,7 +30,7 @@ public function run(IOutput $output) { $account = new GitlabAccount(); $account->setUserId($user->getUID()); $account->setUrl($this->config->getUserUrl($userId)); - $account->setToken($this->config->getUserToken($userId)); + $account->setEncryptedToken($this->config->getUserToken($userId)); $account->setTokenType($this->config->getUserTokenType($userId)); if (!$account->getTokenType()) { if ($this->config->hasUserRefreshToken($userId)) { @@ -43,7 +43,7 @@ public function run(IOutput $output) { $account->setTokenExpiresAt($this->config->getUserTokenExpiresAt($userId)); } if ($this->config->hasUserRefreshToken($userId)) { - $account->setRefreshToken($this->config->getUserRefreshToken($userId)); + $account->setEncryptedRefreshToken($this->config->getUserRefreshToken($userId)); } $account->setUserInfoName($this->config->getUserName($userId)); $account->setUserInfoDisplayName($this->config->getUserDisplayName($userId)); diff --git a/lib/Migration/Version030103Date20241017150114.php b/lib/Migration/Version030103Date20241017150114.php new file mode 100644 index 0000000..f2a88e3 --- /dev/null +++ b/lib/Migration/Version030103Date20241017150114.php @@ -0,0 +1,97 @@ +config->getAppValue(Application::APP_ID, $key); + if ($value !== '') { + $encryptedValue = $this->crypto->encrypt($value); + $this->config->setAppValue(Application::APP_ID, $key, $encryptedValue); + } + } + + // ----------- user tokens + $qbUpdate = $this->connection->getQueryBuilder(); + $qbUpdate->update('gitlab_accounts') + ->set('token', $qbUpdate->createParameter('updateToken')) + ->where( + $qbUpdate->expr()->eq('id', $qbUpdate->createParameter('updateAccountId')) + ); + + $qbSelect = $this->connection->getQueryBuilder(); + $qbSelect->select('id', 'token') + ->from('gitlab_accounts') + ->where( + $qbSelect->expr()->nonEmptyString('token') + ); + $req = $qbSelect->executeQuery(); + while ($row = $req->fetch()) { + $accountId = (int)$row['id']; + $storedClearRefreshToken = $row['token']; + $encryptedToken = $this->crypto->encrypt($storedClearRefreshToken); + $qbUpdate->setParameter('updateAccountId', $accountId, IQueryBuilder::PARAM_INT); + $qbUpdate->setParameter('updateToken', $encryptedToken, IQueryBuilder::PARAM_STR); + $qbUpdate->executeStatement(); + } + $req->closeCursor(); + + // ----------- user refresh tokens + $qbUpdate = $this->connection->getQueryBuilder(); + $qbUpdate->update('gitlab_accounts') + ->set('refresh_token', $qbUpdate->createParameter('updateRefreshToken')) + ->where( + $qbUpdate->expr()->eq('id', $qbUpdate->createParameter('updateAccountId')) + ); + + $qbSelect = $this->connection->getQueryBuilder(); + $qbSelect->select('id', 'refresh_token') + ->from('gitlab_accounts') + ->where( + $qbSelect->expr()->nonEmptyString('refresh_token') + ) + ->andWhere( + $qbSelect->expr()->isNotNull('refresh_token') + ); + $req = $qbSelect->executeQuery(); + while ($row = $req->fetch()) { + $accountId = (int)$row['id']; + $storedClearRefreshToken = $row['refresh_token']; + $encryptedToken = $this->crypto->encrypt($storedClearRefreshToken); + $qbUpdate->setParameter('updateAccountId', $accountId, IQueryBuilder::PARAM_INT); + $qbUpdate->setParameter('updateRefreshToken', $encryptedToken, IQueryBuilder::PARAM_STR); + $qbUpdate->executeStatement(); + } + $req->closeCursor(); + } +} diff --git a/lib/Search/GitlabSearchIssuesProvider.php b/lib/Search/GitlabSearchIssuesProvider.php index fbd5f82..63d9654 100644 --- a/lib/Search/GitlabSearchIssuesProvider.php +++ b/lib/Search/GitlabSearchIssuesProvider.php @@ -102,7 +102,7 @@ public function search(IUser $user, ISearchQuery $query): SearchResult { $accounts = $this->accountMapper->find($user->getUID()); foreach ($accounts as $account) { - $accessToken = $account->getToken(); + $accessToken = $account->getClearToken(); if ($accessToken === '') { continue; } diff --git a/lib/Search/GitlabSearchMergeRequestsProvider.php b/lib/Search/GitlabSearchMergeRequestsProvider.php index a75a867..533541f 100644 --- a/lib/Search/GitlabSearchMergeRequestsProvider.php +++ b/lib/Search/GitlabSearchMergeRequestsProvider.php @@ -102,7 +102,7 @@ public function search(IUser $user, ISearchQuery $query): SearchResult { $accounts = $this->accountMapper->find($user->getUID()); foreach ($accounts as $account) { - $accessToken = $account->getToken(); + $accessToken = $account->getClearToken(); if ($accessToken === '') { continue; } diff --git a/lib/Search/GitlabSearchReposProvider.php b/lib/Search/GitlabSearchReposProvider.php index a1ceada..9fa957c 100644 --- a/lib/Search/GitlabSearchReposProvider.php +++ b/lib/Search/GitlabSearchReposProvider.php @@ -102,7 +102,7 @@ public function search(IUser $user, ISearchQuery $query): SearchResult { $accounts = $this->accountMapper->find($user->getUID()); foreach ($accounts as $account) { - $accessToken = $account->getToken(); + $accessToken = $account->getClearToken(); if ($accessToken === '') { continue; } diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 83ab246..7c3c1fe 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -7,19 +7,38 @@ use OCA\Gitlab\AppInfo\Application; use OCP\IConfig; use OCP\PreConditionNotMetException; +use OCP\Security\ICrypto; class ConfigService { public function __construct( private IConfig $config, + private ICrypto $crypto, ) { } + public function getClearAppValue(string $key): string { + $value = $this->config->getAppValue(Application::APP_ID, $key); + if ($value === '') { + return $value; + } + return $this->crypto->decrypt($value); + } + + public function setEncryptedAppValue(string $key, string $value): void { + if ($value === '') { + $this->config->setAppValue(Application::APP_ID, $key, $value); + } else { + $encryptedValue = $this->crypto->encrypt($value); + $this->config->setAppValue(Application::APP_ID, $key, $encryptedValue); + } + } + public function getAdminClientId(): string { - return $this->config->getAppValue(Application::APP_ID, 'client_id'); + return $this->getClearAppValue('client_id'); } public function setAdminClientId(string $clientId): void { - $this->config->setAppValue(Application::APP_ID, 'client_id', $clientId); + $this->setEncryptedAppValue('client_id', $clientId); } public function hasAdminClientSecret(): bool { @@ -27,11 +46,11 @@ public function hasAdminClientSecret(): bool { } public function getAdminClientSecret(): string { - return $this->config->getAppValue(Application::APP_ID, 'client_secret'); + return $this->getClearAppValue('client_secret'); } public function setAdminClientSecret(string $clientSecret): void { - $this->config->setAppValue(Application::APP_ID, 'client_secret', $clientSecret); + $this->setEncryptedAppValue('client_secret', $clientSecret); } public function getAdminOauthUrl(): string { diff --git a/lib/Service/GitlabAPIService.php b/lib/Service/GitlabAPIService.php index 60d5b45..3b11854 100644 --- a/lib/Service/GitlabAPIService.php +++ b/lib/Service/GitlabAPIService.php @@ -243,7 +243,7 @@ public function request(?GitlabAccount $account, string $baseUrl, string $endPoi // try anonymous request if no user (public page) or user not connected to a gitlab account if ($account !== null) { - $accessToken = $account->getToken(); + $accessToken = $account->getClearToken(); if ($accessToken !== '') { $options['headers']['Authorization'] = 'Bearer ' . $accessToken; } @@ -301,7 +301,7 @@ public function request(?GitlabAccount $account, string $baseUrl, string $endPoi } private function checkTokenExpiration(GitlabAccount $account): void { - if ($account->getRefreshToken() && $account->getTokenExpiresAt()) { + if ($account->getClearRefreshToken() && $account->getTokenExpiresAt()) { $nowTs = (new DateTime())->getTimestamp(); // if token expires in less than a minute or is already expired if ($nowTs > $account->getTokenExpiresAt() - 60) { @@ -312,7 +312,7 @@ private function checkTokenExpiration(GitlabAccount $account): void { private function refreshToken(GitlabAccount $account): bool { $adminOauthUrl = $this->config->getAdminOauthUrl(); - $refreshToken = $account->getRefreshToken(); + $refreshToken = $account->getClearRefreshToken(); if (!$refreshToken) { $this->logger->error('No GitLab refresh token found', ['app' => Application::APP_ID]); return false; @@ -329,8 +329,8 @@ private function refreshToken(GitlabAccount $account): bool { $accessToken = $result['access_token']; $refreshToken = $result['refresh_token']; $account->setUrl($adminOauthUrl); - $account->setToken($accessToken); - $account->setRefreshToken($refreshToken); + $account->setEncryptedToken($accessToken); + $account->setEncryptedRefreshToken($refreshToken); if (isset($result['expires_in'])) { $nowTs = (new DateTime())->getTimestamp(); $expiresAt = $nowTs + (int) $result['expires_in']; @@ -361,7 +361,7 @@ public function revokeOauthToken(GitlabAccount $account): array { 'body' => json_encode([ 'client_id' => $this->config->getAdminClientId(), 'client_secret' => $this->config->getAdminClientSecret(), - 'token' => $account->getToken(), + 'token' => $account->getClearToken(), ]), ]; diff --git a/psalm.xml b/psalm.xml index d25a353..40360d7 100644 --- a/psalm.xml +++ b/psalm.xml @@ -30,6 +30,7 @@ + diff --git a/tests/unit/Controller/GitlabAPIControllerTest.php b/tests/unit/Controller/GitlabAPIControllerTest.php index 926300b..e75b9d1 100644 --- a/tests/unit/Controller/GitlabAPIControllerTest.php +++ b/tests/unit/Controller/GitlabAPIControllerTest.php @@ -62,7 +62,7 @@ protected function setUp(): void { $account = new GitlabAccount(); $account->setUrl('https://gitlab.com'); - $account->setToken(self::API_TOKEN); + $account->setEncryptedToken(self::API_TOKEN); $accountMapper = $this->createMock(GitlabAccountMapper::class); $accountMapper->method('findById')->willReturn($account);