diff --git a/lib/Controller/SAMLController.php b/lib/Controller/SAMLController.php index fd8d77bfd..52b0ec5f8 100644 --- a/lib/Controller/SAMLController.php +++ b/lib/Controller/SAMLController.php @@ -463,7 +463,7 @@ public function singleLogoutService(): Http\RedirectResponse { $pass = false; } } else { - // standard request : need read CRSF check + // standard request : need read CSRF check $pass = $this->request->passesCSRFCheck(); } diff --git a/lib/SAMLSettings.php b/lib/SAMLSettings.php index 1877ee4b5..064e85b2e 100644 --- a/lib/SAMLSettings.php +++ b/lib/SAMLSettings.php @@ -61,6 +61,7 @@ class SAMLSettings { 'saml-attribute-mapping-mfa_mapping', 'saml-attribute-mapping-user_id_ldap_mapping', 'saml-attribute-mapping-group_mapping_prefix', + 'saml-attribute-mapping-user_secret_mapping', 'saml-user-filter-reject_groups', 'saml-user-filter-require_groups', 'sp-entityId', diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 7ddd2d18c..13db7887e 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -160,6 +160,11 @@ public function getForm(): TemplateResponse { 'type' => 'line', 'required' => false, ], + 'user_secret_mapping' => [ + 'text' => $this->l10n->t('Attribute to use as user secret e.g. for the encryption app.'), + 'type' => 'line', + 'required' => false, + ], ]; if (version_compare($this->config->getSystemValueString('version', '0.0.0'), '34.0.0', '<')) { diff --git a/lib/UserBackend.php b/lib/UserBackend.php index 4110bcddc..340e6305e 100644 --- a/lib/UserBackend.php +++ b/lib/UserBackend.php @@ -13,9 +13,11 @@ use OCA\User_SAML\Model\SessionData; use OCP\AppFramework\Services\IAppConfig; use OCP\Authentication\IApacheBackend; +use OCP\Authentication\IProvideUserSecretBackend; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; +use OCP\HintException; use OCP\IConfig; use OCP\IDBConnection; use OCP\ISession; @@ -24,21 +26,24 @@ use OCP\IUserBackend; use OCP\IUserManager; use OCP\IUserSession; +use OCP\Security\IHasher; use OCP\Server; use OCP\User\Backend\ABackend; +use OCP\User\Backend\ICheckPasswordBackend; use OCP\User\Backend\ICountUsersBackend; use OCP\User\Backend\ICustomLogout; use OCP\User\Backend\IGetDisplayNameBackend; use OCP\User\Backend\IGetHomeBackend; use OCP\User\Backend\IProvideEnabledStateBackend; use OCP\User\Backend\ISetDisplayNameBackend; +use OCP\User\Events\PostLoginEvent; use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserFirstTimeLoggedInEvent; use OCP\UserInterface; use Override; use Psr\Log\LoggerInterface; -class UserBackend extends ABackend implements IApacheBackend, IUserBackend, IGetDisplayNameBackend, ICountUsersBackend, IGetHomeBackend, ICustomLogout, ISetDisplayNameBackend, IProvideEnabledStateBackend { +class UserBackend extends ABackend implements IApacheBackend, IUserBackend, IGetDisplayNameBackend, ICountUsersBackend, IGetHomeBackend, ICustomLogout, ISetDisplayNameBackend, IProvideEnabledStateBackend, IProvideUserSecretBackend, ICheckPasswordBackend { /** @var \OCP\UserInterface[] */ private static array $backends = []; @@ -118,10 +123,70 @@ public function createUserIfNotExists(string $uid, array $attributes = []): void } $qb->executeStatement(); + // If we use per-user encryption the keys must be initialized first + $userSecret = $this->getUserSecret($attributes); + if ($userSecret !== null) { + $this->updateUserSecretHash($uid, $userSecret); + $user = $this->userManager->get($uid); + if ($user === null) { + throw new \RuntimeException('New user doesn\'t exists.'); + } + // Emit a post login action to initialize the encryption module with the user secret provided by the idp. + $this->eventDispatcher->dispatchTyped(new PostLoginEvent($user, $uid, $userSecret, false)); + } $this->initializeHomeDir($uid); } } + /** + * @return list + */ + private function getUserSecretHashes(string $uid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('token') + ->from('user_saml_auth_token') + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('name', $qb->createNamedParameter('sso_secret_hash'))) + ->setMaxResults(10); + $result = $qb->executeQuery(); + /** @var list $data */ + $data = $result->fetchAll(\PDO::FETCH_COLUMN); + $result->closeCursor(); + return $data; + } + + private function checkAndUpdateUserSecretHash(string $uid, string $userSecret): bool { + $data = $this->getUserSecretHashes($uid); + foreach ($data as $storedHash) { + if (Server::get(IHasher::class)->verify($userSecret, $storedHash, $newHash)) { + if ($newHash !== null && $newHash !== '') { + $this->updateUserSecretHash($uid, $userSecret, true); + } + return true; + } + } + return false; + } + + private function updateUserSecretHash(string $uid, string $userSecret, bool $exists = false): bool { + $qb = $this->db->getQueryBuilder(); + $hash = Server::get(IHasher::class)->hash($userSecret); + if ($exists || count($this->getUserSecretHashes($uid)) > 0) { + $qb->update('user_saml_auth_token') + ->set('token', $qb->createNamedParameter($hash)) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('name', $qb->createNamedParameter('sso_secret_hash'))); + } else { + $qb->insert('user_saml_auth_token') + ->values([ + 'uid' => $qb->createNamedParameter($uid), + 'token' => $qb->createNamedParameter($hash), + 'name' => $qb->createNamedParameter('sso_secret_hash'), + ]); + } + return $qb->executeStatement() > 0; + } + /** * @throws \OCP\Files\NotFoundException */ @@ -165,6 +230,22 @@ public function getHome(string $uid): string|false { return $users[0]['home'] ?? false; } + /** + * @inheritDoc + * + * By default, user_saml tokens are passwordless and this function + * is unused. It is only called if we have tokens with passwords, + * which happens if we have SSO provided user secrets. + */ + #[Override] + public function checkPassword(string $loginName, string $password): false|string { + if ($this->checkAndUpdateUserSecretHash($loginName, $password)) { + return $loginName; + } + + return false; + } + #[Override] public function getUsers($search = '', $limit = null, $offset = null): array { // shamelessly duplicated from \OC\User\Database @@ -388,6 +469,13 @@ public function getBackendName(): string { return 'user_saml'; } + #[Override] + public function getCurrentUserSecret(): ?string { + $samlData = $this->session->get('user_saml.samlUserData'); + $userSecret = $this->getUserSecret($samlData); + return $userSecret; + } + /** * Whether autoprovisioning is enabled or not */ @@ -573,7 +661,21 @@ public function updateAttributes(string $uid): void { } } - #[\Override] + private function getUserSecret(array $attributes): ?string { + try { + $userSecret = $this->getAttributeValue('saml-attribute-mapping-user_secret_mapping', $attributes); + if ($userSecret === '') { + throw new HintException('Got no user_secret from IDP. Make sure that your IDP provides a per-user secrets.'); + } else { + return $userSecret; + } + } catch (\InvalidArgumentException $e) { + // ignore no user_secret mapping was configured + } + return null; + } + + #[Override] public function countUsers(): int { $query = $this->db->getQueryBuilder(); $query->select($query->func()->count('uid'))