Loading .gitlab-ci.yml +46 −1 Original line number Diff line number Diff line variables: CONTAINER_IMAGE: ubuntu CONTAINER_TAG: focal CONTAINER_NAME: nextcloud APP_NAME: $CI_PROJECT_NAME APP_ENABLE_ARGS: '' TO_PACKAGE: 'appinfo l10n lib templates js img' include: - project: "e/infra/ecloud/nextcloud-apps/ci-templates" Loading @@ -7,3 +12,43 @@ include: - project: "e/infra/ecloud/nextcloud-apps/ci-templates" ref: main file: "nc-apps-deploy.yml" .deploy:nextcloud-app: stage: deploy # assuming all deployment will happen with sames image image: $CONTAINER_IMAGE:$CONTAINER_TAG # assuming we will need to add SSH for all deployment before_script: - echo "FAIL" > .job_status - mkdir $HOME/.ssh - chmod 700 ~/.ssh - echo "$SSH_PRIVATE_KEY_ED" > $HOME/.ssh/id_ed25519 - echo "$SSH_PUBKEY_ED" > $HOME/.ssh/id_ed25519.pub - echo "$SSH_KNOWN_HOSTS" > $HOME/.ssh/known_hosts - chmod 600 ~/.ssh/id_ed25519 - chmod 644 ~/.ssh/known_hosts ~/.ssh/id_ed25519.pub - apt-get update && apt-get install -y openssh-client rsync script: - echo "Deploying ${APP_NAME} to $CI_ENVIRONMENT_NAME ($DEPLOYMENT_HOST)" - rsync -avzh dist/ $SSH_USER@$DEPLOYMENT_HOST:/tmp/${CI_JOB_ID} - ssh $SSH_USER@$DEPLOYMENT_HOST "sudo docker exec -u www-data $CONTAINER_NAME /usr/local/bin/php /var/www/html/occ app:disable ${APP_NAME} && sudo rsync -avzh --chown www-data:www-data --delete /tmp/${CI_JOB_ID}/${APP_NAME} ${DEPLOYMENT_PATH}/html/custom_apps/ && sudo docker exec -u www-data $CONTAINER_NAME /usr/local/bin/php /var/www/html/occ app:enable ${APP_ENABLE_ARGS} ${APP_NAME}" - echo "SUCCESS" > .job_status after_script: # reading job status, checking it and implementing additional steps # are not handled here as rm -rf /tmp/${CI_JOB_ID} will always execute - ssh $SSH_USER@$DEPLOYMENT_HOST "rm -rf /tmp/${CI_JOB_ID}" deploy:staging: extends: .deploy:nextcloud-app when: manual only: - main - murena-main - production - dev/sync-new-secrets - tags environment: name: staging/01 url: https://eeo.one lib/AppInfo/Application.php +7 −1 Original line number Diff line number Diff line Loading @@ -37,6 +37,9 @@ use OCP\User\Events\UserChangedEvent; use OCA\EcloudAccounts\Listeners\UserChangedListener; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCA\EcloudAccounts\Listeners\BeforeTemplateRenderedListener; use OCA\EcloudAccounts\Listeners\TwoFactorStateChangedListener; use OCA\TwoFactorTOTP\Event\StateChanged; use OCP\IUserManager; class Application extends App implements IBootstrap { public const APP_ID = 'ecloud-accounts'; Loading @@ -48,13 +51,16 @@ class Application extends App implements IBootstrap { public function register(IRegistrationContext $context): void { $context->registerEventListener(BeforeUserDeletedEvent::class, BeforeUserDeletedListener::class); $context->registerEventListener(UserChangedEvent::class, UserChangedListener::class); $context->registerEventListener(StateChanged::class, TwoFactorStateChangedListener::class); // $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); } public function boot(IBootContext $context): void { $serverContainer = $context->getServerContainer(); $serverContainer->registerService('LDAPConnectionService', function ($c) { return new LDAPConnectionService(); return new LDAPConnectionService( $c->get(IUserManager::class) ); }); } } lib/Command/Migrate2FASecrets.php +17 −182 Original line number Diff line number Diff line Loading @@ -8,39 +8,17 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use OCP\Security\ICrypto; use OCP\IDBConnection; use OCP\IUserManager; use OCP\IUser; use OCP\IConfig; use OCA\EcloudAccounts\Db\SSOMapper; use OCA\EcloudAccounts\Exception\DbConnectionParamsException; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Connection; use OCA\EcloudAccounts\Db\TwoFactorMapper; class Migrate2FASecrets extends Command { private SSOMapper $ssoMapper; private IDBConnection $dbConn; private Connection $ssoDbConn; private ICrypto $crypto; private IUserManager $userManager; private TwoFactorMapper $twoFactorMapper; private OutputInterface $commandOutput; private IConfig $config; private const TOTP_SECRET_TABLE = 'twofactor_totp_secrets'; private const USER_LABELS = [ 'en' => 'Murena Cloud 2FA', 'es' => 'Murena Cloud 2FA', 'de' => 'Murena Cloud 2FA', 'it' => 'Murena Cloud 2FA', 'fr' => 'Murena Cloud 2FA', ]; public function __construct(IDBConnection $dbConn, ICrypto $crypto, SSOMapper $ssoMapper, IUserManager $userManager, IConfig $config) { public function __construct(SSOMapper $ssoMapper, TwoFactorMapper $twoFactorMapper) { $this->ssoMapper = $ssoMapper; $this->userManager = $userManager; $this->dbConn = $dbConn; $this->crypto = $crypto; $this->config = $config; $this->twoFactorMapper = $twoFactorMapper; parent::__construct(); } Loading @@ -54,50 +32,12 @@ class Migrate2FASecrets extends Command { InputOption::VALUE_OPTIONAL, 'comma separated list of users', '' ) ->addOption( 'sso-db-name', null, InputOption::VALUE_REQUIRED, 'SSO database name', ) ->addOption( 'sso-db-user', null, InputOption::VALUE_REQUIRED, 'SSO database user', ) ->addOption( 'sso-db-password', null, InputOption::VALUE_REQUIRED, 'SSO database password', ) ->addOption( 'sso-db-host', null, InputOption::VALUE_REQUIRED, 'SSO database host', ) ->addOption( 'sso-db-port', null, InputOption::VALUE_REQUIRED, 'SSO database port', 3306 ); } protected function execute(InputInterface $input, OutputInterface $output): int { try { $this->commandOutput = $output; $dbName = $input->getOption('sso-db-name'); $dbHost = $input->getOption('sso-db-host'); $dbPort = $input->getOption('sso-db-port'); $dbPassword = $input->getOption('sso-db-password'); $dbUser = $input->getOption('sso-db-user'); $this->ssoDbConn = $this->getDatabaseConnection($dbName, $dbHost, $dbPort, $dbPassword, $dbUser); $usernames = []; $usernameList = $input->getOption('users'); if (!empty($usernameList)) { Loading @@ -117,119 +57,14 @@ class Migrate2FASecrets extends Command { * @return void */ private function migrateUsers(array $usernames = []) : void { $entries = []; $qb = $this->dbConn->getQueryBuilder(); $qb->select('user_id', 'secret') ->from(self::TOTP_SECRET_TABLE); if (!empty($usernames)) { $qb->where('user_id IN (:usernames)') ->setParameter('usernames', implode(',', $usernames)); } $result = $qb->execute(); while ($row = $result->fetch()) { $entries = $this->twoFactorMapper->getEntries($usernames); foreach ($entries as $entry) { try { $username = (string) $row['user_id']; if (!$this->userManager->get($username) instanceof IUser) { throw new \Exception('No user found in nextcloud with given username'); } $secret = (string) $row['secret']; $decryptedSecret = $this->crypto->decrypt($secret); $ssoUserId = $this->ssoMapper->getUserId($username, $this->ssoDbConn); if (empty($ssoUserId)) { throw new \Exception('Does not exist in SSO database'); } $language = $this->config->getUserValue($uid, 'core', 'lang', 'en'); if (!array_key_exists($language, self::USER_LABELS)) { $language = 'en'; } $entry = $this->getSSOSecretEntry($decryptedSecret, $ssoUserId, $language); $this->ssoMapper->insertCredential($entry, $this->ssoDbConn); $this->ssoMapper->migrateCredential($entry['username'], $entry['secret']); } catch(\Exception $e) { $this->commandOutput->writeln('Error inserting entry for user ' . $username . ' message: ' . $e->getMessage()); $this->commandOutput->writeln('Error inserting entry for user ' . $entry['username'] . ' message: ' . $e->getMessage()); continue; } } } /** * Create secret entry compatible with Keycloak schema * * @return array */ private function getSSOSecretEntry(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)); $userLabel = self::USER_LABELS[$language]; $credentialEntry = [ 'ID' => $id, 'USER_ID' => $ssoUserId, 'USER_LABEL' => 'Murena Cloud 2FA', 'TYPE' => 'otp', 'SECRET_DATA' => json_encode([ 'value' => $secret ]), 'CREDENTIAL_DATA' => json_encode([ 'subType' => 'nextcloud_totp', 'period' => 30, 'digits' => 6, 'algorithm' => 'HmacSHA1', ]), ]; foreach ($credentialEntry as $key => &$value) { $value = "'" . $value . "'"; } $credentialEntry['CREATED_DATE'] = round(microtime(true) * 1000); $credentialEntry['PRIORITY'] = 10; return $credentialEntry; } /** * Attempt to connect to a non-NC database * * @return Connection */ private function getDatabaseConnection(string $dbName, string $dbHost, int $dbPort, string $dbPassword, string $dbUser) : Connection { if (empty($dbName) || empty($dbHost) || empty($dbPort) || empty($dbPassword) || empty($dbUser)) { throw new DbConnectionParamsException('Invalid database parameters!'); } $params = [ 'dbname' => $dbName, 'user' => $dbUser, 'password' => $dbPassword, 'host' => $dbHost, 'port' => $dbPort, 'driver' => 'pdo_mysql' ]; return DriverManager::getConnection($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)); } } lib/Db/SSOMapper.php +160 −7 Original line number Diff line number Diff line Loading @@ -4,21 +4,42 @@ namespace OCA\EcloudAccounts\Db; use OCP\IConfig; use OCP\ILogger; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Connection; use OCP\IUserManager; use OCP\Security\ICrypto; use OCP\IUser; use OCA\EcloudAccounts\Exception\DbConnectionParamsException; class SSOMapper { private $config; private $logger; private IConfig $config; private ILogger $logger; private Connection $conn; private IUserManager $userManager; private ICrypto $crypto; private const USER_ATTRIBUTE_TABLE = 'USER_ATTRIBUTE'; private const CREDENTIAL_TABLE = 'CREDENTIAL'; public function __construct(IConfig $config, ILogger $logger) { private const SSO_CONFIG_KEY = 'keycloak'; private const USER_LABELS = [ 'en' => 'Murena Cloud 2FA', 'es' => 'Murena Cloud 2FA', 'de' => 'Murena Cloud 2FA', 'it' => 'Murena Cloud 2FA', 'fr' => 'Murena Cloud 2FA', ]; public function __construct(IConfig $config, IUserManager $userManager, ILogger $logger, ICrypto $crypto) { $this->config = $config; $this->logger = $logger; $this->userManager = $userManager; $this->crypto = $crypto; $this->initConnection(); } public function getUserId(string $username, Connection $conn) : string { $qb = $conn->createQueryBuilder(); public function getUserId(string $username) : string { $qb = $this->conn->createQueryBuilder(); $qb->select('USER_ID') ->from(self::USER_ATTRIBUTE_TABLE) ->where('NAME = "LDAP_ID"') Loading @@ -29,10 +50,142 @@ class SSOMapper { return (string) $result->fetchOne(); } public function insertCredential(array $entry, Connection $conn) { $qb = $conn->createQueryBuilder(); public function deleteCredential(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\":\"nextcloud_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'); if (!array_key_exists($language, self::USER_LABELS)) { $language = 'en'; } // Only one "nextcloud_totp" at a time $this->deleteCredential($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)); $userLabel = self::USER_LABELS[$language]; $credentialEntry = [ 'ID' => $id, 'USER_ID' => $ssoUserId, 'USER_LABEL' => $userLabel, 'TYPE' => 'otp', 'SECRET_DATA' => json_encode([ 'value' => $secret ]), 'CREDENTIAL_DATA' => json_encode([ 'subType' => 'nextcloud_totp', 'period' => 30, 'digits' => 6, 'algorithm' => 'HmacSHA1', ]), ]; 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)); } } lib/Db/TwoFactorMapper.php 0 → 100644 +48 −0 Original line number Diff line number Diff line <?php namespace OCA\EcloudAccounts\Db; use OCP\IDBConnection; class TwoFactorMapper { private IDBConnection $conn; private const TOTP_SECRET_TABLE = 'twofactor_totp_secrets'; public function __construct(IDBConnection $conn) { $this->conn = $conn; } public function getEntries(array $usernames = []) : array { $entries = []; $qb = $this->conn->getQueryBuilder(); $qb->select('user_id', 'secret') ->from(self::TOTP_SECRET_TABLE); if (!empty($usernames)) { $qb->where('user_id IN (:usernames)') ->setParameter('usernames', implode(',', $usernames)); } $result = $qb->execute(); while ($row = $result->fetch()) { $entry = [ 'username' => (string) $row['user_id'], 'secret' => (string) $row['secret'] ]; $entries[] = $entry; } return $entries; } public function getSecret(string $username) : string { $qb = $this->conn->getQueryBuilder(); $qb->select('secret') ->from(self::TOTP_SECRET_TABLE) ->where('user_id = :username') ->setParameter('username', $username); $result = $qb->execute(); return (string) $result->fetchOne(); } } Loading
.gitlab-ci.yml +46 −1 Original line number Diff line number Diff line variables: CONTAINER_IMAGE: ubuntu CONTAINER_TAG: focal CONTAINER_NAME: nextcloud APP_NAME: $CI_PROJECT_NAME APP_ENABLE_ARGS: '' TO_PACKAGE: 'appinfo l10n lib templates js img' include: - project: "e/infra/ecloud/nextcloud-apps/ci-templates" Loading @@ -7,3 +12,43 @@ include: - project: "e/infra/ecloud/nextcloud-apps/ci-templates" ref: main file: "nc-apps-deploy.yml" .deploy:nextcloud-app: stage: deploy # assuming all deployment will happen with sames image image: $CONTAINER_IMAGE:$CONTAINER_TAG # assuming we will need to add SSH for all deployment before_script: - echo "FAIL" > .job_status - mkdir $HOME/.ssh - chmod 700 ~/.ssh - echo "$SSH_PRIVATE_KEY_ED" > $HOME/.ssh/id_ed25519 - echo "$SSH_PUBKEY_ED" > $HOME/.ssh/id_ed25519.pub - echo "$SSH_KNOWN_HOSTS" > $HOME/.ssh/known_hosts - chmod 600 ~/.ssh/id_ed25519 - chmod 644 ~/.ssh/known_hosts ~/.ssh/id_ed25519.pub - apt-get update && apt-get install -y openssh-client rsync script: - echo "Deploying ${APP_NAME} to $CI_ENVIRONMENT_NAME ($DEPLOYMENT_HOST)" - rsync -avzh dist/ $SSH_USER@$DEPLOYMENT_HOST:/tmp/${CI_JOB_ID} - ssh $SSH_USER@$DEPLOYMENT_HOST "sudo docker exec -u www-data $CONTAINER_NAME /usr/local/bin/php /var/www/html/occ app:disable ${APP_NAME} && sudo rsync -avzh --chown www-data:www-data --delete /tmp/${CI_JOB_ID}/${APP_NAME} ${DEPLOYMENT_PATH}/html/custom_apps/ && sudo docker exec -u www-data $CONTAINER_NAME /usr/local/bin/php /var/www/html/occ app:enable ${APP_ENABLE_ARGS} ${APP_NAME}" - echo "SUCCESS" > .job_status after_script: # reading job status, checking it and implementing additional steps # are not handled here as rm -rf /tmp/${CI_JOB_ID} will always execute - ssh $SSH_USER@$DEPLOYMENT_HOST "rm -rf /tmp/${CI_JOB_ID}" deploy:staging: extends: .deploy:nextcloud-app when: manual only: - main - murena-main - production - dev/sync-new-secrets - tags environment: name: staging/01 url: https://eeo.one
lib/AppInfo/Application.php +7 −1 Original line number Diff line number Diff line Loading @@ -37,6 +37,9 @@ use OCP\User\Events\UserChangedEvent; use OCA\EcloudAccounts\Listeners\UserChangedListener; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCA\EcloudAccounts\Listeners\BeforeTemplateRenderedListener; use OCA\EcloudAccounts\Listeners\TwoFactorStateChangedListener; use OCA\TwoFactorTOTP\Event\StateChanged; use OCP\IUserManager; class Application extends App implements IBootstrap { public const APP_ID = 'ecloud-accounts'; Loading @@ -48,13 +51,16 @@ class Application extends App implements IBootstrap { public function register(IRegistrationContext $context): void { $context->registerEventListener(BeforeUserDeletedEvent::class, BeforeUserDeletedListener::class); $context->registerEventListener(UserChangedEvent::class, UserChangedListener::class); $context->registerEventListener(StateChanged::class, TwoFactorStateChangedListener::class); // $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); } public function boot(IBootContext $context): void { $serverContainer = $context->getServerContainer(); $serverContainer->registerService('LDAPConnectionService', function ($c) { return new LDAPConnectionService(); return new LDAPConnectionService( $c->get(IUserManager::class) ); }); } }
lib/Command/Migrate2FASecrets.php +17 −182 Original line number Diff line number Diff line Loading @@ -8,39 +8,17 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use OCP\Security\ICrypto; use OCP\IDBConnection; use OCP\IUserManager; use OCP\IUser; use OCP\IConfig; use OCA\EcloudAccounts\Db\SSOMapper; use OCA\EcloudAccounts\Exception\DbConnectionParamsException; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Connection; use OCA\EcloudAccounts\Db\TwoFactorMapper; class Migrate2FASecrets extends Command { private SSOMapper $ssoMapper; private IDBConnection $dbConn; private Connection $ssoDbConn; private ICrypto $crypto; private IUserManager $userManager; private TwoFactorMapper $twoFactorMapper; private OutputInterface $commandOutput; private IConfig $config; private const TOTP_SECRET_TABLE = 'twofactor_totp_secrets'; private const USER_LABELS = [ 'en' => 'Murena Cloud 2FA', 'es' => 'Murena Cloud 2FA', 'de' => 'Murena Cloud 2FA', 'it' => 'Murena Cloud 2FA', 'fr' => 'Murena Cloud 2FA', ]; public function __construct(IDBConnection $dbConn, ICrypto $crypto, SSOMapper $ssoMapper, IUserManager $userManager, IConfig $config) { public function __construct(SSOMapper $ssoMapper, TwoFactorMapper $twoFactorMapper) { $this->ssoMapper = $ssoMapper; $this->userManager = $userManager; $this->dbConn = $dbConn; $this->crypto = $crypto; $this->config = $config; $this->twoFactorMapper = $twoFactorMapper; parent::__construct(); } Loading @@ -54,50 +32,12 @@ class Migrate2FASecrets extends Command { InputOption::VALUE_OPTIONAL, 'comma separated list of users', '' ) ->addOption( 'sso-db-name', null, InputOption::VALUE_REQUIRED, 'SSO database name', ) ->addOption( 'sso-db-user', null, InputOption::VALUE_REQUIRED, 'SSO database user', ) ->addOption( 'sso-db-password', null, InputOption::VALUE_REQUIRED, 'SSO database password', ) ->addOption( 'sso-db-host', null, InputOption::VALUE_REQUIRED, 'SSO database host', ) ->addOption( 'sso-db-port', null, InputOption::VALUE_REQUIRED, 'SSO database port', 3306 ); } protected function execute(InputInterface $input, OutputInterface $output): int { try { $this->commandOutput = $output; $dbName = $input->getOption('sso-db-name'); $dbHost = $input->getOption('sso-db-host'); $dbPort = $input->getOption('sso-db-port'); $dbPassword = $input->getOption('sso-db-password'); $dbUser = $input->getOption('sso-db-user'); $this->ssoDbConn = $this->getDatabaseConnection($dbName, $dbHost, $dbPort, $dbPassword, $dbUser); $usernames = []; $usernameList = $input->getOption('users'); if (!empty($usernameList)) { Loading @@ -117,119 +57,14 @@ class Migrate2FASecrets extends Command { * @return void */ private function migrateUsers(array $usernames = []) : void { $entries = []; $qb = $this->dbConn->getQueryBuilder(); $qb->select('user_id', 'secret') ->from(self::TOTP_SECRET_TABLE); if (!empty($usernames)) { $qb->where('user_id IN (:usernames)') ->setParameter('usernames', implode(',', $usernames)); } $result = $qb->execute(); while ($row = $result->fetch()) { $entries = $this->twoFactorMapper->getEntries($usernames); foreach ($entries as $entry) { try { $username = (string) $row['user_id']; if (!$this->userManager->get($username) instanceof IUser) { throw new \Exception('No user found in nextcloud with given username'); } $secret = (string) $row['secret']; $decryptedSecret = $this->crypto->decrypt($secret); $ssoUserId = $this->ssoMapper->getUserId($username, $this->ssoDbConn); if (empty($ssoUserId)) { throw new \Exception('Does not exist in SSO database'); } $language = $this->config->getUserValue($uid, 'core', 'lang', 'en'); if (!array_key_exists($language, self::USER_LABELS)) { $language = 'en'; } $entry = $this->getSSOSecretEntry($decryptedSecret, $ssoUserId, $language); $this->ssoMapper->insertCredential($entry, $this->ssoDbConn); $this->ssoMapper->migrateCredential($entry['username'], $entry['secret']); } catch(\Exception $e) { $this->commandOutput->writeln('Error inserting entry for user ' . $username . ' message: ' . $e->getMessage()); $this->commandOutput->writeln('Error inserting entry for user ' . $entry['username'] . ' message: ' . $e->getMessage()); continue; } } } /** * Create secret entry compatible with Keycloak schema * * @return array */ private function getSSOSecretEntry(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)); $userLabel = self::USER_LABELS[$language]; $credentialEntry = [ 'ID' => $id, 'USER_ID' => $ssoUserId, 'USER_LABEL' => 'Murena Cloud 2FA', 'TYPE' => 'otp', 'SECRET_DATA' => json_encode([ 'value' => $secret ]), 'CREDENTIAL_DATA' => json_encode([ 'subType' => 'nextcloud_totp', 'period' => 30, 'digits' => 6, 'algorithm' => 'HmacSHA1', ]), ]; foreach ($credentialEntry as $key => &$value) { $value = "'" . $value . "'"; } $credentialEntry['CREATED_DATE'] = round(microtime(true) * 1000); $credentialEntry['PRIORITY'] = 10; return $credentialEntry; } /** * Attempt to connect to a non-NC database * * @return Connection */ private function getDatabaseConnection(string $dbName, string $dbHost, int $dbPort, string $dbPassword, string $dbUser) : Connection { if (empty($dbName) || empty($dbHost) || empty($dbPort) || empty($dbPassword) || empty($dbUser)) { throw new DbConnectionParamsException('Invalid database parameters!'); } $params = [ 'dbname' => $dbName, 'user' => $dbUser, 'password' => $dbPassword, 'host' => $dbHost, 'port' => $dbPort, 'driver' => 'pdo_mysql' ]; return DriverManager::getConnection($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)); } }
lib/Db/SSOMapper.php +160 −7 Original line number Diff line number Diff line Loading @@ -4,21 +4,42 @@ namespace OCA\EcloudAccounts\Db; use OCP\IConfig; use OCP\ILogger; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Connection; use OCP\IUserManager; use OCP\Security\ICrypto; use OCP\IUser; use OCA\EcloudAccounts\Exception\DbConnectionParamsException; class SSOMapper { private $config; private $logger; private IConfig $config; private ILogger $logger; private Connection $conn; private IUserManager $userManager; private ICrypto $crypto; private const USER_ATTRIBUTE_TABLE = 'USER_ATTRIBUTE'; private const CREDENTIAL_TABLE = 'CREDENTIAL'; public function __construct(IConfig $config, ILogger $logger) { private const SSO_CONFIG_KEY = 'keycloak'; private const USER_LABELS = [ 'en' => 'Murena Cloud 2FA', 'es' => 'Murena Cloud 2FA', 'de' => 'Murena Cloud 2FA', 'it' => 'Murena Cloud 2FA', 'fr' => 'Murena Cloud 2FA', ]; public function __construct(IConfig $config, IUserManager $userManager, ILogger $logger, ICrypto $crypto) { $this->config = $config; $this->logger = $logger; $this->userManager = $userManager; $this->crypto = $crypto; $this->initConnection(); } public function getUserId(string $username, Connection $conn) : string { $qb = $conn->createQueryBuilder(); public function getUserId(string $username) : string { $qb = $this->conn->createQueryBuilder(); $qb->select('USER_ID') ->from(self::USER_ATTRIBUTE_TABLE) ->where('NAME = "LDAP_ID"') Loading @@ -29,10 +50,142 @@ class SSOMapper { return (string) $result->fetchOne(); } public function insertCredential(array $entry, Connection $conn) { $qb = $conn->createQueryBuilder(); public function deleteCredential(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\":\"nextcloud_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'); if (!array_key_exists($language, self::USER_LABELS)) { $language = 'en'; } // Only one "nextcloud_totp" at a time $this->deleteCredential($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)); $userLabel = self::USER_LABELS[$language]; $credentialEntry = [ 'ID' => $id, 'USER_ID' => $ssoUserId, 'USER_LABEL' => $userLabel, 'TYPE' => 'otp', 'SECRET_DATA' => json_encode([ 'value' => $secret ]), 'CREDENTIAL_DATA' => json_encode([ 'subType' => 'nextcloud_totp', 'period' => 30, 'digits' => 6, 'algorithm' => 'HmacSHA1', ]), ]; 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)); } }
lib/Db/TwoFactorMapper.php 0 → 100644 +48 −0 Original line number Diff line number Diff line <?php namespace OCA\EcloudAccounts\Db; use OCP\IDBConnection; class TwoFactorMapper { private IDBConnection $conn; private const TOTP_SECRET_TABLE = 'twofactor_totp_secrets'; public function __construct(IDBConnection $conn) { $this->conn = $conn; } public function getEntries(array $usernames = []) : array { $entries = []; $qb = $this->conn->getQueryBuilder(); $qb->select('user_id', 'secret') ->from(self::TOTP_SECRET_TABLE); if (!empty($usernames)) { $qb->where('user_id IN (:usernames)') ->setParameter('usernames', implode(',', $usernames)); } $result = $qb->execute(); while ($row = $result->fetch()) { $entry = [ 'username' => (string) $row['user_id'], 'secret' => (string) $row['secret'] ]; $entries[] = $entry; } return $entries; } public function getSecret(string $username) : string { $qb = $this->conn->getQueryBuilder(); $qb->select('secret') ->from(self::TOTP_SECRET_TABLE) ->where('user_id = :username') ->setParameter('username', $username); $result = $qb->execute(); return (string) $result->fetchOne(); } }