Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit f460a3c0 authored by Akhil's avatar Akhil 🙂
Browse files

Merge branch 'dev/sync-new-secrets' into 'main'

Add event listener to sync 2fa state change

See merge request !60
parents 35b66632 a7f6af91
Loading
Loading
Loading
Loading
Loading
+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"
@@ -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
+7 −1
Original line number Diff line number Diff line
@@ -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)
			);
		});
	}
}
+17 −182
Original line number Diff line number Diff line
@@ -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();
	}

@@ -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)) {
@@ -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));
	}
}
+160 −7
Original line number Diff line number Diff line
@@ -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));
	}
}
+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