diff --git a/appinfo/info.xml b/appinfo/info.xml index 286552904fadca8a67774436e561d9842b702f67..def8000f254861c161abfd166c6495595c1d675b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -24,4 +24,7 @@ OCA\EcloudAccounts\Settings\BetaUserSetting OCA\EcloudAccounts\Settings\BetaSection + + OCA\EcloudAccounts\Command\Migrate2FASecrets + diff --git a/lib/Command/Migrate2FASecrets.php b/lib/Command/Migrate2FASecrets.php new file mode 100644 index 0000000000000000000000000000000000000000..8155d7b1f43040539666e99180b5b182dd313e82 --- /dev/null +++ b/lib/Command/Migrate2FASecrets.php @@ -0,0 +1,235 @@ + '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) { + $this->ssoMapper = $ssoMapper; + $this->userManager = $userManager; + $this->dbConn = $dbConn; + $this->crypto = $crypto; + $this->config = $config; + 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 + ); + } + + 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)) { + $usernames = explode(',', $usernameList); + } + $this->migrateUsers($usernames); + return 0; + } catch (\Exception $e) { + $this->commandOutput->writeln($e->getMessage()); + return 1; + } + } + + /** + * Migrate user secrets to the SSO database + * + * @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()) { + 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); + } catch(\Exception $e) { + $this->commandOutput->writeln('Error inserting entry for user ' . $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 new file mode 100644 index 0000000000000000000000000000000000000000..1e711eda0233b133269d3553395d691b5df12001 --- /dev/null +++ b/lib/Db/SSOMapper.php @@ -0,0 +1,38 @@ +config = $config; + $this->logger = $logger; + } + + public function getUserId(string $username, Connection $conn) : string { + $qb = $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 insertCredential(array $entry, Connection $conn) { + $qb = $conn->createQueryBuilder(); + $qb->insert(self::CREDENTIAL_TABLE) + ->values($entry) + ->execute(); + } +}