diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6119917f226a5a16fd4103165beb5861724de6a1..b6233d122cd69fbc06ba750da296845366694c19 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,10 @@ variables: - TO_PACKAGE: 'appinfo l10n lib templates js img' + 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" ref: main @@ -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 diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3574f538feb8edcd24943bce57dfec8f9d867f8c..47cc7b6b743c69af62ee461abc476494fb2a99a0 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -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'; @@ -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) + ); }); } } diff --git a/lib/Command/Migrate2FASecrets.php b/lib/Command/Migrate2FASecrets.php index 8155d7b1f43040539666e99180b5b182dd313e82..13bddf3618e47057f81a3527512d2e4eaee2d9e1 100644 --- a/lib/Command/Migrate2FASecrets.php +++ b/lib/Command/Migrate2FASecrets.php @@ -8,96 +8,36 @@ 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(); } protected function configure(): void { $this - ->setName('ecloud-accounts:migrate-2fa-secrets') - ->setDescription('Migrates 2FA secrets to SSO database') - ->addOption( - 'users', - null, - 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 - ); + ->setName('ecloud-accounts:migrate-2fa-secrets') + ->setDescription('Migrates 2FA secrets to SSO database') + ->addOption( + 'users', + null, + InputOption::VALUE_OPTIONAL, + 'comma separated list of users', + '' + ); } 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)) { @@ -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)); - } } diff --git a/lib/Db/SSOMapper.php b/lib/Db/SSOMapper.php index 1e711eda0233b133269d3553395d691b5df12001..d172ddda84849adfe4ccd4b771cbc2440720dc65 100644 --- a/lib/Db/SSOMapper.php +++ b/lib/Db/SSOMapper.php @@ -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"') @@ -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)); + } } diff --git a/lib/Db/TwoFactorMapper.php b/lib/Db/TwoFactorMapper.php new file mode 100644 index 0000000000000000000000000000000000000000..5b9739abf46135e44b5d6ff5ce32844ec2a3df07 --- /dev/null +++ b/lib/Db/TwoFactorMapper.php @@ -0,0 +1,48 @@ +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(); + } +} diff --git a/lib/Listeners/TwoFactorStateChangedListener.php b/lib/Listeners/TwoFactorStateChangedListener.php new file mode 100644 index 0000000000000000000000000000000000000000..860ad4b64eb8f411076dd09e724b3dae009cc573 --- /dev/null +++ b/lib/Listeners/TwoFactorStateChangedListener.php @@ -0,0 +1,54 @@ +appManager = $appManager; + $this->ssoMapper = $ssoMapper; + $this->twoFactorMapper = $twoFactorMapper; + $this->logger = $logger; + } + + + public function handle(Event $event): void { + if (!($event instanceof StateChanged) || !$this->appManager->isEnabledForUser(self::TWOFACTOR_APP_ID)) { + return; + } + + $user = $event->getUser(); + $username = $user->getUID(); + try { + // When state change event is fired by user disabling 2FA, delete existing credential and return + if (!$event->isEnabled()) { + $this->ssoMapper->deleteCredential($username); + return; + } + + $secret = $this->twoFactorMapper->getSecret($username); + $this->ssoMapper->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()); + } + } +}