diff --git a/README.md b/README.md index d5a0529dced10460a486d3acec8f62e8affc1e33..42c9c93d84be7446f3cdd80d846d4f15c7bdce88 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,14 @@ The values should be set as follows: 'welcome_sendgrid_template_ids' => [ 'en' => 'EN_TEMPLATE_ID', 'es' => 'ES_TEMPLATE_ID', ... ] ... ``` + +## Sync 2FA secrets with Keycloak based SSO service + +- Enable admin service client in Keycloak +- Configure the following parameters in `config.php`: + - `oidc_admin_client_id` (client ID of the admin service client) + - `oidc_admin_client_secret` (client secret of the admin service client) + - `oidc_admin_username` (username of admin account) + - `oidc_admin_password` (password of admin account) + - `oidc_login_provider_url` (provider URL: see also https://github.com/pulsejet/nextcloud-oidc-login) + - `oidc_admin_sync_2fa` -> (set to boolean value true to enable sync; defaults to false) diff --git a/appinfo/info.xml b/appinfo/info.xml index 0d83ecbb6cb428a9d9cea429ea7f03b280c439ee..351006aa5eaa1d50ff706409521645bd7f0536f7 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -10,7 +10,7 @@ - 5.0.0 + 5.1.0 agpl Murena SAS EcloudAccounts diff --git a/lib/Command/Migrate2FASecrets.php b/lib/Command/Migrate2FASecrets.php index 79228be2ab96493144ab58e568c156f2665c04ef..6968376874ed9f2db6b75beeb5e72b2e13e5e037 100644 --- a/lib/Command/Migrate2FASecrets.php +++ b/lib/Command/Migrate2FASecrets.php @@ -4,20 +4,20 @@ declare(strict_types=1); namespace OCA\EcloudAccounts\Command; -use OCA\EcloudAccounts\Db\SSOMapper; use OCA\EcloudAccounts\Db\TwoFactorMapper; +use OCA\EcloudAccounts\Service\SSOService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class Migrate2FASecrets extends Command { - private SSOMapper $ssoMapper; + private SSOService $ssoService; private TwoFactorMapper $twoFactorMapper; private OutputInterface $commandOutput; - public function __construct(SSOMapper $ssoMapper, TwoFactorMapper $twoFactorMapper) { - $this->ssoMapper = $ssoMapper; + public function __construct(SSOService $ssoService, TwoFactorMapper $twoFactorMapper) { + $this->ssoService = $ssoService; $this->twoFactorMapper = $twoFactorMapper; parent::__construct(); } @@ -60,7 +60,7 @@ class Migrate2FASecrets extends Command { $entries = $this->twoFactorMapper->getEntries($usernames); foreach ($entries as $entry) { try { - $this->ssoMapper->migrateCredential($entry['username'], $entry['secret']); + $this->ssoService->migrateCredential($entry['username'], $entry['secret']); } catch (\Exception $e) { $this->commandOutput->writeln('Error inserting entry for user ' . $entry['username'] . ' message: ' . $e->getMessage()); continue; diff --git a/lib/Db/SSOMapper.php b/lib/Db/SSOMapper.php deleted file mode 100644 index 89f4af886efdb0ac651800dc334ec34f6cda1eed..0000000000000000000000000000000000000000 --- a/lib/Db/SSOMapper.php +++ /dev/null @@ -1,193 +0,0 @@ -l10nFactory = $l10nFactory; - $this->config = $config; - $this->logger = $logger; - $this->userManager = $userManager; - $this->crypto = $crypto; - if (!empty($this->config->getSystemValue(self::SSO_CONFIG_KEY))) { - $this->initConnection(); - } - } - - public function isSSOEnabled() : bool { - return isset($this->conn); - } - - public function getUserId(string $username) : string { - $qb = $this->conn->createQueryBuilder(); - $qb->select('USER_ID') - ->from(self::USER_ATTRIBUTE_TABLE) - ->where('NAME = "LDAP_ID"') - ->andWhere('VALUE = :username'); - - $qb->setParameter('username', $username); - $result = $qb->execute(); - return (string) $result->fetchOne(); - } - - public function deleteCredentials(string $username) { - $userId = $this->getUserId($username); - $qb = $this->conn->createQueryBuilder(); - $qb->delete(self::CREDENTIAL_TABLE) - ->where('USER_ID = :username') - ->andWhere('TYPE = "otp"') - ->andWhere('CREDENTIAL_DATA LIKE "%\"subType\":\"totp\"%"') - ->setParameter('username', $userId) - ->execute(); - } - - public function migrateCredential(string $username, string $secret) { - if (!$this->userManager->get($username) instanceof IUser) { - throw new \Exception('No user found in nextcloud with given username'); - } - - $decryptedSecret = $this->crypto->decrypt($secret); - $ssoUserId = $this->getUserId($username); - if (empty($ssoUserId)) { - throw new \Exception('Does not exist in SSO database'); - } - - $language = $this->config->getUserValue($username, 'core', 'lang', 'en'); - - // Only one 2FA device at a time - $this->deleteCredentials($username); - - $entry = $this->getCredentialEntry($decryptedSecret, $ssoUserId, $language); - $this->insertCredential($entry); - } - - public function insertCredential(array $entry) : void { - $qb = $this->conn->createQueryBuilder(); - $qb->insert(self::CREDENTIAL_TABLE) - ->values($entry) - ->execute(); - } - - /** - * Create secret entry compatible with Keycloak schema - * - * @return array - */ - - private function getCredentialEntry(string $secret, string $ssoUserId, string $language) : array { - // Create the random UUID from the sso user ID so multiple entries of same credential do not happen - $id = $this->randomUUID(substr($ssoUserId, 0, 16)); - - $l10n = $this->l10nFactory->get(Application::APP_ID, $language); - $userLabel = $l10n->t('Murena Cloud 2FA'); - - $credentialEntry = [ - 'ID' => $id, - 'USER_ID' => $ssoUserId, - 'USER_LABEL' => $userLabel, - 'TYPE' => 'otp', - 'SECRET_DATA' => json_encode([ - 'value' => $secret - ]), - 'CREDENTIAL_DATA' => json_encode([ - 'subType' => 'totp', - 'period' => 30, - 'digits' => 6, - 'algorithm' => 'HmacSHA1', - 'secretEncoding' => 'BASE32', - ]), - ]; - - foreach ($credentialEntry as $key => &$value) { - $value = "'" . $value . "'"; - } - $credentialEntry['CREATED_DATE'] = round(microtime(true) * 1000); - $credentialEntry['PRIORITY'] = 10; - - return $credentialEntry; - } - - private function initConnection() : void { - try { - $params = $this->getConnectionParams(); - $this->conn = DriverManager::getConnection($params); - } catch (Throwable $e) { - $this->logger->error('Error connecting to Keycloak database: ' . $e->getMessage()); - } - } - - private function isDbConfigValid($config) : bool { - if (!$config || !is_array($config)) { - return false; - } - if (!isset($config['db_port'])) { - $config['db_port'] = 3306; - } - - return isset($config['db_name']) - && isset($config['db_user']) - && isset($config['db_password']) - && isset($config['db_host']) - && isset($config['db_port']) ; - } - - private function getConnectionParams() : array { - $config = $this->config->getSystemValue(self::SSO_CONFIG_KEY); - - if (!$this->isDbConfigValid($config)) { - throw new DbConnectionParamsException('Invalid SSO database configuration!'); - } - - $params = [ - 'dbname' => $config['db_name'], - 'user' => $config['db_user'], - 'password' => $config['db_password'], - 'host' => $config['db_host'], - 'port' => $config['db_port'], - 'driver' => 'pdo_mysql' - ]; - return $params; - } - - /** - * From https://www.uuidgenerator.net/dev-corner/php - * As keycloak generates random UUIDs using the java.util.UUID class which is RFC 4122 compliant - * - * @return string - */ - private function randomUUID($data = null) : string { - // Generate 16 bytes (128 bits) of random data or use the data passed into the function. - $data = $data ?? random_bytes(16); - assert(strlen($data) == 16); - - // Set version to 0100 - $data[6] = chr(ord($data[6]) & 0x0f | 0x40); - // Set bits 6-7 to 10 - $data[8] = chr(ord($data[8]) & 0x3f | 0x80); - - // Output the 36 character UUID. - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - } -} diff --git a/lib/Exception/SSOAdminAPIException.php b/lib/Exception/SSOAdminAPIException.php new file mode 100644 index 0000000000000000000000000000000000000000..675dd687c52360a8b3abe1be066a3c5345f4167b --- /dev/null +++ b/lib/Exception/SSOAdminAPIException.php @@ -0,0 +1,9 @@ +appManager = $appManager; - $this->ssoMapper = $ssoMapper; + $this->ssoService = $ssoService; $this->twoFactorMapper = $twoFactorMapper; $this->logger = $logger; } public function handle(Event $event): void { - if (!($event instanceof StateChanged) || !$this->appManager->isEnabledForUser(self::TWOFACTOR_APP_ID) || !$this->ssoMapper->isSSOEnabled()) { + if (!($event instanceof StateChanged) || !$this->appManager->isEnabledForUser(self::TWOFACTOR_APP_ID) || !$this->ssoService->shouldSync2FA()) { return; } @@ -41,15 +41,14 @@ class TwoFactorStateChangedListener implements IEventListener { // When state change event is fired by user disabling 2FA, delete existing 2FA credentials and return // i.e. disable 2FA for user at SSO if (!$event->isEnabled()) { - $this->ssoMapper->deleteCredentials($username); + $this->ssoService->deleteCredentials($username); return; } $secret = $this->twoFactorMapper->getSecret($username); - $this->ssoMapper->migrateCredential($username, $secret); + $this->ssoService->migrateCredential($username, $secret); } catch (Exception $e) { - $stateText = $event->isEnabled() ? 'new secret enabled' : 'disabled'; - $this->logger->error('Error updating secret state(' . $stateText .') for user: ' . $username . ': ' . $e->getMessage()); + $this->logger->logException($e, ['app' => Application::APP_ID]); } } } diff --git a/lib/Service/CurlService.php b/lib/Service/CurlService.php index 9cf95068e15081df7b8bbf374cdd6844ff1c3ccd..a201e51b2afc55a10782b96029f5f4e5c883163e 100644 --- a/lib/Service/CurlService.php +++ b/lib/Service/CurlService.php @@ -46,6 +46,10 @@ class CurlService { return $this->request('DELETE', $url, $params, $headers, $userOptions); } + public function put($url, $params = [], $headers = [], $userOptions = []) { + return $this->request('PUT', $url, $params, $headers, $userOptions); + } + /** * @return int */ @@ -54,6 +58,24 @@ class CurlService { return $this->lastStatusCode; } + private function buildPostData($params = [], $headers = []) { + $jsonContent = in_array('Content-Type: application/json', $headers); + if ($jsonContent) { + $params = json_encode($params); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('JSON encoding failed: ' . json_last_error_msg()); + } + return $params; + } + + $formContent = in_array('Content-Type: application/x-www-form-urlencoded', $headers); + if ($formContent) { + $params = http_build_query($params); + return $params; + } + + return $params; + } /** * Curl run request @@ -73,7 +95,7 @@ class CurlService { CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers ); - array_merge($options, $userOptions); + $options = array_merge($options, $userOptions); switch ($method) { case 'GET': if ($params) { @@ -82,14 +104,7 @@ class CurlService { break; case 'POST': $options[CURLOPT_POST] = true; - $jsonContent = in_array('Content-Type: application/json', $headers); - if ($jsonContent) { - $params = json_encode($params); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception('JSON encoding failed: ' . json_last_error_msg()); - } - } - $options[CURLOPT_POSTFIELDS] = $params; + $options[CURLOPT_POSTFIELDS] = $this->buildPostData($params, $headers); break; case 'DELETE': $options[CURLOPT_CUSTOMREQUEST] = "DELETE"; @@ -97,8 +112,12 @@ class CurlService { $url = $url . '?' . http_build_query($params); } break; + case 'PUT': + $options[CURLOPT_CUSTOMREQUEST] = "PUT"; + $options[CURLOPT_POSTFIELDS] = $this->buildPostData($params, $headers); + break; default: - throw new Exception('Unsuported method.'); + throw new Exception('Unsupported method.'); break; } $options[CURLOPT_URL] = $url; diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php new file mode 100644 index 0000000000000000000000000000000000000000..b176b59db6f0515c782c6808cb2a6a0e4c6fcb14 --- /dev/null +++ b/lib/Service/SSOService.php @@ -0,0 +1,216 @@ +appName = $appName; + $this->config = $config; + + $ssoProviderUrl = $this->config->getSystemValue('oidc_login_provider_url', ''); + $ssoProviderUrlParts = explode('/auth', $ssoProviderUrl); + $rootUrl = $ssoProviderUrlParts[0]; + $realmsPart = $ssoProviderUrlParts[1]; + + $this->ssoConfig['admin_client_id'] = $this->config->getSystemValue('oidc_admin_client_id', ''); + $this->ssoConfig['admin_client_secret'] = $this->config->getSystemValue('oidc_admin_client_secret', ''); + $this->ssoConfig['admin_username'] = $this->config->getSystemValue('oidc_admin_username', ''); + $this->ssoConfig['admin_password'] = $this->config->getSystemValue('oidc_admin_password', ''); + $this->ssoConfig['admin_rest_api_url'] = $rootUrl . '/auth/admin' . $realmsPart; + $this->ssoConfig['root_url'] = $rootUrl; + $this->crypto = $crypto; + $this->curl = $curlService; + $this->logger = $logger; + $this->l10nFactory = $l10nFactory; + } + + public function shouldSync2FA() : bool { + return $this->config->getSystemValue('oidc_admin_sync_2fa', false); + } + + public function migrateCredential(string $username, string $secret) : void { + if(empty($this->currentUserId)) { + $this->getUserId($username); + } + $this->deleteCredentials($username); + + $decryptedSecret = $this->crypto->decrypt($secret); + $language = $this->config->getUserValue($username, 'core', 'lang', 'en'); + $credentialEntry = $this->getCredentialEntry($decryptedSecret, $language); + $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId; + + $data = [ + 'credentials' => [$credentialEntry] + ]; + + $this->logger->debug('migrateCredential calling SSO API with url: '. $url . ' and data: ' . print_r($data, true)); + $this->callSSOAPI($url, 'PUT', $data, 204); + } + + public function deleteCredentials(string $username) : void { + if(empty($this->currentUserId)) { + $this->getUserId($username); + } + $credentialIds = $this->getCredentialIds(); + + foreach ($credentialIds as $credentialId) { + $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; + $url = str_replace('{USER_ID}', $this->currentUserId, $url); + $url .= '/' . $credentialId; + $this->logger->debug('deleteCredentials calling SSO API with url: '. $url); + $this->callSSOAPI($url, 'DELETE', [], 204); + } + } + + private function getCredentialIds() : array { + $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; + $url = str_replace('{USER_ID}', $this->currentUserId, $url); + $this->logger->debug('getCredentialIds calling SSO API with url: '. $url); + + $credentials = $this->callSSOAPI($url, 'GET'); + + if (empty($credentials) || !is_array($credentials)) { + return []; + } + + $credentials = array_filter($credentials, function ($credential) { + if ($credential['type'] !== 'otp' || !isset($credential['credentialData'])) { + return false; + } + $credentialData = json_decode($credential['credentialData'], true); + if (!isset($credentialData['subType']) || !isset($credentialData['subType']) + || $credentialData['subType'] !== 'totp' || $credentialData['secretEncoding'] !== 'BASE32') { + return false; + } + return true; + }); + + return array_map(function ($credential) { + return $credential['id']; + }, $credentials); + } + + /** + * Create secret entry compatible with Keycloak schema + * + * @return array + */ + + private function getCredentialEntry(string $secret, string $language) : array { + $l10n = $this->l10nFactory->get(Application::APP_ID, $language); + $userLabel = $l10n->t('Murena Cloud 2FA'); + + // We build manually instead of json_encode to avoid any tricky escaping issues as this is a nested json object + $secretData = '{"value":"' . $secret . '"}'; + $credentialData = '{"subType":"totp","period":30,"digits":6,"algorithm":"HmacSHA1","secretEncoding":"BASE32"}'; + $credentialEntry = [ + 'userLabel' => $userLabel, + 'type' => 'otp', + 'secretData' => $secretData, + 'credentialData' => $credentialData + ]; + return $credentialEntry; + } + + private function getUserId(string $username) : void { + $usernameWithoutDomain = explode('@', $username)[0]; + $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '?exact=true&username=' . $usernameWithoutDomain; + $this->logger->debug('getUserId calling SSO API with url: '. $url); + $users = $this->callSSOAPI($url, 'GET'); + if (empty($users) || !is_array($users) || !isset($users[0])) { + throw new SSOAdminAPIException('Error: no user found for search with url: ' . $url); + } + $this->currentUserId = $users[0]['id']; + } + + private function getAdminAccessToken() : void { + if (!empty($this->adminAccessToken)) { + return; + } + $adminAccessTokenRoute = $this->ssoConfig['root_url'] . self::ADMIN_TOKEN_ENDPOINT; + $requestBody = [ + 'username' => $this->ssoConfig['admin_username'], + 'password' => $this->ssoConfig['admin_password'], + 'client_id' => $this->ssoConfig['admin_client_id'], + 'client_secret' => $this->ssoConfig['admin_client_secret'], + 'grant_type' => 'password' + ]; + + $headers = [ + 'Content-Type: application/x-www-form-urlencoded' + ]; + $this->logger->debug('getAdminAccessToken calling SSO API with url: '. $adminAccessTokenRoute . ' and headers: ' . print_r($headers, true) . ' and body: ' . print_r($requestBody, true)); + $response = $this->curl->post($adminAccessTokenRoute, $requestBody, $headers); + + if ($this->curl->getLastStatusCode() !== 200) { + $statusCode = strval($this->curl->getLastStatusCode()); + throw new SSOAdminAccessTokenException('Error getting Admin Access token. Status Code: ' . $statusCode); + } + $response = json_decode($response, true); + + if (!isset($response['access_token'])) { + throw new SSOAdminAccessTokenException('Error: admin access token not set in response!'); + } + $this->adminAccessToken = $response['access_token']; + } + + private function callSSOAPI(string $url, string $method, array $data = [], int $expectedStatusCode = 200) :?array { + if (empty($url)) { + return null; + } + $this->getAdminAccessToken(); + $headers = [ + "cache-control: no-cache", + "Content-Type: application/json", + "Authorization: Bearer " . $this->adminAccessToken + ]; + + if ($method === 'GET') { + $answer = $this->curl->get($url, $data, $headers); + } + + if ($method === 'DELETE') { + $answer = $this->curl->delete($url, $data, $headers); + } + + if ($method === 'POST') { + $answer = $this->curl->post($url, $data, $headers); + } + + if ($method === 'PUT') { + $answer = $this->curl->put($url, $data, $headers); + } + + $statusCode = $this->curl->getLastStatusCode(); + + if ($statusCode !== $expectedStatusCode) { + throw new SSOAdminAPIException('Error calling SSO API with url ' . $url . ' status code: ' . $statusCode); + } + + $answer = json_decode($answer, true); + return $answer; + } +}