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;
+ }
+}