encrypt tokens, client id and client secret. bump min NC version to 27

Signed-off-by: Julien Veyssier <julien-nc@posteo.net>
This commit is contained in:
Julien Veyssier 2024-10-02 02:16:47 +02:00
Родитель 3444a7b460
Коммит a0deed1e94
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4141FEE162030638
10 изменённых файлов: 281 добавлений и 85 удалений

Просмотреть файл

@ -10,7 +10,7 @@
<description><![CDATA[GitHub integration provides a dashboard widget displaying your most important notifications
and a unified search provider for repositories, issues and pull requests. It also provides a link reference provider
to render links to issues, pull requests and comments in Talk and Text.]]></description>
<version>3.0.0</version>
<version>3.0.1</version>
<licence>agpl</licence>
<author>Julien Veyssier</author>
<namespace>Github</namespace>
@ -23,7 +23,7 @@
<bugs>https://github.com/nextcloud/integration_github/issues</bugs>
<screenshot>https://github.com/nextcloud/integration_github/raw/main/img/screenshot1.jpg</screenshot>
<dependencies>
<nextcloud min-version="26" max-version="31"/>
<nextcloud min-version="27" max-version="31"/>
</dependencies>
<settings>
<admin>OCA\Github\Settings\Admin</admin>

Просмотреть файл

