diff --git a/appinfo/info.xml b/appinfo/info.xml index 8685f191..d6acbf76 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -8,7 +8,7 @@ OpenID Connect user backend Use an OpenID Connect backend to login to your Nextcloud Allows flexible configuration of an OIDC server as Nextcloud login user backend. - 8.10.1 + 8.1.1 agpl Roeland Jago Douma Julius Härtl diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 192220ca..9880be3f 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -21,11 +21,11 @@ use OCA\UserOIDC\Listener\InternalTokenRequestedListener; use OCA\UserOIDC\Listener\TimezoneHandlingListener; use OCA\UserOIDC\Listener\TokenInvalidatedListener; +use OCA\UserOIDC\MagentaBearer\MBackend; use OCA\UserOIDC\Service\ID4MeService; use OCA\UserOIDC\Service\RequestClassificationService; use OCA\UserOIDC\Service\SettingsService; use OCA\UserOIDC\Service\TokenService; -use OCA\UserOIDC\User\Backend; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -42,7 +42,6 @@ class Application extends App implements IBootstrap { public const APP_ID = 'user_oidc'; public const OIDC_API_REQ_HEADER = 'Authorization'; - private $backend; private $cachedProviders; public function __construct(array $urlParams = []) { @@ -54,14 +53,14 @@ public function register(IRegistrationContext $context): void { $userManager = $this->getContainer()->get(IUserManager::class); /* Register our own user backend */ - $this->backend = $this->getContainer()->get(Backend::class); + $backend = $this->getContainer()->get(MBackend::class); $config = $this->getContainer()->get(IConfig::class); if (version_compare($config->getSystemValueString('version', '0.0.0'), '32.0.0', '>=')) { // see https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/app_upgrade_guide/upgrade_to_32.html#id3 - $userManager->registerBackend($this->backend); + $userManager->registerBackend($backend); } else { - \OC_User::useBackend($this->backend); + \OC_User::useBackend($backend); } $context->registerEventListener(LoadAdditionalScriptsEvent::class, TimezoneHandlingListener::class); @@ -83,8 +82,10 @@ public function register(IRegistrationContext $context): void { } public function boot(IBootContext $context): void { - $context->injectFn(\Closure::fromCallable([$this->backend, 'injectSession'])); - $context->injectFn(\Closure::fromCallable([$this, 'checkLoginToken'])); + /** @var MBackend $backend */ + $backend = $this->getContainer()->get(MBackend::class); + $context->injectFn(\Closure::fromCallable([$backend, 'injectSession'])); + // $context->injectFn(\Closure::fromCallable([$this, 'checkLoginToken'])); /** @var IUserSession $userSession */ $userSession = $this->getContainer()->get(IUserSession::class); if ($userSession->isLoggedIn()) { diff --git a/lib/MagentaBearer/MBackend.php b/lib/MagentaBearer/MBackend.php new file mode 100644 index 00000000..96010d96 --- /dev/null +++ b/lib/MagentaBearer/MBackend.php @@ -0,0 +1,148 @@ +request->getHeader(Application::OIDC_API_REQ_HEADER); + + return preg_match('/^\s*bearer\s+/i', $headerToken) === 1; + } + + public function getCurrentUserId(): string { + $headerToken = $this->request->getHeader(Application::OIDC_API_REQ_HEADER); + + if (preg_match('/^\s*bearer\s+/i', $headerToken) !== 1) { + $this->logger->debug('No Bearer token'); + return ''; + } + + $headerToken = preg_replace('/^\s*bearer\s+/i', '', $headerToken); + if (!is_string($headerToken) || $headerToken === '') { + $this->logger->debug('No Bearer token'); + return ''; + } + + $providers = $this->providerMapper->getProviders(); + if (count($providers) === 0) { + $this->logger->debug('No OIDC providers'); + return ''; + } + + foreach ($providers as $provider) { + if ($this->providerService->getSetting($provider->getId(), ProviderService::SETTING_CHECK_BEARER, '0') !== '1') { + continue; + } + + try { + $sharedSecret = $this->crypto->decrypt($provider->getBearerSecret()); + $bearerToken = $this->mtokenService->decryptToken($headerToken, $sharedSecret); + $this->mtokenService->verifySignature($bearerToken, $sharedSecret); + + $payload = $this->mtokenService->decode($bearerToken); + $this->mtokenService->verifyClaims($payload, ['http://auth.magentacloud.de']); + } catch (InvalidTokenException $e) { + $this->logger->debug('Invalid token: ' . $e->getMessage() . '. Trying another provider.'); + continue; + } catch (SignatureException $e) { + $this->logger->debug($e->getMessage() . '. Trying another provider.'); + continue; + } catch (\Throwable $e) { + $this->logger->debug('General non-matching provider problem: ' . $e->getMessage()); + continue; + } + + $uidAttribute = $this->providerService->getSetting($provider->getId(), ProviderService::SETTING_MAPPING_UID, 'sub'); + $userId = is_object($payload) ? ($payload->{$uidAttribute} ?? null) : null; + + if (!$this->isAcceptableUserId($userId)) { + $this->logger->debug('No extractable user id, check mapping!'); + return ''; + } + + try { + $provisioningResult = $this->provisioningService->provisionUser($userId, $provider->getId(), $payload); + $provisionedUser = $provisioningResult['user'] ?? null; + + if ($provisionedUser instanceof IUser) { + $userId = $provisionedUser->getUID(); + } + + $this->checkFirstLogin($userId); + + return $userId; + } catch (ProvisioningDeniedException $e) { + $this->logger->error('Bearer token access denied: ' . $e->getMessage()); + return ''; + } + } + + $this->logger->debug('Could not find provider for token'); + + return ''; + } +} diff --git a/lib/User/AbstractOidcBackend.php b/lib/User/AbstractOidcBackend.php new file mode 100644 index 00000000..dad8e961 --- /dev/null +++ b/lib/User/AbstractOidcBackend.php @@ -0,0 +1,192 @@ +userMapper->countUsers(); + + if ($limit > 0 && $count > $limit) { + return $limit; + } + + return $count; + } catch (\Throwable $e) { + $this->logger->error('Failed to count OIDC users', [ + 'exception' => $e, + ]); + + return false; + } + } + + public function deleteUser($uid): bool { + if (!is_string($uid) || $uid === '') { + return false; + } + + try { + $user = $this->userMapper->getUser($uid); + $this->userMapper->delete($user); + return true; + } catch (DoesNotExistException $e) { + $this->logger->info('Tried to delete non-existent user', [ + 'uid' => $uid, + 'exception' => $e, + ]); + return false; + } catch (Exception $e) { + $this->logger->error('Failed to delete user', [ + 'uid' => $uid, + 'exception' => $e, + ]); + return false; + } + } + + public function getUsers($search = '', $limit = null, $offset = null): array { + if (!is_string($search) + || ($limit !== null && !is_int($limit)) + || ($offset !== null && !is_int($offset)) + ) { + return []; + } + + return array_map( + static fn ($user) => $user->getUserId(), + $this->userMapper->find($search, $limit, $offset) + ); + } + + public function userExists($uid): bool { + return is_string($uid) && $uid !== '' && $this->userMapper->userExists($uid); + } + + public function getDisplayName($uid): string { + if (!is_string($uid) || $uid === '') { + return (string)$uid; + } + + try { + $user = $this->userMapper->getUser($uid); + return $user->getDisplayName(); + } catch (DoesNotExistException) { + return $uid; + } + } + + public function getDisplayNames($search = '', $limit = null, $offset = null): array { + if (!is_string($search) + || ($limit !== null && !is_int($limit)) + || ($offset !== null && !is_int($offset)) + ) { + return []; + } + + return $this->userMapper->findDisplayNames($search, $limit, $offset); + } + + public function hasUserListings(): bool { + return true; + } + + public function canConfirmPassword(string $uid): bool { + return false; + } + + public function injectSession(ISession $session): void { + $this->session = $session; + } + + public function getLogoutUrl(): string { + return $this->urlGenerator->linkToRouteAbsolute('user_oidc.login.singleLogoutService'); + } + + protected function isAcceptableUserId(mixed $userId): bool { + return is_string($userId) && trim($userId) !== ''; + } + + protected function checkFirstLogin(string $userId): bool { + $user = $this->userManager->get($userId); + if ($user === null) { + return false; + } + + $firstLogin = $user->getLastLogin() === 0; + + if ($firstLogin) { + try { + if (version_compare($this->config->getSystemValueString('version', '0.0.0'), '34.0.0', '>=') + && interface_exists(ISetupManager::class) + ) { + Server::get(ISetupManager::class)->setupForUser($user); + } else { + \OC_Util::setupFS($userId); + } + + $userFolder = Server::get(IRootFolder::class)->getUserFolder($userId); + \OC_Util::copySkeleton($userId, $userFolder); + } catch (\Throwable $e) { + $this->logger->warning('Could not fully set up user filesystem on first login', [ + 'userId' => $userId, + 'exception' => $e, + ]); + } + + if (class_exists(UserFirstTimeLoggedInEvent::class)) { + $this->eventDispatcher->dispatchTyped(new UserFirstTimeLoggedInEvent($user)); + } + } + + $user->updateLastLoginTimestamp(); + + return $firstLogin; + } +}