From d0ba053a02254b92c12d97103f63b3b304b519b3 Mon Sep 17 00:00:00 2001 From: Maxence Lange Date: Fri, 25 Sep 2026 18:23:10 -0100 Subject: [PATCH 1/2] fix(refactoring): switch back to beforeuserloggerin Signed-off-by: Maxence Lange --- lib/AppInfo/Application.php | 8 ++--- lib/Listeners/UserLoggingIn.php | 9 +++--- lib/Master.php | 29 ++++++++++--------- lib/Service/GlobalScaleService.php | 19 ++++++------ tests/unit/lib/MasterTest.php | 12 ++++---- .../lib/Service/GlobalScaleServiceTest.php | 18 ++++++++---- 6 files changed, 53 insertions(+), 42 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index b425bc1..f9e582a 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -38,10 +38,10 @@ 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; @@ -69,7 +69,7 @@ public function register(IRegistrationContext $context): void { $context->registerCapability(PublicCapabilities::class); // events on master - $context->registerEventListener(UserLoggedInEvent::class, UserLoggingIn::class); + $context->registerEventListener(BeforeUserLoggedInEvent::class, UserLoggingIn::class); $context->registerEventListener( AddContentSecurityPolicyEvent::class, AddContentSecurityPolicyListener::class @@ -222,11 +222,11 @@ private function redirectToSlave(IRequest $request, Master $master, IUserSession $this->logger->debug('new redirectToSlave'); $master->handleLoginRequest( - $user, + $user->getUID(), '', + $user->getBackend(), true, ); - $this->logger->debug('ending redirectToSlave'); } } diff --git a/lib/Listeners/UserLoggingIn.php b/lib/Listeners/UserLoggingIn.php index b6c7a31..4f413cb 100644 --- a/lib/Listeners/UserLoggingIn.php +++ b/lib/Listeners/UserLoggingIn.php @@ -14,11 +14,11 @@ use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\IRequest; -use OCP\User\Events\UserLoggedInEvent; +use OCP\User\Events\BeforeUserLoggedInEvent; use Psr\Log\LoggerInterface; /** - * @template-implements IEventListener + * @template-implements IEventListener */ class UserLoggingIn implements IEventListener { @@ -32,7 +32,7 @@ public function __construct( #[\Override] public function handle(Event $event): void { - if (!$event instanceof UserLoggedInEvent) { + if (!$event instanceof BeforeUserLoggedInEvent) { return; } @@ -48,8 +48,9 @@ public function handle(Event $event): void { $this->logger->debug('new BeforeUserLoggedInEvent event'); $this->master->handleLoginRequest( - $event->getUser(), + $event->getUsername(), $event->getPassword(), + $event->getBackend() ); $this->logger->debug('ending BeforeUserLoggedInEvent event'); diff --git a/lib/Master.php b/lib/Master.php index 90b6f2d..0535f47 100644 --- a/lib/Master.php +++ b/lib/Master.php @@ -18,15 +18,16 @@ 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\ServerVersion; +use OCP\UserInterface; use OCP\Util; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; @@ -65,15 +66,15 @@ public function __construct( * @throws NotFoundExceptionInterface */ public function handleLoginRequest( - IUser $user, + string $uid, ?string $password, + null|IApacheBackend|UserInterface $backend = null, bool $ignoreJwt = false, ): void { - $backend = $user->getBackend(); $this->logger->debug( 'start handle login request', [ - 'uid' => $user->getUID(), + 'uid' => $uid, 'backend' => ($backend === null) ? null : $backend::class ] ); @@ -95,9 +96,14 @@ public function handleLoginRequest( 'params' => $this->request->getParams(), ]; + if ($this->isPath(['/apps/oauth2/authorize'], $target)) { + // oauth2 authorization is done on master + return; + } + $redirectUrl = $this->request->getParam('redirect_url', ''); - $ssoUserData = $this->globalScaleService->getSsoUserData($user); + $ssoUserData = $this->globalScaleService->getSsoUserData($uid, $backend); if ($ssoUserData !== null && $ssoUserData['backend'] === 'saml') { $this->logger->debug('handleLoginRequest: backend is SAML'); @@ -151,19 +157,16 @@ public function handleLoginRequest( } try { - $location = $this->globalScaleService->getSecondaryRemoteLocation($user); + $location = $this->globalScaleService->getSecondaryRemoteLocation($uid, $backend); } catch (IsLocalAdminException) { return; } - if ($location !== null) { - $this->logger->debug( - 'handleLoginRequest: redirecting user: ' . $user->getUID() . ' to ' . $location - ); - $this->redirectUser($user->getUID(), $password, $location, $options); + if ($location !== null) { + $this->logger->debug('handleLoginRequest: redirecting user: ' . $uid . ' to ' . $location); + $this->redirectUser($uid, $password, $location, $options); } else { - $this->logger->debug('handleLoginRequest: Could not find location for account ' . $user->getUID()); - + $this->logger->debug('handleLoginRequest: Could not find location for account ' . $uid); throw new HintException('Unknown Account'); } } diff --git a/lib/Service/GlobalScaleService.php b/lib/Service/GlobalScaleService.php index a8c7306..53fc63f 100644 --- a/lib/Service/GlobalScaleService.php +++ b/lib/Service/GlobalScaleService.php @@ -20,6 +20,7 @@ use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\JWT; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\Key; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\IApacheBackend; use OCP\Config\IUserConfig; use OCP\GlobalScale\IGlobalScaleService; use OCP\Http\Client\IClientService; @@ -30,6 +31,7 @@ use OCP\IUser; use OCP\Security\ISecureRandom; use OCP\Server; +use OCP\UserInterface; use Psr\Log\LoggerInterface; trait TGlobalScaleService { @@ -194,17 +196,17 @@ public function requestGssOcs(string $address, string $route, array $data = [], * * @return array{backend: 'saml'|'oidc', formatted: array, raw: array}|null */ - public function getSsoUserData(IUser $user): ?array { - $uid = $user->getUID(); - + public function getSsoUserData(string $uid, null|IApacheBackend|UserInterface $backend): ?array { $cached = $this->userConfig->getValueArray($uid, Application::APP_ID, ConfigLexicon::SSO_USER_DATA, [], lazy: true); if ($cached !== []) { return $cached; } - $backend = $user->getBackend(); - $data = null; + if ($backend === null) { + return null; + } + $data = null; try { if (class_exists('\OCA\User_SAML\UserBackend') && $backend instanceof \OCA\User_SAML\UserBackend) { @@ -235,12 +237,11 @@ public function getSsoUserData(IUser $user): ?array { * * @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(); + public function getSecondaryRemoteLocation(string $uid, ?UserInterface $backend): ?string { $discoveryData = []; $isSamlOrOidc = false; - $ssoUserData = $this->getSsoUserData($user); + $ssoUserData = $this->getSsoUserData($uid, $backend); if ($ssoUserData !== null) { $isSamlOrOidc = true; $this->logger->debug('getSecondaryRemoteLocation: backend is ' . $ssoUserData['backend']); @@ -315,7 +316,7 @@ protected function normalizeLocation(string $url): string { } public function sendToSecondary(IUser $user, string $path, array $payload): string { - $location = $this->getSecondaryRemoteLocation($user); + $location = $this->getSecondaryRemoteLocation($user->getUID(), $user->getBackend()); if ($location === null) { throw new \Exception('Could not send message to secondary. No secondary location found for user with id: ' . $user->getUID()); } diff --git a/tests/unit/lib/MasterTest.php b/tests/unit/lib/MasterTest.php index 7ced059..afaa0f6 100644 --- a/tests/unit/lib/MasterTest.php +++ b/tests/unit/lib/MasterTest.php @@ -102,13 +102,13 @@ public function testHandleLoginRequest(): void { $this->request->method('getParam')->willReturn(''); $this->globalScaleService->expects($this->once())->method('getSecondaryRemoteLocation') - ->with($user) + ->with($user->getUID(), $user->getBackend()) ->willReturn($location); $master->expects($this->once())->method('redirectUser') ->with('user', 'password', $location, ['target' => '/', 'params' => []]); - $master->handleLoginRequest($user, 'password'); + $master->handleLoginRequest($user->getUID(), 'password', $user->getBackend()); } public function testHandleLoginRequestException(): void { @@ -121,13 +121,13 @@ public function testHandleLoginRequestException(): void { $this->request->method('getParam')->willReturn(''); $this->globalScaleService->method('getSecondaryRemoteLocation') - ->with($user) + ->with($user->getUID(), $user->getBackend()) ->willReturn(null); $master->expects($this->never())->method('redirectUser'); $this->expectException(HintException::class); - $master->handleLoginRequest($user, 'password'); + $master->handleLoginRequest($user->getUID(), 'password', $user->getBackend()); } public function testHandleLoginRequestIgnoresValidJwtUnlessIgnored(): void { @@ -144,7 +144,7 @@ public function testHandleLoginRequestIgnoresValidJwtUnlessIgnored(): void { $this->globalScaleService->expects($this->never())->method('getSecondaryRemoteLocation'); $master->expects($this->never())->method('redirectUser'); - $master->handleLoginRequest($user, 'password'); + $master->handleLoginRequest($user->getUID(), 'password', $user->getBackend()); } public function testHandleLoginRequestIgnoreJwtSkipsJwtCheck(): void { @@ -164,7 +164,7 @@ public function testHandleLoginRequestIgnoreJwtSkipsJwtCheck(): void { $master->expects($this->once())->method('redirectUser'); - $master->handleLoginRequest($user, 'password', true); + $master->handleLoginRequest($user->getUID(), 'password', $user->getBackend(), true); } public function testCreateJWT(): void { diff --git a/tests/unit/lib/Service/GlobalScaleServiceTest.php b/tests/unit/lib/Service/GlobalScaleServiceTest.php index b6b9599..738cf9d 100644 --- a/tests/unit/lib/Service/GlobalScaleServiceTest.php +++ b/tests/unit/lib/Service/GlobalScaleServiceTest.php @@ -106,7 +106,8 @@ public function testGetSecondaryRemoteLocationSkipsLocalAdmin(): void { $this->expectException(IsLocalAdminException::class); - $service->getSecondaryRemoteLocation($this->getUser('admin')); + $admin = $this->getUser('admin'); + $service->getSecondaryRemoteLocation($admin->getUID(), $admin->getBackend()); } public function testGetSecondaryRemoteLocationSkipsLocalAccount(): void { @@ -121,16 +122,18 @@ public function testGetSecondaryRemoteLocationSkipsLocalAccount(): void { $this->expectException(IsLocalAdminException::class); - $service->getSecondaryRemoteLocation($this->getUser('localuser')); + $user = $this->getUser('localuser'); + $service->getSecondaryRemoteLocation($user->getUID(), $user->getBackend()); } public function testGetSecondaryRemoteLocationKeepsSchemeIfAlreadyPresent(): void { $service = $this->getInstance(['queryLookupServer']); $service->method('queryLookupServer')->willReturn('http://nextcloud.example.com'); + $user = $this->getUser('regularuser'); $this->assertSame( 'http://nextcloud.example.com', - $service->getSecondaryRemoteLocation($this->getUser('regularuser')) + $service->getSecondaryRemoteLocation($user->getUID(), $user->getBackend()) ); } @@ -140,7 +143,8 @@ public function testGetSecondaryRemoteLocationReturnsNullWhenNothingFound(): voi $this->userConfig->expects($this->never())->method('setValueArray'); - $this->assertNull($service->getSecondaryRemoteLocation($this->getUser('regularuser'))); + $user = $this->getUser('regularuser'); + $this->assertNull($service->getSecondaryRemoteLocation($user->getUID(), $user->getBackend())); } public function testGetSecondaryRemoteLocationFallsBackToDiscoveryModule(): void { @@ -156,9 +160,10 @@ public function testGetSecondaryRemoteLocationFallsBackToDiscoveryModule(): void $this->lookup->expects($this->once())->method('sanitizeUid'); $this->request->method('getServerProtocol')->willReturn('https'); + $user = $this->getUser('regularuser'); $this->assertSame( 'https://discovered.example.com', - $service->getSecondaryRemoteLocation($this->getUser('regularuser')) + $service->getSecondaryRemoteLocation($user->getUID(), $user->getBackend()) ); } @@ -175,9 +180,10 @@ public function testGetSecondaryRemoteLocationDoesNotUseDiscoveryModuleWhenLooku $this->lookup->expects($this->never())->method('sanitizeUid'); $this->request->method('getServerProtocol')->willReturn('https'); + $user = $this->getUser('regularuser'); $this->assertSame( 'https://nextcloud.example.com', - $service->getSecondaryRemoteLocation($this->getUser('regularuser')) + $service->getSecondaryRemoteLocation($user->getUID(), $user->getBackend()) ); } From da9984201a44b180285512780a67c1d56af46f40 Mon Sep 17 00:00:00 2001 From: Maxence Lange Date: Wed, 30 Sep 2026 08:30:04 -0100 Subject: [PATCH 2/2] feat(oauth2): manage login flow Signed-off-by: Maxence Lange --- appinfo/routes.php | 5 + lib/AppInfo/Application.php | 3 +- lib/ConfigLexicon.php | 2 + lib/Controller/MasterController.php | 27 +++ lib/Master.php | 36 ++-- lib/Service/OAuth2Service.php | 302 ++++++++++++++++++++++++++++ lib/Service/ToolsService.php | 28 +++ tests/psalm-baseline.xml | 15 ++ tests/unit/lib/MasterTest.php | 16 +- 9 files changed, 412 insertions(+), 22 deletions(-) create mode 100644 lib/Service/OAuth2Service.php create mode 100644 lib/Service/ToolsService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index e05734b..49eda95 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -22,6 +22,11 @@ 'url' => '/autologout', 'verb' => 'GET' ], + [ + 'name' => 'Master#finalizeOAuthFlow', + 'url' => '/oauth2/login/flow', + 'verb' => 'POST' + ], [ 'name' => 'Slave#findFile', 'url' => '/gf/{token}/{fileId}', diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index f9e582a..3bce760 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -210,7 +210,8 @@ private function redirectToSlave(IRequest $request, Master $master, IUserSession || str_starts_with($uri, '/apps/globalsiteselector/autologout') || str_starts_with($uri, '/apps/user_saml/saml/sls') || str_starts_with($uri, '/apps/user_oidc/sls') - || str_starts_with($uri, '/login/flow') + // we keep hand on /login/flow/grant that will be emulated by the app + || (str_starts_with($uri, '/login/flow') && !str_starts_with($uri, '/login/flow/grant')) ) { return; } diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index bf69f1d..589f15e 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 MANAGE_OAUTH2 = 'manageOAuth2'; public const SSO_USER_DATA = 'ssoUserData'; #[\Override] @@ -34,6 +35,7 @@ public function getAppConfigs(): array { new Entry(key: self::GS_TOKENS, type: ValueType::ARRAY, defaultRaw: [], definition: 'list of token+host to navigate through GlobalScale', lazy: true), new Entry(key: self::LOCAL_TOKEN, type: ValueType::STRING, defaultRaw: '', definition: 'local token to id instance within GlobalScale'), new Entry(key: self::REDIRECT_WEBDAV, type: ValueType::BOOL, defaultRaw: false, definition: 'redirect WebDAV request on Master to Slaves', lazy: false), + new Entry(key: self::MANAGE_OAUTH2, type: ValueType::BOOL, defaultRaw: false, definition: 'manage OAuth2 requests from Master', lazy: false), ]; } diff --git a/lib/Controller/MasterController.php b/lib/Controller/MasterController.php index 9947f20..656cfca 100644 --- a/lib/Controller/MasterController.php +++ b/lib/Controller/MasterController.php @@ -10,12 +10,16 @@ use OCA\GlobalSiteSelector\AppInfo\Application; use OCA\GlobalSiteSelector\GlobalSiteSelector; use OCA\GlobalSiteSelector\Master; +use OCA\GlobalSiteSelector\Service\OAuth2Service; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\JWT; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\Key; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UseSession; use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; use OCP\AppFramework\OCSController; use OCP\IRequest; use OCP\IURLGenerator; @@ -34,11 +38,34 @@ public function __construct( IRequest $request, private readonly IURLGenerator $urlGenerator, private readonly GlobalSiteSelector $gss, + private readonly OAuth2Service $oauth2Service, private readonly LoggerInterface $logger, ) { parent::__construct($appName, $request); } + #[PublicPage] + #[FrontpageRoute(verb: 'POST', url: '/test')] + public function finalizeOAuthFlow( + string $stateToken, + string $clientIdentifier = '', + string $providedRedirectUri = '', + ): Response { + try { + return $this->oauth2Service->finalizeOAuth2( + $stateToken, + $clientIdentifier, + $providedRedirectUri, + $this->request->getHeader('user-agent'), + ); + } catch (\Exception $e) { + $this->logger->warning('fail to manage oauth2', ['exception' => $e]); + $response = new Response(); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + } + #[PublicPage] #[NoCSRFRequired] #[UseSession] diff --git a/lib/Master.php b/lib/Master.php index 0535f47..09acd40 100644 --- a/lib/Master.php +++ b/lib/Master.php @@ -15,6 +15,8 @@ use OCA\GlobalSiteSelector\AppInfo\Application; use OCA\GlobalSiteSelector\Exceptions\IsLocalAdminException; use OCA\GlobalSiteSelector\Service\GlobalScaleService; +use OCA\GlobalSiteSelector\Service\OAuth2Service; +use OCA\GlobalSiteSelector\Service\ToolsService; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\JWT; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\Key; use OCP\AppFramework\Http\StandaloneTemplateResponse; @@ -23,6 +25,7 @@ use OCP\Http\Client\IClientService; use OCP\IAppConfig; use OCP\IConfig; +use OCP\IInitialStateService; use OCP\IRequest; use OCP\ISession; use OCP\Security\ICrypto; @@ -45,6 +48,7 @@ public function __construct( private readonly ISession $session, private readonly GlobalSiteSelector $gss, private readonly ICrypto $crypto, + private readonly IInitialStateService $initialStateService, private readonly LoginFlowV2Service $loginFlowV2Service, private readonly ServerVersion $serverVersion, private readonly Lookup $lookup, @@ -54,6 +58,8 @@ public function __construct( private readonly IConfig $config, private readonly LoggerInterface $logger, private readonly GlobalScaleService $globalScaleService, + private readonly ToolsService $toolsService, + private readonly OAuth2Service $oauth2Service, ) { } @@ -82,7 +88,6 @@ public function handleLoginRequest( /** ignoring request from slave with valid jwt */ if (!$ignoreJwt && $this->isValidJwt($this->request->getParam('jwt', ''))) { $this->logger->debug('ignore request with valid jwt'); - return; } @@ -96,8 +101,11 @@ public function handleLoginRequest( 'params' => $this->request->getParams(), ]; - if ($this->isPath(['/apps/oauth2/authorize'], $target)) { - // oauth2 authorization is done on master + if ($this->toolsService->isPath(['/apps/globalsiteselector/oauth2/login/flow'], $target)) { + return; + } + + if ($this->oauth2Service->manageOauth2($uid, $target)) { return; } @@ -151,7 +159,7 @@ public function handleLoginRequest( $this->logger->debug('handleLoginRequest: backend is not SAML or OIDC'); } - if ($this->isPath(['/login/flow', '/login/v2/flow'], $redirectUrl ?? '')) { + if ($this->toolsService->isPath(['/login/flow', '/login/v2/flow'], $redirectUrl ?? '')) { $options['target'] = $redirectUrl; $this->logger->debug('handleLoginRequest: overriding target with slave flow path: ' . $options['target']); } @@ -190,7 +198,7 @@ protected function redirectUser($uid, $password, $location, array $options = []) '/mirall|csyncoC/', // <-- Support also not compliant Desktop Clients '/^.*\(Android\)$/' ] - ) || $this->isPath(['/login/flow/grant', '/login/v2/grant'], $options['target'] ?? ''); + ) || $this->toolsService->isPath(['/login/flow/grant', '/login/v2/grant'], $options['target'] ?? ''); $requestUri = $this->request->getRequestUri(); @@ -385,25 +393,15 @@ private function forceRelativeUrl(string $url): string { return $url; } - private function isPath(array $search, string $path): bool { - if ($path === '') { - return false; - } - - foreach ($search as $entry) { - if (str_starts_with($path, (string)$entry) || str_starts_with($path, '/index.php' . $entry)) { - return true; - } - } - - return false; - } - private function handleFlowDone(bool $result): StandaloneTemplateResponse { if ($result) { // login flow v2 templates were moved in NC33 if ($this->serverVersion->getMajorVersion() >= 33) { + Util::addScript('core', 'common'); + Util::addScript('core', 'main'); + Util::addTranslations('core'); Util::addScript('core', 'login_flow'); + $this->initialStateService->provideInitialState('core', 'loginFlowState', 'done'); return new StandaloneTemplateResponse('core', 'loginflow', renderAs: 'guest'); } diff --git a/lib/Service/OAuth2Service.php b/lib/Service/OAuth2Service.php new file mode 100644 index 0000000..dff3f65 --- /dev/null +++ b/lib/Service/OAuth2Service.php @@ -0,0 +1,302 @@ +appManager->isAppLoaded('oauth2') + || !$this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::MANAGE_OAUTH2)) { + return false; + } + + // oauth2 authorization in initialized on master + if ($this->toolsService->isPath(['/apps/oauth2/authorize'], $target)) { + return true; + } + + // we emulate login flow grant + if ($this->toolsService->isPath(['/login/flow/grant'], $target) + && $this->userSession->isLoggedIn()) { + echo $this->handleFlowGrant( + $this->request->getParam('stateToken', ''), + $this->request->getParam('clientIdentifier', ''), + (int)$this->request->getParam('direct', 0), + $this->request->getParam('providedRedirectUri', ''), + $this->request->getHeader('user-agent'), + $uid, + )->render(); + die(); + } + + return false; + } + + /** + * use of ReflectionProperty can be removed once we hit min-version=36 + */ + private function handleFlowGrant( + string $stateToken, + string $clientIdentifier, + int $direct, + string $providedRedirectUri, + string $userAgent, + string $userId, + ): Response { + if (!$this->isValidToken($stateToken)) { + $this->logger->warning('State token does not match'); + return new StandaloneTemplateResponse( + 'core', + '403', + ['message' => 'State token does not match'], + 'guest', + Http::STATUS_FORBIDDEN + ); + } + + $clientName = ($userAgent !== '') ? $userAgent : 'unknown'; + + $client = null; + if ($clientIdentifier !== '') { + $client = $this->clientMapper->getByIdentifier($clientIdentifier); + $clientName = ((new ReflectionProperty($client, 'name'))->isPublic()) ? $client->name : $client->getName(); + } + + $csp = new ContentSecurityPolicy(); + if ($client) { + $csp->addAllowedFormActionDomain(((new ReflectionProperty($client, 'redirectUri'))->isPublic()) ? $client->redirectUri : $client->getRedirectUri()); + } else { + $csp->addAllowedFormActionDomain('nc://*'); + } + + $this->initialStateService->provideInitialState('core', 'loginFlowState', 'grant'); + $this->initialStateService->provideInitialState('core', 'loginFlowGrant', [ + // the last step is host by the app instead of core to keep current session active + 'actionUrl' => $this->urlGenerator->linkToRouteAbsolute('globalsiteselector.Master.finalizeOAuthFlow'), + 'client' => $clientName, + 'clientIdentifier' => $clientIdentifier, + 'instanceName' => $this->defaults->getName(), + 'stateToken' => $stateToken, + 'serverHost' => $this->getServerPath(), + 'oauthState' => $this->session->get('oauth.state'), + 'direct' => $direct, + 'providedRedirectUri' => $providedRedirectUri, + 'userDisplayName' => $userId, + 'userId' => $userId, + ]); + + // we need to load basic scripts + Util::addScript('core', 'common'); + Util::addScript('core', 'main'); + Util::addTranslations('core'); + Util::addScript('core', 'login_flow'); + $response = new TemplateResponse('core', 'loginflow', renderAs: 'guest'); + $response->setContentSecurityPolicy($csp); + + return $response; + } + + /** + * use of ReflectionProperty can be removed once we hit min-version=36 + */ + public function finalizeOAuth2( + string $stateToken, + string $clientIdentifier, + string $providedRedirectUri, + string $userAgent, + ): Response { + if (!$this->appManager->isAppLoaded('oauth2') + || !$this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::MANAGE_OAUTH2)) { + throw new \Exception('feature not available'); + } + + if (!$this->isValidToken($stateToken)) { + $this->session->remove(ClientFlowLoginController::STATE_NAME); + throw new \Exception('invalid token'); + } + + $this->session->remove(ClientFlowLoginController::STATE_NAME); + + $sessionId = $this->session->getId(); + $sessionToken = $this->tokenProvider->getToken($sessionId); + $loginName = $sessionToken->getLoginName(); + $uid = $sessionToken->getUID(); + if ($uid === '') { + throw new \Exception('missing uid'); + } + + try { + $password = $this->tokenProvider->getPassword($sessionToken, $sessionId); + } catch (PasswordlessTokenException) { + $password = null; + } + + $clientName = ($userAgent !== '') ? $userAgent : 'unknown'; + $client = false; + if ($clientIdentifier !== '') { + $client = $this->clientMapper->getByIdentifier($clientIdentifier); + $clientName = ((new ReflectionProperty($client, 'name'))->isPublic()) ? $client->name : $client->getName(); + } + + $token = $this->random->generate(72, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); + $generatedToken = $this->tokenProvider->generateToken( + $token, + $uid, + $loginName, + $password, + $clientName, + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER + ); + + if ($client) { + $code = $this->random->generate(128, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); + $accessToken = new AccessToken(); + if ((new ReflectionProperty($accessToken, 'clientId'))->isPublic()) { + $accessToken->clientId = ((new ReflectionProperty($client, 'id'))->isPublic()) ? $client->id : $client->getId(); + $accessToken->encryptedToken = $this->crypto->encrypt($token, $code); + $accessToken->hashedCode = hash('sha512', $code); + $accessToken->tokenId = $generatedToken->getId(); + $accessToken->codeCreatedAt = $this->timeFactory->now()->getTimestamp(); + } else { + $accessToken->setClientId(((new ReflectionProperty($client, 'id'))->isPublic()) ? $client->id : $client->getId()); + $accessToken->setEncryptedToken($this->crypto->encrypt($token, $code)); + $accessToken->setHashedCode(hash('sha512', $code)); + $accessToken->setTokenId($generatedToken->getId()); + $accessToken->setCodeCreatedAt($this->timeFactory->now()->getTimestamp()); + } + $this->accessTokenMapper->insert($accessToken); + + $enableOcClients = $this->config->getSystemValueBool('oauth2.enable_oc_clients', false); + + $redirectUri = ((new ReflectionProperty($client, 'redirectUri'))->isPublic()) ? $client->redirectUri : $client->getRedirectUri(); + if ($enableOcClients && $redirectUri === 'http://localhost:*') { + // Sanity check untrusted redirect URI provided by the client first + if (!preg_match('/^http:\/\/localhost:[0-9]+$/', $providedRedirectUri)) { + throw new Exception('fail sanity check on redirect uri'); + } + + $redirectUri = $providedRedirectUri; + } + + if (parse_url($redirectUri, PHP_URL_QUERY)) { + $redirectUri .= '&'; + } else { + $redirectUri .= '?'; + } + + $redirectUri .= sprintf( + 'state=%s&code=%s', + urlencode($this->session->get('oauth.state')), + urlencode($code) + ); + $this->session->remove('oauth.state'); + } else { + $redirectUri = 'nc://login/server:' . $this->getServerPath() . '&user:' . urlencode($loginName) . '&password:' . urlencode($token); + + // Clear the token from the login here + $this->tokenProvider->invalidateToken($sessionId); + } + + $this->eventDispatcher->dispatchTyped(new AppPasswordCreatedEvent($generatedToken)); + + return new RedirectResponse($redirectUri); + } + + private function isValidToken(string $stateToken): bool { + $currentToken = $this->session->get(ClientFlowLoginController::STATE_NAME); + if (!is_string($currentToken)) { + return false; + } + return hash_equals($currentToken, $stateToken); + } + + private function getServerPath(): string { + $serverPostfix = ''; + $requestUri = $this->request->getRequestUri(); + + if (str_contains($requestUri, '/index.php')) { + $serverPostfix = substr($requestUri, 0, strpos($requestUri, '/index.php')); + } elseif (str_contains($requestUri, '/login/flow')) { + $serverPostfix = substr($requestUri, 0, strpos($requestUri, '/login/flow')); + } + + $protocol = $this->request->getServerProtocol(); + + if ($protocol !== 'https') { + $xForwardedProto = $this->request->getHeader('X-Forwarded-Proto'); + $xForwardedSSL = $this->request->getHeader('X-Forwarded-Ssl'); + if ($xForwardedProto === 'https' || $xForwardedSSL === 'on') { + $protocol = 'https'; + } + } + + return $protocol . '://' . $this->request->getServerHost() . $serverPostfix; + } +} diff --git a/lib/Service/ToolsService.php b/lib/Service/ToolsService.php new file mode 100644 index 0000000..7911f65 --- /dev/null +++ b/lib/Service/ToolsService.php @@ -0,0 +1,28 @@ + + + + accessTokenMapper]]> + clientMapper]]> + clientMapper]]> + + + + + + + + + + \OC\User\Database diff --git a/tests/unit/lib/MasterTest.php b/tests/unit/lib/MasterTest.php index afaa0f6..d4e8c4b 100644 --- a/tests/unit/lib/MasterTest.php +++ b/tests/unit/lib/MasterTest.php @@ -13,12 +13,15 @@ use OCA\GlobalSiteSelector\Lookup; use OCA\GlobalSiteSelector\Master; use OCA\GlobalSiteSelector\Service\GlobalScaleService; +use OCA\GlobalSiteSelector\Service\OAuth2Service; +use OCA\GlobalSiteSelector\Service\ToolsService; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\JWT; use OCA\GlobalSiteSelector\Vendor\Firebase\JWT\Key; use OCP\HintException; use OCP\Http\Client\IClientService; use OCP\IAppConfig; use OCP\IConfig; +use OCP\IInitialStateService; use OCP\IRequest; use OCP\ISession; use OCP\IUser; @@ -32,6 +35,7 @@ class MasterTest extends TestCase { private GlobalSiteSelector&MockObject $gss; private ICrypto&MockObject $crypto; + private IInitialStateService&MockObject $initialStateService; private Lookup&MockObject $lookup; private IRequest&MockObject $request; private IClientService&MockObject $clientService; @@ -41,6 +45,8 @@ class MasterTest extends TestCase { private ISession&MockObject $session; private LoginFlowV2Service&MockObject $loginflow; private GlobalScaleService&MockObject $globalScaleService; + private ToolsService&MockObject $toolsService; + private OAuth2Service&MockObject $oauth2Service; private ServerVersion $serverVersion; public function setUp(): void { @@ -49,6 +55,7 @@ public function setUp(): void { $this->gss = $this->getMockBuilder(GlobalSiteSelector::class) ->disableOriginalConstructor()->getMock(); $this->crypto = $this->createMock(ICrypto::class); + $this->initialStateService = $this->createMock(IInitialStateService::class); $this->lookup = $this->getMockBuilder(Lookup::class) ->disableOriginalConstructor()->getMock(); $this->loginflow = $this->createMock(LoginFlowV2Service::class); @@ -59,8 +66,10 @@ 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(); + $this->globalScaleService = $this->getMockBuilder(GlobalScaleService::class)->disableOriginalConstructor()->getMock(); + $this->toolsService = $this->createMock(ToolsService::class); + $this->oauth2Service = $this->createMock(OAuth2Service::class); + } private function getInstance(array $mockMethods = []): Master&MockObject { @@ -70,6 +79,7 @@ private function getInstance(array $mockMethods = []): Master&MockObject { $this->session, $this->gss, $this->crypto, + $this->initialStateService, $this->loginflow, $this->serverVersion, $this->lookup, @@ -79,6 +89,8 @@ private function getInstance(array $mockMethods = []): Master&MockObject { $this->config, $this->logger, $this->globalScaleService, + $this->toolsService, + $this->oauth2Service, ] )->onlyMethods($mockMethods)->getMock(); }