@ -9,7 +9,10 @@ namespace OCA\Github\Controller;
use OCA\Github\AppInfo\Application;
use OCA\Github\Reference\GithubIssuePrReferenceProvider;
use OCA\Github\Service\GithubAPIService;
use OCA\Github\Service\SecretService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
@ -24,27 +27,28 @@ use OCP\PreConditionNotMetException;
class ConfigController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private IConfig $config,
private IURLGenerator $urlGenerator,
private IL10N $l,
private IInitialState $initialStateService,
private GithubAPIService $githubAPIService,
string $appName,
IRequest $request,
private IConfig $config,
private IURLGenerator $urlGenerator,
private IL10N $l,
private IInitialState $initialStateService,
private GithubAPIService $githubAPIService,
private SecretService $secretService,
private GithubIssuePrReferenceProvider $githubIssuePrReferenceProvider,
private ?string $userId,
private ?string $userId,
) {
parent::__construct($appName, $request);
}
/**
* @NoAdminRequired
* Set config values
*
* @param array $values key/value pairs to store in user preferences
* @return DataResponse
* @throws PreConditionNotMetException
*/
#[NoAdminRequired]
public function setConfig(array $values): DataResponse {
// revoke the oauth token if needed
if (isset($values['token']) && $values['token'] === '') {
@ -56,7 +60,11 @@ class ConfigController extends Controller {
// save values
foreach ($values as $key => $value) {
$this->config->setUserValue($this->userId, Application::APP_ID, $key, $value);
if ($key === 'token') {
$this->secretService->setEncryptedUserValue($this->userId, $key, $value);
} else {
$this->config->setUserValue($this->userId, Application::APP_ID, $key, $value);
}
}
$result = [];
@ -76,6 +84,7 @@ class ConfigController extends Controller {
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'user_name');
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'user_displayname');
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'token_type');
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'token');
$result['user_name'] = '';
}
// connect or disconnect: invalidate the user-related cache
@ -92,28 +101,28 @@ class ConfigController extends Controller {
*/
public function setAdminConfig(array $values): DataResponse {
foreach ($values as $key => $value) {
$this->config->setAppValue(Application::APP_ID, $key, $value);
if (in_array($key, ['client_id', 'client_secret', 'default_link_token'], true)) {
$this->secretService->setEncryptedAppValue($key, $value);
} else {
$this->config->setAppValue(Application::APP_ID, $key, $value);
}
}
return new DataResponse(1);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param string $user_name
* @param string $user_displayname
* @return TemplateResponse
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function popupSuccessPage(string $user_name, string $user_displayname): TemplateResponse {
$this->initialStateService->provideInitialState('popup-data', ['user_name' => $user_name, 'user_displayname' => $user_displayname]);
return new TemplateResponse(Application::APP_ID, 'popupSuccess', [], TemplateResponse::RENDER_AS_GUEST);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* Receive oauth code and get oauth access token
*
* @param string $code request code to use when requesting oauth token
@ -121,10 +130,12 @@ class ConfigController extends Controller {
* @return RedirectResponse to user settings
* @throws PreConditionNotMetException
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function oauthRedirect(string $code, string $state): RedirectResponse {
$configState = $this->config->getUserValue($this->userId, Application::APP_ID, 'oauth_state');
$clientID = $this->config->getAppValue(Application::APP_ID, 'client_id');
$clientSecret = $this->config->getAppValue(Application::APP_ID, 'client_secret');
$clientID = $this->secretService->getEncryptedAppValue('client_id');
$clientSecret = $this->secretService->getEncryptedAppValue('client_secret');
// anyway, reset state
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'oauth_state');
@ -139,7 +150,7 @@ class ConfigController extends Controller {
if (isset($result['access_token'])) {
$this->githubIssuePrReferenceProvider->invalidateUserCache($this->userId);
$accessToken = $result['access_token'];
$this->config->setUserValue($this->userId, Application::APP_ID, 'token', $accessToken);
$this->secretService->setEncryptedUserValue($this->userId, 'token', $accessToken);
$this->config->setUserValue($this->userId, Application::APP_ID, 'token_type', 'oauth');
$userInfo = $this->storeUserInfo();

Просмотреть файл

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace OCA\Github\Migration;
use Closure;
use OCA\Github\AppInfo\Application;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use OCP\Security\ICrypto;
class Version030010Date20241002015812 extends SimpleMigrationStep {
public function __construct(
private IDBConnection $connection,
private ICrypto $crypto,
private IConfig $config,
) {
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options) {
// client_id, client_secret, default_link_token
foreach (['client_id', 'client_secret', 'default_link_token'] as $key) {
$value = $this->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('preferences')
->set('configvalue', $qbUpdate->createParameter('updateValue'))
->where(
$qbUpdate->expr()->eq('appid', $qbUpdate->createNamedParameter(Application::APP_ID, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qbUpdate->expr()->eq('userid', $qbUpdate->createParameter('updateUserId'))
)
->andWhere(
$qbUpdate->expr()->eq('configkey', $qbUpdate->createNamedParameter('token', IQueryBuilder::PARAM_STR))
);
$qbSelect = $this->connection->getQueryBuilder();
$qbSelect->select('userid', 'configvalue')
->from('preferences')
->where(
$qbSelect->expr()->eq('appid', $qbSelect->createNamedParameter(Application::APP_ID, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qbSelect->expr()->eq('configkey', $qbSelect->createNamedParameter('token', IQueryBuilder::PARAM_STR))
)
->andWhere(
$qbSelect->expr()->nonEmptyString('configvalue')
)
->andWhere(
$qbSelect->expr()->isNotNull('configvalue')
);
$req = $qbSelect->executeQuery();
while ($row = $req->fetch()) {
$userId = $row['userid'];
$storedClearToken = $row['configvalue'];
$encryptedToken = $this->crypto->encrypt($storedClearToken);
$qbUpdate->setParameter('updateValue', $encryptedToken, IQueryBuilder::PARAM_STR);
$qbUpdate->setParameter('updateUserId', $userId, IQueryBuilder::PARAM_STR);
$qbUpdate->executeStatement();
}
$req->closeCursor();
}
}

Просмотреть файл

@ -10,6 +10,7 @@ namespace OCA\Github\Search;
use OCA\Github\AppInfo\Application;
use OCA\Github\Service\GithubAPIService;
use OCA\Github\Service\SecretService;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\IL10N;
@ -28,6 +29,7 @@ class GithubSearchIssuesProvider implements IProvider {
private IConfig $config,
private IURLGenerator $urlGenerator,
private GithubAPIService $service,
private SecretService $secretService,
) {
}
@ -78,7 +80,7 @@ class GithubSearchIssuesProvider implements IProvider {
return SearchResult::paginated($this->getName(), [], 0);
}
$accessToken = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'token');
$accessToken = $this->secretService->getAccessToken($user->getUID());
if ($accessToken === '') {
return SearchResult::paginated($this->getName(), [], 0);
}

Просмотреть файл

@ -10,6 +10,7 @@ namespace OCA\Github\Search;
use OCA\Github\AppInfo\Application;
use OCA\Github\Service\GithubAPIService;
use OCA\Github\Service\SecretService;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\IL10N;
@ -28,6 +29,7 @@ class GithubSearchReposProvider implements IProvider {
private IConfig $config,
private IURLGenerator $urlGenerator,
private GithubAPIService $service,
private SecretService $secretService,
) {
}
@ -78,7 +80,7 @@ class GithubSearchReposProvider implements IProvider {
return SearchResult::paginated($this->getName(), [], 0);
}
$accessToken = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'token');
$accessToken = $this->secretService->getAccessToken($user->getUID());
if ($accessToken === '') {
return SearchResult::paginated($this->getName(), [], 0);
}

Просмотреть файл

@ -30,13 +30,12 @@ class GithubAPIService {
private IClient $client;
public function __construct(
string $appName,
private SecretService $secretService,
private LoggerInterface $logger,
private IL10N $l10n,
private IConfig $config,
private IURLGenerator $urlGenerator,
private IUserManager $userManager,
IClientService $clientService,
private IL10N $l10n,
private IConfig $config,
private IURLGenerator $urlGenerator,
IClientService $clientService,
) {
$this->client = $clientService->newClient();
}
@ -359,43 +358,6 @@ class GithubAPIService {
return $this->request($userId, $endpoint, [], 'GET', true, 5);
}
/**
* Get the user access token
* If there is none, get the default one, check:
* - if we use it for this endpoint
* - if user is anonymous
* - if user is a guest
* @param string|null $userId
* @param bool $endpointUsesDefaultToken
* @return string
*/
public function getAccessToken(?string $userId, bool $endpointUsesDefaultToken = false): string {
// use user access token in priority
$accessToken = '';
// for logged in users
if ($userId !== null) {
$accessToken = $this->config->getUserValue($userId, Application::APP_ID, 'token');
// fallback to admin default token if $useDefaultToken
if ($accessToken === '' && $endpointUsesDefaultToken) {
$user = $this->userManager->get($userId);
$isGuestUser = $user->getBackendClassName() === 'Guests';
$allowDefaultTokenToGuests = $this->config->getAppValue(Application::APP_ID, 'allow_default_link_token_to_guests', '0') === '1';
if ((!$isGuestUser) || $allowDefaultTokenToGuests) {
$accessToken = $this->config->getAppValue(Application::APP_ID, 'default_link_token');
}
}
} elseif ($endpointUsesDefaultToken) {
// anonymous users
$allowDefaultTokenToAnonymous = $this->config->getAppValue(Application::APP_ID, 'allow_default_link_token_to_anonymous', '0') === '1';
if ($allowDefaultTokenToAnonymous) {
$accessToken = $this->config->getAppValue(Application::APP_ID, 'default_link_token');
}
}
return $accessToken;
}
/**
* Make an authenticated HTTP request to GitHub API
* @param string|null $userId
@ -416,7 +378,7 @@ class GithubAPIService {
'User-Agent' => 'Nextcloud GitHub integration',
],
];
$accessToken = $this->getAccessToken($userId, $endpointUsesDefaultToken);
$accessToken = $this->secretService->getAccessToken($userId, $endpointUsesDefaultToken);
if ($accessToken !== '') {
$options['headers']['Authorization'] = 'token ' . $accessToken;
}
@ -469,9 +431,9 @@ class GithubAPIService {
}
public function revokeOauthToken(string $userId): array {
$accessToken = $this->config->getUserValue($userId, Application::APP_ID, 'token');
$clientId = $this->config->getAppValue(Application::APP_ID, 'client_id');
$clientSecret = $this->config->getAppValue(Application::APP_ID, 'client_secret');
$accessToken = $this->secretService->getEncryptedUserValue($userId, 'token');
$clientId = $this->secretService->getEncryptedAppValue('client_id');
$clientSecret = $this->secretService->getEncryptedAppValue('client_secret');
$endPoint = 'applications/' . $clientId . '/token';
try {
$url = 'https://api.github.com/' . $endPoint;

Просмотреть файл

@ -0,0 +1,134 @@
<?php
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Github\Service;
use DateInterval;
use DateTime;
use Exception;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use OCA\Github\AppInfo\Application;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\PreConditionNotMetException;
use OCP\Security\ICrypto;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* Service to make requests to GitHub v3 (JSON) API
*/
class SecretService {
public function __construct(
private IConfig $config,
private IUserManager $userManager,
private ICrypto $crypto,
) {
}
/**
* @param string $userId
* @param string $key
* @param string $value
* @return void
* @throws PreConditionNotMetException
*/
public function setEncryptedUserValue(string $userId, string $key, string $value): void {
if ($value === '') {
$this->config->setUserValue($userId, Application::APP_ID, $key, '');
return;
}
$encryptedValue = $this->crypto->encrypt($value);
$this->config->setUserValue($userId, Application::APP_ID, $key, $encryptedValue);
}
/**
* @param string $userId
* @param string $key
* @return string
* @throws Exception
*/
public function getEncryptedUserValue(string $userId, string $key): string {
$storedValue = $this->config->getUserValue($userId, Application::APP_ID, $key);
if ($storedValue === '') {
return '';
}
return $this->crypto->decrypt($storedValue);
}
/**
* @param string $key
* @param string $value
* @return void
*/
public function setEncryptedAppValue(string $key, string $value): void {
if ($value === '') {
$this->config->setAppValue(Application::APP_ID, $key, '');
return;
}
$encryptedValue = $this->crypto->encrypt($value);
$this->config->setAppValue(Application::APP_ID, $key, $encryptedValue);
}
/**
* @param string $key
* @return string
* @throws Exception
*/
public function getEncryptedAppValue(string $key): string {
$storedValue = $this->config->getAppValue(Application::APP_ID, $key);
if ($storedValue === '') {
return '';
}
return $this->crypto->decrypt($storedValue);
}
/**
* Get the user access token
* If there is none, get the default one, check:
* - if we use it for this endpoint
* - if user is anonymous
* - if user is a guest
*
* @param string|null $userId
* @param bool $endpointUsesDefaultToken
* @return string
* @throws Exception
*/
public function getAccessToken(?string $userId, bool $endpointUsesDefaultToken = false): string {
// use user access token in priority
$accessToken = '';
// for logged in users
if ($userId !== null) {
$accessToken = $this->getEncryptedUserValue($userId, 'token');
// fallback to admin default token if $useDefaultToken
if ($accessToken === '' && $endpointUsesDefaultToken) {
$user = $this->userManager->get($userId);
$isGuestUser = $user->getBackendClassName() === 'Guests';
$allowDefaultTokenToGuests = $this->config->getAppValue(Application::APP_ID, 'allow_default_link_token_to_guests', '0') === '1';
if ((!$isGuestUser) || $allowDefaultTokenToGuests) {
$accessToken = $this->getEncryptedAppValue('default_link_token');
}
}
} elseif ($endpointUsesDefaultToken) {
// anonymous users
$allowDefaultTokenToAnonymous = $this->config->getAppValue(Application::APP_ID, 'allow_default_link_token_to_anonymous', '0') === '1';
if ($allowDefaultTokenToAnonymous) {
$accessToken = $this->getEncryptedAppValue('default_link_token');
}
}
return $accessToken;
}
}

Просмотреть файл

@ -6,6 +6,7 @@
namespace OCA\Github\Settings;
use OCA\Github\AppInfo\Application;
use OCA\Github\Service\SecretService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
@ -14,6 +15,7 @@ use OCP\Settings\ISettings;
class Admin implements ISettings {
public function __construct(
private SecretService $secretService,
private IConfig $config,
private IInitialState $initialStateService,
) {
@ -23,22 +25,22 @@ class Admin implements ISettings {
* @return TemplateResponse
*/
public function getForm(): TemplateResponse {
$clientID = $this->config->getAppValue(Application::APP_ID, 'client_id');
$clientSecret = $this->config->getAppValue(Application::APP_ID, 'client_secret');
$clientID = $this->secretService->getEncryptedAppValue('client_id');
$clientSecret = $this->secretService->getEncryptedAppValue('client_secret');
$usePopup = $this->config->getAppValue(Application::APP_ID, 'use_popup', '0');
$adminDashboardEnabled = $this->config->getAppValue(Application::APP_ID, 'dashboard_enabled', '1') === '1';
$adminLinkPreviewEnabled = $this->config->getAppValue(Application::APP_ID, 'link_preview_enabled', '1') === '1';
$defaultLinkToken = $this->config->getAppValue(Application::APP_ID, 'default_link_token');
$defaultLinkToken = $this->secretService->getEncryptedAppValue('default_link_token');
$allowDefaultTokenToAnonymous = $this->config->getAppValue(Application::APP_ID, 'allow_default_link_token_to_anonymous', '0') === '1';
$allowDefaultTokenToGuests = $this->config->getAppValue(Application::APP_ID, 'allow_default_link_token_to_guests', '0') === '1';
$adminConfig = [
'client_id' => $clientID,
'client_secret' => $clientSecret,
'client_secret' => $clientSecret === '' ? '' : 'dummyClientSecret',
'use_popup' => ($usePopup === '1'),
'dashboard_enabled' => $adminDashboardEnabled,
'link_preview_enabled' => $adminLinkPreviewEnabled,
'default_link_token' => $defaultLinkToken,
'default_link_token' => $defaultLinkToken === '' ? '' : 'dummyToken',
'allow_default_link_token_to_anonymous' => $allowDefaultTokenToAnonymous,
'allow_default_link_token_to_guests' => $allowDefaultTokenToGuests,
];

Просмотреть файл

@ -6,6 +6,7 @@
namespace OCA\Github\Settings;
use OCA\Github\AppInfo\Application;
use OCA\Github\Service\SecretService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
@ -14,6 +15,7 @@ use OCP\Settings\ISettings;
class Personal implements ISettings {
public function __construct(
private SecretService $secretService,
private IConfig $config,
private IInitialState $initialStateService,
private ?string $userId,
@ -24,7 +26,7 @@ class Personal implements ISettings {
* @return TemplateResponse
*/
public function getForm(): TemplateResponse {
$token = $this->config->getUserValue($this->userId, Application::APP_ID, 'token');
$token = $this->secretService->getEncryptedUserValue($this->userId, 'token');
$searchIssuesEnabled = $this->config->getUserValue($this->userId, Application::APP_ID, 'search_issues_enabled', '0');
$searchReposEnabled = $this->config->getUserValue($this->userId, Application::APP_ID, 'search_repos_enabled', '0');
$navigationEnabled = $this->config->getUserValue($this->userId, Application::APP_ID, 'navigation_enabled', '0');
@ -33,14 +35,14 @@ class Personal implements ISettings {
$userDisplayName = $this->config->getUserValue($this->userId, Application::APP_ID, 'user_displayname');
// for OAuth
$clientID = $this->config->getAppValue(Application::APP_ID, 'client_id');
$clientSecret = $this->config->getAppValue(Application::APP_ID, 'client_secret') !== '';
$clientID = $this->secretService->getEncryptedAppValue('client_id');
$clientSecret = $this->secretService->getEncryptedAppValue('client_secret');
$usePopup = $this->config->getAppValue(Application::APP_ID, 'use_popup', '0');
$userConfig = [
'token' => $token,
'token' => $token === '' ? '' : 'dummyToken',
'client_id' => $clientID,
'client_secret' => $clientSecret,
'client_secret' => $clientSecret !== '',
'use_popup' => ($usePopup === '1'),
'search_issues_enabled' => ($searchIssuesEnabled === '1'),
'search_repos_enabled' => ($searchReposEnabled === '1'),

Просмотреть файл

@ -4,6 +4,7 @@ namespace OCA\Github\Tests;
use OCA\Github\AppInfo\Application;
use OCA\Github\Service\GithubAPIService;
use OCA\Github\Service\SecretService;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
@ -27,7 +28,6 @@ class GithubAPIControllerTest extends TestCase {
private $githubApiController;
private $githubApiService;
private $iClient;
private $config;
public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();
@ -54,8 +54,10 @@ class GithubAPIControllerTest extends TestCase {
$this->iClient = $this->createMock(IClient::class);
$clientService->method('newClient')->willReturn($this->iClient);
$secretService = \OC::$server->get(SecretService::class);
$this->githubApiService = new GithubAPIService(
self::APP_NAME,
$secretService,
\OC::$server->get(\Psr\Log\LoggerInterface::class),
$this->createMock(IL10N::class),
\OC::$server->get(IConfig::class),
@ -71,8 +73,7 @@ class GithubAPIControllerTest extends TestCase {
self::TEST_USER1
);
$this->config = \OC::$server->get(IConfig::class);
$this->config->setUserValue(self::TEST_USER1, Application::APP_ID, 'token', self::API_TOKEN);
$secretService->setEncryptedUserValue(self::TEST_USER1, 'token', self::API_TOKEN);
}
public function testGetNotifications(): void {