diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 162f1e3..127fada 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -19,7 +19,9 @@ use OCA\GlobalSiteSelector\Listeners\UserDeleted; use OCA\GlobalSiteSelector\Listeners\UserLoggedOut; use OCA\GlobalSiteSelector\Listeners\UserLoggingIn; +use OCA\GlobalSiteSelector\Master; use OCA\GlobalSiteSelector\PublicCapabilities; +use OCA\GlobalSiteSelector\Service\GlobalScaleService; use OCA\GlobalSiteSelector\SetupChecks\LongJwtKeySetupCheck; use OCA\GlobalSiteSelector\Slave; use OCA\GlobalSiteSelector\UserBackend; @@ -28,16 +30,17 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\GlobalScale\IGlobalScaleService; use OCP\IRequest; use OCP\IUserManager; use OCP\IUserSession; use OCP\Security\CSP\AddContentSecurityPolicyEvent; use OCP\Server; use OCP\User\Events\BeforeUserDeletedEvent; -use OCP\User\Events\BeforeUserLoggedInEvent; use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserCreatedEvent; use OCP\User\Events\UserDeletedEvent; +use OCP\User\Events\UserLoggedInEvent; use OCP\User\Events\UserLoggedOutEvent; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; @@ -65,7 +68,7 @@ public function register(IRegistrationContext $context): void { $context->registerCapability(PublicCapabilities::class); // events on master - $context->registerEventListener(BeforeUserLoggedInEvent::class, UserLoggingIn::class); + $context->registerEventListener(UserLoggedInEvent::class, UserLoggingIn::class); $context->registerEventListener( AddContentSecurityPolicyEvent::class, AddContentSecurityPolicyListener::class @@ -80,6 +83,13 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserUpdatedEvent::class, UserChanged::class); $context->registerSetupCheck(LongJwtKeySetupCheck::class); + + // registerGlobalScaleService() and IGlobalScaleService only exist since Nextcloud + // 34.0.3, but this app still supports 32 and 33, see lib/Service/GlobalScaleService.php + if (interface_exists(IGlobalScaleService::class)) { + /** @psalm-suppress UndefinedInterfaceMethod */ + $context->registerGlobalScaleService(GlobalScaleService::class); + } } /** @@ -92,6 +102,7 @@ public function boot(IBootContext $context): void { $context->injectFn(\Closure::fromCallable($this->registerUserBackendForSlave(...))); $context->injectFn(\Closure::fromCallable($this->redirectToMasterLogin(...))); + $context->injectFn(\Closure::fromCallable($this->redirectToSlave(...))); } /** @@ -179,4 +190,35 @@ private function redirectToMasterLogin(): void { ); } } + + private function redirectToSlave(IRequest $request, Master $master, IUserSession $userSession): void { + /** only used in master mode */ + if (!$this->globalSiteSelector->isMaster()) { + return; + } + + if (!$userSession->isLoggedIn()) { + return; + } + + // We should ignore oauth2 token endpoint (oauth can send the credentials as basic auth which will fail with apache auth) + $uri = $request->getPathInfo(); + if (str_starts_with($uri, '/apps/oauth/api/v1/token') || str_starts_with($uri, '/apps/oauth2/authorize') || str_starts_with($uri, '/login/flow')) { + return; + } + + $user = $userSession->getUser(); + if ($user === null) { + return; + } + + $this->logger->debug('new redirectToSlave'); + $master->handleLoginRequest( + $user, + '', + true, + ); + + $this->logger->debug('ending redirectToSlave'); + } } diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 34d3a04..fb23998 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -18,6 +18,7 @@ class ConfigLexicon implements ILexicon { public const GS_TOKENS = 'globalScaleTokens'; public const LOCAL_TOKEN = 'localToken'; public const REDIRECT_WEBDAV = 'redirectWebDAV'; + public const SSO_USER_DATA = 'ssoUserData'; #[\Override] public function getStrictness(): Strictness { @@ -42,6 +43,7 @@ public function getAppConfigs(): array { #[\Override] public function getUserConfigs(): array { return [ + new Entry(key: self::SSO_USER_DATA, type: ValueType::ARRAY, defaultRaw: [], definition: 'formatted SAML/OIDC identity data, cached from the last login with an active SSO session', lazy: true), ]; } } diff --git a/lib/Exceptions/IsLocalAdminException.php b/lib/Exceptions/IsLocalAdminException.php new file mode 100644 index 0000000..3f32e10 --- /dev/null +++ b/lib/Exceptions/IsLocalAdminException.php @@ -0,0 +1,14 @@ + + * @template-implements IEventListener */ class UserLoggingIn implements IEventListener { @@ -25,12 +26,13 @@ public function __construct( private readonly GlobalSiteSelector $globalSiteSelector, private readonly Master $master, private readonly LoggerInterface $logger, + private readonly IRequest $request, ) { } #[\Override] public function handle(Event $event): void { - if (!$event instanceof BeforeUserLoggedInEvent) { + if (!$event instanceof UserLoggedInEvent) { return; } @@ -39,11 +41,15 @@ public function handle(Event $event): void { return; } + $uri = $this->request->getRequestUri(); + if (str_ends_with($uri, '/apps/oauth/api/v1/token')) { + return; + } + $this->logger->debug('new BeforeUserLoggedInEvent event'); $this->master->handleLoginRequest( - $event->getUsername(), + $event->getUser(), $event->getPassword(), - $event->getBackend() ); $this->logger->debug('ending BeforeUserLoggedInEvent event'); diff --git a/lib/Master.php b/lib/Master.php index 65b4e73..90b6f2d 100644 --- a/lib/Master.php +++ b/lib/Master.php @@ -13,19 +13,19 @@ use OC\Core\Controller\ClientFlowLoginV2Controller; use OC\Core\Service\LoginFlowV2Service; use OCA\GlobalSiteSelector\AppInfo\Application; -use OCA\GlobalSiteSelector\UserDiscoveryModules\IUserDiscoveryModule; +use OCA\GlobalSiteSelector\Exceptions\IsLocalAdminException; +use OCA\GlobalSiteSelector\Service\GlobalScaleService; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\JWT; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\Key; use OCP\AppFramework\Http\StandaloneTemplateResponse; -use OCP\Authentication\IApacheBackend; use OCP\HintException; use OCP\Http\Client\IClientService; use OCP\IAppConfig; use OCP\IConfig; use OCP\IRequest; use OCP\ISession; +use OCP\IUser; use OCP\Security\ICrypto; -use OCP\Server; use OCP\ServerVersion; use OCP\Util; use Psr\Container\ContainerExceptionInterface; @@ -52,6 +52,7 @@ public function __construct( private readonly IAppConfig $appConfig, private readonly IConfig $config, private readonly LoggerInterface $logger, + private readonly GlobalScaleService $globalScaleService, ) { } @@ -64,80 +65,63 @@ public function __construct( * @throws NotFoundExceptionInterface */ public function handleLoginRequest( - string $uid, + IUser $user, ?string $password, - ?IApacheBackend $backend = null, + bool $ignoreJwt = false, ): void { + $backend = $user->getBackend(); $this->logger->debug( 'start handle login request', [ - 'uid' => $uid, + 'uid' => $user->getUID(), 'backend' => ($backend === null) ? null : $backend::class ] ); /** ignoring request from slave with valid jwt */ - if ($this->isValidJwt($this->request->getParam('jwt', ''))) { + if (!$ignoreJwt && $this->isValidJwt($this->request->getParam('jwt', ''))) { $this->logger->debug('ignore request with valid jwt'); return; } - $target = (!$this->request->getPathInfo()) ? '/' : '/index.php' . $this->request->getPathInfo(); - $this->logger->debug('handleLoginRequest: target is: ' . $target); - + // Use the entry script that actually handled this request (index.php, + // remote.php, ...) instead of hardcoding index.php: this event also + // fires for DAV/OCS requests served through remote.php, and hardcoding + // index.php there produces a target URL that doesn't exist on the slave. + $target = (!$this->request->getPathInfo()) ? '/' : $this->request->getScriptName() . $this->request->getPathInfo(); $options = [ 'target' => $target, 'params' => $this->request->getParams(), ]; - $discoveryData = []; - - $userDiscoveryModule = $this->config->getSystemValueString('gss.user.discovery.module', ''); - $this->logger->debug('handleLoginRequest: discovery module is: ' . $userDiscoveryModule); - $redirectUrl = $this->request->getParam('redirect_url', ''); - $isSamlOrOidc = false; - if (class_exists('\OCA\User_SAML\UserBackend') - && $backend instanceof \OCA\User_SAML\UserBackend) { - $isSamlOrOidc = true; + $ssoUserData = $this->globalScaleService->getSsoUserData($user); + if ($ssoUserData !== null && $ssoUserData['backend'] === 'saml') { $this->logger->debug('handleLoginRequest: backend is SAML'); - $options['backend'] = 'saml'; - $options['userData'] = $backend->getUserData(); - $uid = $options['userData']['formatted']['uid']; $password = ''; - $discoveryData['saml'] = $options['userData']['raw']; // we only send the formatted user data to the slave - $options['userData'] = $options['userData']['formatted']; + $options['backend'] = 'saml'; + $options['userData'] = $ssoUserData['formatted']; $options['saml'] = [ 'idp' => $this->session->get('user_saml.Idp') ]; - - $this->logger->debug('handleLoginRequest: backend is SAML.', ['options' => $options]); - } elseif (class_exists('\OCA\UserOIDC\Controller\LoginController') - && class_exists('\OCA\UserOIDC\User\Backend') - && $backend instanceof \OCA\UserOIDC\User\Backend - && method_exists($backend, 'getUserData') - ) { - // TODO double check if we need to behave the same when saml or oidc is used - $isSamlOrOidc = true; + } elseif ($ssoUserData !== null && $ssoUserData['backend'] === 'oidc') { $this->logger->debug('handleLoginRequest: backend is OIDC'); - $options['backend'] = 'oidc'; - $options['userData'] = $backend->getUserData(); - $uid = $options['userData']['formatted']['uid']; $password = ''; - $discoveryData['oidc'] = $options['userData']['raw']; // we only send the formatted user data to the slave - $options['userData'] = $options['userData']['formatted']; + $options['backend'] = 'oidc'; + $options['userData'] = $ssoUserData['formatted']; $options['oidc'] = [ - 'providerId' => $this->session->get(\OCA\UserOIDC\Controller\LoginController::PROVIDERID) + // keep in sync with \OCA\UserOIDC\Controller\LoginController::PROVIDERID + 'providerId' => $this->session->get('oidc.providerid') ]; - // TODO: switch 'oidc.redirect' to \OCA\UserOIDC\Controller\LoginController::REDIRECT_AFTER_LOGIN once switched to public $state = $this->request->getParam('state') ?? ''; $sessionKeySuffix = ($state !== '') ? '-' . $state : ''; + // keep in sync with \OCA\UserOIDC\Controller\LoginController::REDIRECT_AFTER_LOGIN $redirect = $this->session->get('oidc.redirect') ?? $this->session->get('oidc.redirect' . $sessionKeySuffix) ?? '/'; $options['target'] = $this->forceRelativeUrl($redirect); @@ -157,8 +141,6 @@ public function handleLoginRequest( parse_str(parse_url($oidcRedirect, PHP_URL_QUERY) ?? '', $oidcRedirectParams); $redirectUrl = $oidcRedirectParams['redirect_url'] ?? $redirectUrl; } - - $this->logger->debug('handleLoginRequest: backend is OIDC.', ['options' => $options]); } else { $this->logger->debug('handleLoginRequest: backend is not SAML or OIDC'); } @@ -168,89 +150,35 @@ public function handleLoginRequest( $this->logger->debug('handleLoginRequest: overriding target with slave flow path: ' . $options['target']); } - $this->logger->debug('handleLoginRequest: uid is: ' . $uid); - - // let local account login, everyone else will redirected to a client - $masterAdmins = $this->config->getSystemValue('gss.master.admin', []); // old syntax - $localAccounts = $this->config->getSystemValue('gss.master.accounts', []); // new one - $masterAdmins = (is_array($masterAdmins)) ? $masterAdmins : []; - $localAccounts = (is_array($localAccounts)) ? $localAccounts : []; - - if (in_array($uid, array_merge($masterAdmins, $localAccounts), true)) { - $this->logger->debug('handleLoginRequest: this user is a local account so ignore'); + try { + $location = $this->globalScaleService->getSecondaryRemoteLocation($user); + } catch (IsLocalAdminException) { return; } - - // first ask the lookup server if we already know the user - // is from SAML or OIDC, only search on userId, ignore email. - $location = $this->queryLookupServer($uid, $isSamlOrOidc); - $this->logger->debug('handleLoginRequest: location according to lookup server: ' . $location); - - // if not we fall-back to a initial user deployment method, if configured - if (empty($location) && !empty($userDiscoveryModule)) { - try { - $this->logger->debug('handleLoginRequest: obtaining location from discovery module ' . $userDiscoveryModule); - - /** @var IUserDiscoveryModule $module */ - $module = Server::get($userDiscoveryModule); - $location = $module->getLocation($discoveryData); - $this->lookup->sanitizeUid($uid); - - $this->logger->debug( - 'handleLoginRequest: location according to discovery module: ' . $location - ); - } catch (Exception $e) { - $this->logger->warning( - 'could not load user discovery module: ' . $userDiscoveryModule, - ['exception' => $e->getMessage()] - ); - } - } - - if (!empty($location)) { + if ($location !== null) { $this->logger->debug( - 'handleLoginRequest: redirecting user: ' . $uid . ' to ' . $this->normalizeLocation($location) + 'handleLoginRequest: redirecting user: ' . $user->getUID() . ' to ' . $location ); - $this->redirectUser($uid, $password, $this->normalizeLocation($location), $options); + $this->redirectUser($user->getUID(), $password, $location, $options); } else { - $this->logger->debug('handleLoginRequest: Could not find location for account ' . $uid); + $this->logger->debug('handleLoginRequest: Could not find location for account ' . $user->getUID()); throw new HintException('Unknown Account'); } } - /** - * format URL - * - * @param string $url - */ - protected function normalizeLocation($url): string { - if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) { - return $url; - } - - return $this->request->getServerProtocol() . '://' . $url; - } - - /** - * search for the user and return the location of the user - * - * @param $uid - */ - protected function queryLookupServer(string &$uid, bool $matchUid = false): string { - return $this->lookup->search($uid, $matchUid); - } - /** * redirect user to the right Nextcloud server * * @param string $uid + * @param string $password * @param string $location * @param array $options can contain additional parameters, e.g. from SAML + * * @throws Exception */ - protected function redirectUser($uid, string $password, $location, array $options = []) { + protected function redirectUser($uid, $password, $location, array $options = []) { $isClient = $this->request->isUserAgent( [ IRequest::USER_AGENT_CLIENT_IOS, @@ -262,13 +190,24 @@ protected function redirectUser($uid, string $password, $location, array $option ) || $this->isPath(['/login/flow/grant', '/login/v2/grant'], $options['target'] ?? ''); $requestUri = $this->request->getRequestUri(); + // check for both possible direct webdav end-points - $isDirectWebDavAccess = str_contains($requestUri, 'remote.php/webdav'); - $isDirectWebDavAccess = $isDirectWebDavAccess || str_contains($requestUri, 'remote.php/dav'); + $isDirectWebDavAccess = str_starts_with($requestUri, '/remote.php/webdav') + || str_starts_with($requestUri, '/remote.php/dav'); + + $isDirectOCS = str_starts_with($requestUri, '/ocs/v2.php') + || str_starts_with($requestUri, '/ocs/v1.php'); $authHeader = $this->request->getHeader('Authorization'); $redirectWebDav = $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::REDIRECT_WEBDAV); - $hasBasicAuth = $redirectWebDav && $authHeader !== '' && str_starts_with(strtolower($authHeader), 'basic '); + $authHeaderLower = strtolower($authHeader); + // Basic (third-party WebDAV clients) as well as Bearer (OAuth2 app + // tokens, e.g. from a GSS-unaware OIDC/OAuth2 client) can both be + // forwarded as-is to the slave: neither depends on a browser session, + // unlike the JWT-autologin bounce below. + $hasForwardableAuth = $authHeader !== '' + && (str_starts_with($authHeaderLower, 'basic ') || str_starts_with($authHeaderLower, 'bearer ')); + $hasForwardableAuthDav = $redirectWebDav && $hasForwardableAuth; // default redirect status code; overridden below for the 307 forward. $statusCode = 302; @@ -290,16 +229,19 @@ protected function redirectUser($uid, string $password, $location, array $option // fallback to v1 $redirectUrl = 'nc://login/server:' . $location . '&user:' . urlencode($uid) . '&password:' . urlencode($appToken); } - } elseif ($isDirectWebDavAccess && $hasBasicAuth) { - // Third-party WebDAV clients authenticated with HTTP Basic - // (curl, rclone, davfs2, sabre/dav based clients, generic DAV - // consumers, etc.): forward the request as-is to the slave with - // a 307 (RFC 9110 §15.4.8) so that PUT, PROPFIND, MKCOL, DELETE, - // COPY and MOVE are not downgraded to GET, and the original - // request URI is preserved end-to-end. The client re-issues the - // same request to the slave, including the Authorization header - // it already presented to the master. - $this->logger->debug('redirectUser: third-party webdav request with Basic Auth, forwarding with 307'); + } elseif ($isDirectWebDavAccess && $hasForwardableAuthDav) { + // Third-party WebDAV clients authenticated with HTTP Basic or + // Bearer (curl, rclone, davfs2, sabre/dav based clients, OAuth2 + // clients, generic DAV consumers, etc.): forward the request as-is + // to the slave with a 307 (RFC 9110 §15.4.8) so that PUT, + // PROPFIND, MKCOL, DELETE, COPY and MOVE are not downgraded to + // GET, and the original request URI is preserved end-to-end. The + // client re-issues the same request to the slave, including the + // Authorization header it already presented to the master. + $this->logger->debug('redirectUser: third-party webdav request with forwardable auth, forwarding with 307'); + $redirectUrl = rtrim($location, '/') . $requestUri; + $statusCode = 307; + } elseif ($isDirectOCS) { $redirectUrl = rtrim($location, '/') . $requestUri; $statusCode = 307; } else { @@ -346,12 +288,13 @@ protected function createJwt($uid, string $password, $options): string { * * @param string $location * @param string $uid + * @param string $password * @param array $options * * @return string * @throws Exception */ - protected function getAppToken($location, $uid, string $password, $options) { + protected function getAppToken($location, $uid, $password, $options) { $client = $this->clientService->newClient(); $jwt = $this->createJwt($uid, $password, $options); diff --git a/lib/Service/GlobalScaleService.php b/lib/Service/GlobalScaleService.php index b604d9f..a8c7306 100644 --- a/lib/Service/GlobalScaleService.php +++ b/lib/Service/GlobalScaleService.php @@ -13,16 +13,26 @@ use JsonException; use OCA\GlobalSiteSelector\AppInfo\Application; use OCA\GlobalSiteSelector\ConfigLexicon; +use OCA\GlobalSiteSelector\Exceptions\IsLocalAdminException; use OCA\GlobalSiteSelector\GlobalSiteSelector; use OCA\GlobalSiteSelector\Lookup; +use OCA\GlobalSiteSelector\UserDiscoveryModules\IUserDiscoveryModule; +use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\JWT; +use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\Key; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Config\IUserConfig; +use OCP\GlobalScale\IGlobalScaleService; use OCP\Http\Client\IClientService; use OCP\IAppConfig; use OCP\IConfig; +use OCP\IRequest; use OCP\IURLGenerator; +use OCP\IUser; use OCP\Security\ISecureRandom; +use OCP\Server; use Psr\Log\LoggerInterface; -class GlobalScaleService { +trait TGlobalScaleService { public function __construct( private readonly IAppConfig $appConfig, private readonly IClientService $clientService, @@ -32,6 +42,9 @@ public function __construct( private readonly GlobalSiteSelector $gss, private readonly Lookup $lookup, private readonly LoggerInterface $logger, + private readonly IRequest $request, + private readonly IUserConfig $userConfig, + private readonly ITimeFactory $time, ) { } @@ -171,4 +184,183 @@ public function requestGssOcs(string $address, string $route, array $data = [], return []; } } + + /** + * Return the formatted SAML/OIDC identity data for a user, if their account is + * backed by one of those backends. + * + * This is cached since this requires calling the backend's getUserData(), + * which reads from the current session and is not always available. + * + * @return array{backend: 'saml'|'oidc', formatted: array, raw: array}|null + */ + public function getSsoUserData(IUser $user): ?array { + $uid = $user->getUID(); + + $cached = $this->userConfig->getValueArray($uid, Application::APP_ID, ConfigLexicon::SSO_USER_DATA, [], lazy: true); + if ($cached !== []) { + return $cached; + } + + $backend = $user->getBackend(); + $data = null; + + try { + if (class_exists('\OCA\User_SAML\UserBackend') + && $backend instanceof \OCA\User_SAML\UserBackend) { + $userData = $backend->getUserData(); + $data = ['backend' => 'saml', 'formatted' => $userData['formatted'], 'raw' => $userData['raw']]; + } elseif (class_exists('\OCA\UserOIDC\Controller\LoginController') + && class_exists('\OCA\UserOIDC\User\Backend') + && $backend instanceof \OCA\UserOIDC\User\Backend + && method_exists($backend, 'getUserData') + ) { + $userData = $backend->getUserData(); + $data = ['backend' => 'oidc', 'formatted' => $userData['formatted'], 'raw' => $userData['raw']]; + } + } catch (Exception $e) { + $this->logger->debug('getSsoUserData: could not read SAML/OIDC session data for ' . $uid, ['exception' => $e]); + return null; + } + + if ($data !== null) { + $this->userConfig->setValueArray($uid, Application::APP_ID, ConfigLexicon::SSO_USER_DATA, $data, lazy: true); + } + + return $data; + } + + /** + * Find the secondary (slave) location for a user, if any. + * + * @throws IsLocalAdminException If the user is one of the local admin and shouldn't be redirected + */ + public function getSecondaryRemoteLocation(IUser $user): ?string { + $uid = $user->getUID(); + $discoveryData = []; + $isSamlOrOidc = false; + + $ssoUserData = $this->getSsoUserData($user); + if ($ssoUserData !== null) { + $isSamlOrOidc = true; + $this->logger->debug('getSecondaryRemoteLocation: backend is ' . $ssoUserData['backend']); + + $uid = $ssoUserData['formatted']['uid']; + $discoveryData[$ssoUserData['backend']] = $ssoUserData['raw']; + } else { + $this->logger->debug('getSecondaryRemoteLocation: backend is not SAML or OIDC'); + } + + $this->logger->debug('getSecondaryRemoteLocation: uid is: ' . $uid); + + // let local account login, everyone else will be redirected to a client + $masterAdmins = $this->config->getSystemValue('gss.master.admin', []); // old syntax + $localAccounts = $this->config->getSystemValue('gss.master.accounts', []); // new one + $masterAdmins = (is_array($masterAdmins)) ? $masterAdmins : []; + $localAccounts = (is_array($localAccounts)) ? $localAccounts : []; + + if (in_array($uid, array_merge($masterAdmins, $localAccounts), true)) { + $this->logger->debug('getSecondaryRemoteLocation: this user is a local account so ignore'); + throw new IsLocalAdminException(); + } + + // first ask the lookup server if we already know the user + // is from SAML or OIDC, only search on userId, ignore email. + $location = $this->queryLookupServer($uid, $isSamlOrOidc); + $this->logger->debug('getSecondaryRemoteLocation: location according to lookup server: ' . $location); + + // if not we fall back to an initial user deployment method, if configured + $userDiscoveryModule = $this->config->getSystemValueString('gss.user.discovery.module', ''); + if (empty($location) && !empty($userDiscoveryModule)) { + try { + $this->logger->debug('getSecondaryRemoteLocation: obtaining location from discovery module ' . $userDiscoveryModule); + + /** @var IUserDiscoveryModule $module */ + $module = Server::get($userDiscoveryModule); + $location = $module->getLocation($discoveryData); + $this->lookup->sanitizeUid($uid); + + $this->logger->debug( + 'getSecondaryRemoteLocation: location according to discovery module: ' . $location + ); + } catch (Exception $e) { + $this->logger->warning( + 'Could not load user discovery module: ' . $userDiscoveryModule, + ['exception' => $e->getMessage()] + ); + } + } + + if ($location === '') { + return null; + } + + return $this->normalizeLocation($location); + } + + protected function queryLookupServer(string &$uid, bool $matchUid = false): string { + return $this->lookup->search($uid, $matchUid); + } + + /** + * @param non-empty-string $url + * @return non-empty-string + */ + protected function normalizeLocation(string $url): string { + if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) { + return $url; + } + + return $this->request->getServerProtocol() . '://' . $url; + } + + public function sendToSecondary(IUser $user, string $path, array $payload): string { + $location = $this->getSecondaryRemoteLocation($user); + if ($location === null) { + throw new \Exception('Could not send message to secondary. No secondary location found for user with id: ' . $user->getUID()); + } + + if (!isset($payload['exp'])) { + $payload['exp'] = $this->time->getTime() + 300; // expires after 5 minutes; + } + + $jwt = JWT::encode( + $payload, + $this->config->getSystemValueString('gss.jwt.key', ''), + 'HS256', + ); + + try { + $this->clientService->newClient()->post( + $location . $path, + [ + 'headers' => ['OCS-APIRequest' => 'true'], + 'verify' => !$this->config->getSystemValueBool('gss.selfsigned.allow', false), + 'query' => ['format' => 'json'], + 'body' => ['jwt' => $jwt], + ] + ); + } catch (\Exception $e) { + throw new \Exception('Could not send message to secondary due to a network issue :' . $e->getMessage(), previous: $e); + } + + return $location; + } + + public function decodePayload(string $jwt): array { + return (array)JWT::decode($jwt, new Key($this->config->getSystemValueString('gss.jwt.key', ''), 'HS256')); + } +} + +// OCP\GlobalScale\IGlobalScaleService only exists since Nextcloud 34.0.3, but this app +// still supports 32 and 33, so only implement it when it's actually available. +if (interface_exists(IGlobalScaleService::class)) { + class GlobalScaleService implements IGlobalScaleService { + use TGlobalScaleService; + } +} else { + // needed as long as Nextcloud < 34.0.3 is supported, see appinfo/info.xml + class GlobalScaleService { + use TGlobalScaleService; + } } diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index d4c2012..a388a78 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -4,11 +4,6 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> - - - \OCA\User_SAML\UserBackend - - \OC\User\Database diff --git a/tests/unit/lib/MasterTest.php b/tests/unit/lib/MasterTest.php index 20c8050..7ced059 100644 --- a/tests/unit/lib/MasterTest.php +++ b/tests/unit/lib/MasterTest.php @@ -12,6 +12,7 @@ use OCA\GlobalSiteSelector\GlobalSiteSelector; use OCA\GlobalSiteSelector\Lookup; use OCA\GlobalSiteSelector\Master; +use OCA\GlobalSiteSelector\Service\GlobalScaleService; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\JWT; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\Key; use OCP\HintException; @@ -20,6 +21,7 @@ use OCP\IConfig; use OCP\IRequest; use OCP\ISession; +use OCP\IUser; use OCP\Security\ICrypto; use OCP\Server; use OCP\ServerVersion; @@ -38,6 +40,7 @@ class MasterTest extends TestCase { private LoggerInterface&MockObject $logger; private ISession&MockObject $session; private LoginFlowV2Service&MockObject $loginflow; + private GlobalScaleService&MockObject $globalScaleService; private ServerVersion $serverVersion; public function setUp(): void { @@ -56,6 +59,8 @@ public function setUp(): void { $this->appConfig = $this->createMock(IAppConfig::class); $this->logger = $this->createMock(LoggerInterface::class); $this->session = $this->createMock(ISession::class); + $this->globalScaleService = $this->getMockBuilder(GlobalScaleService::class) + ->disableOriginalConstructor()->getMock(); } private function getInstance(array $mockMethods = []): Master&MockObject { @@ -72,34 +77,94 @@ private function getInstance(array $mockMethods = []): Master&MockObject { $this->clientService, $this->appConfig, $this->config, - $this->logger + $this->logger, + $this->globalScaleService, ] )->onlyMethods($mockMethods)->getMock(); } + private function getUser(string $uid, $backend = null): IUser&MockObject { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $user->method('getBackend')->willReturn($backend); + + return $user; + } + public function testHandleLoginRequest(): void { - $location = 'nextcloud.com'; - $master = $this->getInstance(['queryLookupServer', 'redirectUser']); - $master->expects($this->once())->method('queryLookupServer') + $location = 'https://nextcloud.com'; + $user = $this->getUser('user'); + + $master = $this->getInstance(['redirectUser']); + + $this->request->method('getPathInfo')->willReturn(''); + $this->request->method('getParams')->willReturn([]); + $this->request->method('getParam')->willReturn(''); + + $this->globalScaleService->expects($this->once())->method('getSecondaryRemoteLocation') + ->with($user) ->willReturn($location); - $this->request->method('getServerProtocol') - ->willReturn('https'); $master->expects($this->once())->method('redirectUser') - ->with('user', 'password', 'https://' . $location); + ->with('user', 'password', $location, ['target' => '/', 'params' => []]); - $master->handleLoginRequest('user', 'password'); + $master->handleLoginRequest($user, 'password'); } public function testHandleLoginRequestException(): void { - $location = ''; - $master = $this->getInstance(['queryLookupServer', 'redirectUser']); - $master->expects($this->once())->method('queryLookupServer') - ->willReturn($location); + $user = $this->getUser('user'); + + $master = $this->getInstance(['redirectUser']); + + $this->request->method('getPathInfo')->willReturn(''); + $this->request->method('getParams')->willReturn([]); + $this->request->method('getParam')->willReturn(''); + + $this->globalScaleService->method('getSecondaryRemoteLocation') + ->with($user) + ->willReturn(null); + + $master->expects($this->never())->method('redirectUser'); $this->expectException(HintException::class); + $master->handleLoginRequest($user, 'password'); + } + + public function testHandleLoginRequestIgnoresValidJwtUnlessIgnored(): void { + $user = $this->getUser('user'); + + $master = $this->getInstance(['redirectUser', 'isValidJwt']); + + $this->request->method('getParam')->willReturn('some-jwt'); + + $master->expects($this->once())->method('isValidJwt') + ->with('some-jwt') + ->willReturn(true); + + $this->globalScaleService->expects($this->never())->method('getSecondaryRemoteLocation'); $master->expects($this->never())->method('redirectUser'); - $master->handleLoginRequest('user', 'password'); + + $master->handleLoginRequest($user, 'password'); + } + + public function testHandleLoginRequestIgnoreJwtSkipsJwtCheck(): void { + $location = 'https://nextcloud.com'; + $user = $this->getUser('user'); + + $master = $this->getInstance(['redirectUser', 'isValidJwt']); + + $this->request->method('getPathInfo')->willReturn(''); + $this->request->method('getParams')->willReturn([]); + $this->request->method('getParam')->willReturn('some-jwt'); + + $master->expects($this->never())->method('isValidJwt'); + + $this->globalScaleService->method('getSecondaryRemoteLocation') + ->willReturn($location); + + $master->expects($this->once())->method('redirectUser'); + + $master->handleLoginRequest($user, 'password', true); } public function testCreateJWT(): void { @@ -140,25 +205,4 @@ public function dataTestBuildBasicAuthUrl(): array { ['nextcloud.com', 'user', 'password', 'https://user:password@nextcloud.com'], ]; } - - /** - * @dataProvider dataTestNormalizeLocation - * - * @param $url - * @param $expected - */ - public function testNormalizeLocation(string $url, string $expected): void { - $master = $this->getInstance(); - $this->request->expects($this->any())->method('getServerProtocol')->willReturn('https'); - $result = $this->invokePrivate($master, 'normalizeLocation', [$url]); - $this->assertSame($expected, $result); - } - - public function dataTestNormalizeLocation(): array { - return [ - ['localhost/nextcloud', 'https://localhost/nextcloud'], - ['https://localhost/nextcloud', 'https://localhost/nextcloud'], - ['http://localhost/nextcloud', 'http://localhost/nextcloud'], - ]; - } } diff --git a/tests/unit/lib/Service/GlobalScaleServiceTest.php b/tests/unit/lib/Service/GlobalScaleServiceTest.php new file mode 100644 index 0000000..b6b9599 --- /dev/null +++ b/tests/unit/lib/Service/GlobalScaleServiceTest.php @@ -0,0 +1,328 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->clientService = $this->createMock(IClientService::class); + $this->config = $this->createMock(IConfig::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->secureRandom = $this->createMock(ISecureRandom::class); + $this->gss = $this->getMockBuilder(GlobalSiteSelector::class) + ->disableOriginalConstructor()->getMock(); + $this->lookup = $this->getMockBuilder(Lookup::class) + ->disableOriginalConstructor()->getMock(); + $this->logger = $this->createMock(LoggerInterface::class); + $this->request = $this->createMock(IRequest::class); + $this->userConfig = $this->createMock(IUserConfig::class); + $this->time = $this->createMock(ITimeFactory::class); + } + + public function tearDown(): void { + // Some tests freeze "now" for JWT::decode() via this testing hook (see below); + // always reset it so it can't leak into unrelated tests. + JWT::$timestamp = null; + + parent::tearDown(); + } + + private function getInstance(array $mockMethods = []): GlobalScaleService&MockObject { + return $this->getMockBuilder(GlobalScaleService::class) + ->setConstructorArgs( + [ + $this->appConfig, + $this->clientService, + $this->config, + $this->urlGenerator, + $this->secureRandom, + $this->gss, + $this->lookup, + $this->logger, + $this->request, + $this->userConfig, + $this->time, + ] + )->onlyMethods($mockMethods)->getMock(); + } + + private function getUser(string $uid): IUser&MockObject { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $user->method('getBackend')->willReturn(null); + + return $user; + } + + public function testGetSecondaryRemoteLocationSkipsLocalAdmin(): void { + $this->config->method('getSystemValue')->willReturnMap([ + ['gss.master.admin', [], ['admin']], + ['gss.master.accounts', [], []], + ]); + + $service = $this->getInstance(['queryLookupServer']); + $service->expects($this->never())->method('queryLookupServer'); + $this->userConfig->expects($this->never())->method('setValueArray'); + + $this->expectException(IsLocalAdminException::class); + + $service->getSecondaryRemoteLocation($this->getUser('admin')); + } + + public function testGetSecondaryRemoteLocationSkipsLocalAccount(): void { + $this->config->method('getSystemValue')->willReturnMap([ + ['gss.master.admin', [], []], + ['gss.master.accounts', [], ['localuser']], + ]); + + $service = $this->getInstance(['queryLookupServer']); + $service->expects($this->never())->method('queryLookupServer'); + $this->userConfig->expects($this->never())->method('setValueArray'); + + $this->expectException(IsLocalAdminException::class); + + $service->getSecondaryRemoteLocation($this->getUser('localuser')); + } + + public function testGetSecondaryRemoteLocationKeepsSchemeIfAlreadyPresent(): void { + $service = $this->getInstance(['queryLookupServer']); + $service->method('queryLookupServer')->willReturn('http://nextcloud.example.com'); + + $this->assertSame( + 'http://nextcloud.example.com', + $service->getSecondaryRemoteLocation($this->getUser('regularuser')) + ); + } + + public function testGetSecondaryRemoteLocationReturnsNullWhenNothingFound(): void { + $service = $this->getInstance(['queryLookupServer']); + $service->method('queryLookupServer')->willReturn(''); + + $this->userConfig->expects($this->never())->method('setValueArray'); + + $this->assertNull($service->getSecondaryRemoteLocation($this->getUser('regularuser'))); + } + + public function testGetSecondaryRemoteLocationFallsBackToDiscoveryModule(): void { + FakeUserDiscoveryModule::$location = 'discovered.example.com'; + + $this->config->method('getSystemValueString')->willReturnMap([ + ['gss.user.discovery.module', '', FakeUserDiscoveryModule::class], + ]); + + $service = $this->getInstance(['queryLookupServer']); + $service->method('queryLookupServer')->willReturn(''); + + $this->lookup->expects($this->once())->method('sanitizeUid'); + $this->request->method('getServerProtocol')->willReturn('https'); + + $this->assertSame( + 'https://discovered.example.com', + $service->getSecondaryRemoteLocation($this->getUser('regularuser')) + ); + } + + public function testGetSecondaryRemoteLocationDoesNotUseDiscoveryModuleWhenLookupSucceeds(): void { + FakeUserDiscoveryModule::$location = 'discovered.example.com'; + + $this->config->method('getSystemValueString')->willReturnMap([ + ['gss.user.discovery.module', '', FakeUserDiscoveryModule::class], + ]); + + $service = $this->getInstance(['queryLookupServer']); + $service->method('queryLookupServer')->willReturn('nextcloud.example.com'); + + $this->lookup->expects($this->never())->method('sanitizeUid'); + $this->request->method('getServerProtocol')->willReturn('https'); + + $this->assertSame( + 'https://nextcloud.example.com', + $service->getSecondaryRemoteLocation($this->getUser('regularuser')) + ); + } + + public function testSendToSecondaryThrowsWhenLocationNotFound(): void { + $service = $this->getInstance(['getSecondaryRemoteLocation']); + $service->method('getSecondaryRemoteLocation')->willReturn(null); + + $this->clientService->expects($this->never())->method('newClient'); + + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/No secondary location found/'); + + $service->sendToSecondary($this->getUser('regularuser'), '/apps/oauth2/api/v1/pushtoken', ['uid' => 'regularuser']); + } + + public function testSendToSecondarySendsSignedPayloadWithDefaultExpiry(): void { + $jwtKey = 'jwtkeybutlongenoughforsecurityasthisisnowimportant'; + + $service = $this->getInstance(['getSecondaryRemoteLocation']); + $service->method('getSecondaryRemoteLocation')->willReturn('https://secondary.example.com'); + + $this->config->method('getSystemValueString')->willReturnMap([ + ['gss.jwt.key', '', $jwtKey], + ]); + $this->config->method('getSystemValueBool')->willReturn(false); + $this->time->method('getTime')->willReturn(1000); + + $client = $this->createMock(IClient::class); + $this->clientService->method('newClient')->willReturn($client); + + $capturedOptions = null; + $client->expects($this->once())->method('post') + ->with( + 'https://secondary.example.com/apps/oauth2/api/v1/pushtoken', + $this->callback(function (array $options) use (&$capturedOptions): bool { + $capturedOptions = $options; + return true; + }) + ) + ->willReturn($this->createMock(IResponse::class)); + + $service->sendToSecondary( + $this->getUser('regularuser'), + '/apps/oauth2/api/v1/pushtoken', + ['uid' => 'regularuser', 'token' => 'sometoken'] + ); + + $this->assertSame(['OCS-APIRequest' => 'true'], $capturedOptions['headers']); + $this->assertTrue($capturedOptions['verify']); + $this->assertSame(['format' => 'json'], $capturedOptions['query']); + + // The payload's "exp" (1300) is derived from the mocked getTime() (1000), a + // timestamp long in the past by real wall-clock time: freeze JWT::decode()'s + // notion of "now" to that same mocked time so the token isn't seen as expired. + JWT::$timestamp = 1000; + $decoded = (array)JWT::decode($capturedOptions['body']['jwt'], new Key($jwtKey, 'HS256')); + $this->assertSame('regularuser', $decoded['uid']); + $this->assertSame('sometoken', $decoded['token']); + $this->assertSame(1300, $decoded['exp']); + } + + public function testSendToSecondaryKeepsExplicitExpiry(): void { + $jwtKey = 'jwtkeybutlongenoughforsecurityasthisisnowimportant'; + + $service = $this->getInstance(['getSecondaryRemoteLocation']); + $service->method('getSecondaryRemoteLocation')->willReturn('https://secondary.example.com'); + + $this->config->method('getSystemValueString')->willReturnMap([ + ['gss.jwt.key', '', $jwtKey], + ]); + $this->config->method('getSystemValueBool')->willReturn(false); + + $client = $this->createMock(IClient::class); + $this->clientService->method('newClient')->willReturn($client); + + $capturedOptions = null; + $client->method('post') + ->with($this->anything(), $this->callback(function (array $options) use (&$capturedOptions): bool { + $capturedOptions = $options; + return true; + })) + ->willReturn($this->createMock(IResponse::class)); + + $this->time->expects($this->never())->method('getTime'); + + $service->sendToSecondary( + $this->getUser('regularuser'), + '/apps/oauth2/api/v1/pushtoken', + ['uid' => 'regularuser', 'exp' => 5000] + ); + + // Same as above: the explicit "exp" (5000) is a fictional timestamp far in the + // past by real wall-clock time, so freeze JWT::decode()'s notion of "now" to + // something before it. + JWT::$timestamp = 1000; + $decoded = (array)JWT::decode($capturedOptions['body']['jwt'], new Key($jwtKey, 'HS256')); + $this->assertSame(5000, $decoded['exp']); + } + + public function testSendToSecondaryWrapsNetworkException(): void { + $service = $this->getInstance(['getSecondaryRemoteLocation']); + $service->method('getSecondaryRemoteLocation')->willReturn('https://secondary.example.com'); + + $this->config->method('getSystemValueString')->willReturn('jwtkeybutlongenoughforsecurityasthisisnowimportant'); + $this->config->method('getSystemValueBool')->willReturn(false); + $this->time->method('getTime')->willReturn(1000); + + $client = $this->createMock(IClient::class); + $this->clientService->method('newClient')->willReturn($client); + $client->method('post')->willThrowException(new \Exception('connection refused')); + + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/network issue/'); + + $service->sendToSecondary($this->getUser('regularuser'), '/path', ['uid' => 'regularuser']); + } + + public function testDecodePayloadReturnsOriginalPayload(): void { + $jwtKey = 'jwtkeybutlongenoughforsecurityasthisisnowimportant'; + $this->config->method('getSystemValueString')->willReturnMap([ + ['gss.jwt.key', '', $jwtKey], + ]); + + $service = $this->getInstance(); + $jwt = JWT::encode(['uid' => 'regularuser', 'token' => 'sometoken', 'exp' => time() + 300], $jwtKey, 'HS256'); + + $decoded = $service->decodePayload($jwt); + + $this->assertSame('regularuser', $decoded['uid']); + $this->assertSame('sometoken', $decoded['token']); + } +} + +/** + * Instantiated by GlobalScaleService via Server::get($className), which + * autowires arbitrary classes that aren't explicitly registered - a plain + * constructor-less class is enough, no test double registration needed. + * Deliberately does not implement IUserDiscoveryModule: GlobalScaleService + * only duck-types against it (a docblock hint, no instanceof check), and + * declaring the interface here trips up PHPUnit's test-file discovery. + */ +class FakeUserDiscoveryModule { + public static string $location = ''; + + public function getLocation(array $data): string { + return self::$location; + } +}