From 86a496d58980cc3dd578368c9d8e5f951ec01f17 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Fri, 1 Mar 2024 18:37:47 +0100 Subject: [PATCH 1/2] fix(Session): avoid password confirmation on SSO SSO backends like SAML and OIDC tried a trick to suppress password confirmations as they are not possible by design. At least for SAML it was not reliable when existing user backends where used as user repositories. Now we are setting a special scope with the token, and also make sure that the scope is taken over when tokens are regenerated. Signed-off-by: Arthur Schiwon --- core/Controller/OCJSController.php | 5 +- .../DependencyInjection/DIContainer.php | 3 +- .../PasswordConfirmationMiddleware.php | 26 ++++++- .../Token/PublicKeyTokenProvider.php | 1 + lib/private/Template/JSConfigHelper.php | 73 ++++++++++--------- lib/private/TemplateLayout.php | 4 +- lib/private/legacy/OC_User.php | 10 ++- ...sswordConfirmationMiddlewareController.php | 4 + .../PasswordConfirmationMiddlewareTest.php | 60 ++++++++++++++- 9 files changed, 143 insertions(+), 43 deletions(-) diff --git a/core/Controller/OCJSController.php b/core/Controller/OCJSController.php index e909343912574..d20665ccfeab7 100644 --- a/core/Controller/OCJSController.php +++ b/core/Controller/OCJSController.php @@ -29,6 +29,7 @@ namespace OC\Core\Controller; use bantu\IniGetWrapper\IniGetWrapper; +use OC\Authentication\Token\IProvider; use OC\CapabilitiesManager; use OC\Template\JSConfigHelper; use OCP\App\IAppManager; @@ -64,6 +65,7 @@ public function __construct( IURLGenerator $urlGenerator, CapabilitiesManager $capabilitiesManager, IInitialStateService $initialStateService, + IProvider $tokenProvider, ) { parent::__construct($appName, $request); @@ -78,7 +80,8 @@ public function __construct( $iniWrapper, $urlGenerator, $capabilitiesManager, - $initialStateService + $initialStateService, + $tokenProvider ); } diff --git a/lib/private/AppFramework/DependencyInjection/DIContainer.php b/lib/private/AppFramework/DependencyInjection/DIContainer.php index a0951e7552323..bbbbca4e00fca 100644 --- a/lib/private/AppFramework/DependencyInjection/DIContainer.php +++ b/lib/private/AppFramework/DependencyInjection/DIContainer.php @@ -276,7 +276,8 @@ public function __construct(string $appName, array $urlParams = [], ServerContai $c->get(IControllerMethodReflector::class), $c->get(ISession::class), $c->get(IUserSession::class), - $c->get(ITimeFactory::class) + $c->get(ITimeFactory::class), + $c->get(\OC\Authentication\Token\IProvider::class), ) ); $dispatcher->registerMiddleware( diff --git a/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php b/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php index 351f47ea92459..27328e17b03c7 100644 --- a/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php +++ b/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php @@ -25,12 +25,17 @@ use OC\AppFramework\Middleware\Security\Exceptions\NotConfirmedException; use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Authentication\Token\IProvider; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; use OCP\AppFramework\Middleware; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; use OCP\ISession; use OCP\IUserSession; +use OCP\Session\Exceptions\SessionNotAvailableException; use OCP\User\Backend\IPasswordConfirmationBackend; use ReflectionMethod; @@ -45,6 +50,7 @@ class PasswordConfirmationMiddleware extends Middleware { private $timeFactory; /** @var array */ private $excludedUserBackEnds = ['user_saml' => true, 'user_globalsiteselector' => true]; + private IProvider $tokenProvider; /** * PasswordConfirmationMiddleware constructor. @@ -57,11 +63,14 @@ class PasswordConfirmationMiddleware extends Middleware { public function __construct(ControllerMethodReflector $reflector, ISession $session, IUserSession $userSession, - ITimeFactory $timeFactory) { + ITimeFactory $timeFactory, + IProvider $tokenProvider, + ) { $this->reflector = $reflector; $this->session = $session; $this->userSession = $userSession; $this->timeFactory = $timeFactory; + $this->tokenProvider = $tokenProvider; } /** @@ -86,8 +95,21 @@ public function beforeController($controller, $methodName) { $backendClassName = $user->getBackendClassName(); } + try { + $sessionId = $this->session->getId(); + $token = $this->tokenProvider->getToken($sessionId); + } catch (SessionNotAvailableException|InvalidTokenException|WipeTokenException|ExpiredTokenException) { + // States we do not deal with here. + return; + } + $scope = $token->getScopeAsArray(); + if (isset($scope['sso-based-login']) && $scope['sso-based-login'] === true) { + // Users logging in from SSO backends cannot confirm their password by design + return; + } + $lastConfirm = (int) $this->session->get('last-password-confirm'); - // we can't check the password against a SAML backend, so skip password confirmation in this case + // TODO: confirm excludedUserBackEnds can go away and remove it if (!isset($this->excludedUserBackEnds[$backendClassName]) && $lastConfirm < ($this->timeFactory->getTime() - (30 * 60 + 15))) { // allow 15 seconds delay throw new NotConfirmedException(); } diff --git a/lib/private/Authentication/Token/PublicKeyTokenProvider.php b/lib/private/Authentication/Token/PublicKeyTokenProvider.php index 3a15ba006d461..8a6b0b6fed735 100644 --- a/lib/private/Authentication/Token/PublicKeyTokenProvider.php +++ b/lib/private/Authentication/Token/PublicKeyTokenProvider.php @@ -265,6 +265,7 @@ public function renewSessionToken(string $oldSessionId, string $sessionId): OCPI OCPIToken::TEMPORARY_TOKEN, $token->getRemember() ); + $newToken->setScope($token->getScopeAsArray()); $this->cacheToken($newToken); $this->cacheInvalidHash($token->getToken()); diff --git a/lib/private/Template/JSConfigHelper.php b/lib/private/Template/JSConfigHelper.php index 8cba93f1f4e69..cca3d64654445 100644 --- a/lib/private/Template/JSConfigHelper.php +++ b/lib/private/Template/JSConfigHelper.php @@ -34,10 +34,14 @@ namespace OC\Template; use bantu\IniGetWrapper\IniGetWrapper; +use OC\Authentication\Token\IProvider; use OC\CapabilitiesManager; use OC\Share\Share; use OCP\App\AppPathNotFoundException; use OCP\App\IAppManager; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; use OCP\Constants; use OCP\Defaults; use OCP\Files\FileInfo; @@ -49,47 +53,29 @@ use OCP\ISession; use OCP\IURLGenerator; use OCP\IUser; +use OCP\Session\Exceptions\SessionNotAvailableException; use OCP\User\Backend\IPasswordConfirmationBackend; use OCP\Util; class JSConfigHelper { - protected IL10N $l; - protected Defaults $defaults; - protected IAppManager $appManager; - protected ISession $session; - protected ?IUser $currentUser; - protected IConfig $config; - protected IGroupManager $groupManager; - protected IniGetWrapper $iniWrapper; - protected IURLGenerator $urlGenerator; - protected CapabilitiesManager $capabilitiesManager; - protected IInitialStateService $initialStateService; /** @var array user back-ends excluded from password verification */ private $excludedUserBackEnds = ['user_saml' => true, 'user_globalsiteselector' => true]; - public function __construct(IL10N $l, - Defaults $defaults, - IAppManager $appManager, - ISession $session, - ?IUser $currentUser, - IConfig $config, - IGroupManager $groupManager, - IniGetWrapper $iniWrapper, - IURLGenerator $urlGenerator, - CapabilitiesManager $capabilitiesManager, - IInitialStateService $initialStateService) { - $this->l = $l; - $this->defaults = $defaults; - $this->appManager = $appManager; - $this->session = $session; - $this->currentUser = $currentUser; - $this->config = $config; - $this->groupManager = $groupManager; - $this->iniWrapper = $iniWrapper; - $this->urlGenerator = $urlGenerator; - $this->capabilitiesManager = $capabilitiesManager; - $this->initialStateService = $initialStateService; + public function __construct( + protected IL10N $l, + protected Defaults $defaults, + protected IAppManager $appManager, + protected ISession $session, + protected ?IUser $currentUser, + protected IConfig $config, + protected IGroupManager $groupManager, + protected IniGetWrapper $iniWrapper, + protected IURLGenerator $urlGenerator, + protected CapabilitiesManager $capabilitiesManager, + protected IInitialStateService $initialStateService, + protected IProvider $tokenProvider, + ) { } public function getConfig(): string { @@ -155,9 +141,13 @@ public function getConfig(): string { } if ($this->currentUser instanceof IUser) { - $lastConfirmTimestamp = $this->session->get('last-password-confirm'); - if (!is_int($lastConfirmTimestamp)) { - $lastConfirmTimestamp = 0; + if ($this->canUserValidatePassword()) { + $lastConfirmTimestamp = $this->session->get('last-password-confirm'); + if (!is_int($lastConfirmTimestamp)) { + $lastConfirmTimestamp = 0; + } + } else { + $lastConfirmTimestamp = PHP_INT_MAX; } } else { $lastConfirmTimestamp = 0; @@ -311,4 +301,15 @@ public function getConfig(): string { return $result; } + + protected function canUserValidatePassword(): bool { + try { + $token = $this->tokenProvider->getToken($this->session->getId()); + } catch (ExpiredTokenException|WipeTokenException|InvalidTokenException|SessionNotAvailableException) { + // actually we do not know, so we fall back to this statement + return true; + } + $scope = $token->getScopeAsArray(); + return !isset($scope['sso-based-login']) || $scope['sso-based-login'] === false; + } } diff --git a/lib/private/TemplateLayout.php b/lib/private/TemplateLayout.php index 96d0ae3e5174d..7835e974b8598 100644 --- a/lib/private/TemplateLayout.php +++ b/lib/private/TemplateLayout.php @@ -43,6 +43,7 @@ namespace OC; use bantu\IniGetWrapper\IniGetWrapper; +use OC\Authentication\Token\IProvider; use OC\Search\SearchQuery; use OC\Template\CSSResourceLocator; use OC\Template\JSConfigHelper; @@ -259,7 +260,8 @@ public function __construct($renderAs, $appId = '') { \OC::$server->get(IniGetWrapper::class), \OC::$server->getURLGenerator(), \OC::$server->getCapabilitiesManager(), - \OCP\Server::get(IInitialStateService::class) + \OCP\Server::get(IInitialStateService::class), + \OCP\Server::get(IProvider::class), ); $config = $jsConfigHelper->getConfig(); if (\OC::$server->getContentSecurityPolicyNonceManager()->browserSupportsCspV3()) { diff --git a/lib/private/legacy/OC_User.php b/lib/private/legacy/OC_User.php index dc172ba4144f9..7cf0b3487a92e 100644 --- a/lib/private/legacy/OC_User.php +++ b/lib/private/legacy/OC_User.php @@ -35,7 +35,7 @@ * along with this program. If not, see * */ - +use OC\Authentication\Token\IProvider; use OC\User\LoginException; use OCP\EventDispatcher\IEventDispatcher; use OCP\IGroupManager; @@ -196,6 +196,14 @@ public static function loginWithApache(\OCP\Authentication\IApacheBackend $backe $userSession->createSessionToken($request, $uid, $uid, $password); $userSession->createRememberMeToken($userSession->getUser()); + + if (empty($password)) { + $tokenProvider = \OC::$server->get(IProvider::class); + $token = $tokenProvider->getToken($userSession->getSession()->getId()); + $token->setScope(['sso-based-login' => true]); + $tokenProvider->updateToken($token); + } + // setup the filesystem OC_Util::setupFS($uid); // first call the post_login hooks, the login-process needs to be diff --git a/tests/lib/AppFramework/Middleware/Security/Mock/PasswordConfirmationMiddlewareController.php b/tests/lib/AppFramework/Middleware/Security/Mock/PasswordConfirmationMiddlewareController.php index 5b83575f7117c..941906d8bb654 100644 --- a/tests/lib/AppFramework/Middleware/Security/Mock/PasswordConfirmationMiddlewareController.php +++ b/tests/lib/AppFramework/Middleware/Security/Mock/PasswordConfirmationMiddlewareController.php @@ -46,4 +46,8 @@ public function testAnnotation() { #[PasswordConfirmationRequired] public function testAttribute() { } + + #[PasswordConfirmationRequired] + public function testSSO() { + } } diff --git a/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php index 3752259c61baa..ed51837acbfc2 100644 --- a/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php +++ b/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php @@ -26,7 +26,9 @@ use OC\AppFramework\Middleware\Security\Exceptions\NotConfirmedException; use OC\AppFramework\Middleware\Security\PasswordConfirmationMiddleware; use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Authentication\Token\IProvider; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Token\IToken; use OCP\IRequest; use OCP\ISession; use OCP\IUser; @@ -49,6 +51,7 @@ class PasswordConfirmationMiddlewareTest extends TestCase { private $controller; /** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */ private $timeFactory; + private IProvider|\PHPUnit\Framework\MockObject\MockObject $tokenProvider; protected function setUp(): void { $this->reflector = new ControllerMethodReflector(); @@ -56,6 +59,7 @@ protected function setUp(): void { $this->userSession = $this->createMock(IUserSession::class); $this->user = $this->createMock(IUser::class); $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->tokenProvider = $this->createMock(IProvider::class); $this->controller = new PasswordConfirmationMiddlewareController( 'test', $this->createMock(IRequest::class) @@ -65,7 +69,8 @@ protected function setUp(): void { $this->reflector, $this->session, $this->userSession, - $this->timeFactory + $this->timeFactory, + $this->tokenProvider, ); } @@ -107,6 +112,13 @@ public function testAnnotation($backend, $lastConfirm, $currentTime, $exception) $this->timeFactory->method('getTime') ->willReturn($currentTime); + $token = $this->createMock(IToken::class); + $token->method('getScopeAsArray') + ->willReturn([]); + $this->tokenProvider->expects($this->once()) + ->method('getToken') + ->willReturn($token); + $thrown = false; try { $this->middleware->beforeController($this->controller, __FUNCTION__); @@ -135,6 +147,13 @@ public function testAttribute($backend, $lastConfirm, $currentTime, $exception) $this->timeFactory->method('getTime') ->willReturn($currentTime); + $token = $this->createMock(IToken::class); + $token->method('getScopeAsArray') + ->willReturn([]); + $this->tokenProvider->expects($this->once()) + ->method('getToken') + ->willReturn($token); + $thrown = false; try { $this->middleware->beforeController($this->controller, __FUNCTION__); @@ -145,6 +164,8 @@ public function testAttribute($backend, $lastConfirm, $currentTime, $exception) $this->assertSame($exception, $thrown); } + + public function dataProvider() { return [ ['foo', 2000, 4000, true], @@ -155,4 +176,41 @@ public function dataProvider() { ['foo', 2000, 3816, true], ]; } + + public function testSSO() { + static $sessionId = 'mySession1d'; + + $this->reflector->reflect($this->controller, __FUNCTION__); + + $this->user->method('getBackendClassName') + ->willReturn('fictional_backend'); + $this->userSession->method('getUser') + ->willReturn($this->user); + + $this->session->method('get') + ->with('last-password-confirm') + ->willReturn(0); + $this->session->method('getId') + ->willReturn($sessionId); + + $this->timeFactory->method('getTime') + ->willReturn(9876); + + $token = $this->createMock(IToken::class); + $token->method('getScopeAsArray') + ->willReturn(['sso-based-login' => true]); + $this->tokenProvider->expects($this->once()) + ->method('getToken') + ->with($sessionId) + ->willReturn($token); + + $thrown = false; + try { + $this->middleware->beforeController($this->controller, __FUNCTION__); + } catch (NotConfirmedException) { + $thrown = true; + } + + $this->assertSame(false, $thrown); + } } From 4ec174197f3ee47631193f7941ef656ce6b06be1 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Wed, 12 Jun 2024 11:05:43 +0200 Subject: [PATCH 2/2] fix(Token): make new scope future compatible - "password-unconfirmable" is the effective name for 30, but a draft name was backported. Signed-off-by: Arthur Schiwon --- .../Middleware/Security/PasswordConfirmationMiddleware.php | 2 +- lib/private/Template/JSConfigHelper.php | 2 +- lib/private/legacy/OC_User.php | 2 +- .../Middleware/Security/PasswordConfirmationMiddlewareTest.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php b/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php index 27328e17b03c7..8d00f6b74237c 100644 --- a/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php +++ b/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php @@ -103,7 +103,7 @@ public function beforeController($controller, $methodName) { return; } $scope = $token->getScopeAsArray(); - if (isset($scope['sso-based-login']) && $scope['sso-based-login'] === true) { + if (isset($scope['password-unconfirmable']) && $scope['password-unconfirmable'] === true) { // Users logging in from SSO backends cannot confirm their password by design return; } diff --git a/lib/private/Template/JSConfigHelper.php b/lib/private/Template/JSConfigHelper.php index cca3d64654445..f255da9fd6655 100644 --- a/lib/private/Template/JSConfigHelper.php +++ b/lib/private/Template/JSConfigHelper.php @@ -310,6 +310,6 @@ protected function canUserValidatePassword(): bool { return true; } $scope = $token->getScopeAsArray(); - return !isset($scope['sso-based-login']) || $scope['sso-based-login'] === false; + return !isset($scope['password-unconfirmable']) || $scope['password-unconfirmable'] === false; } } diff --git a/lib/private/legacy/OC_User.php b/lib/private/legacy/OC_User.php index 7cf0b3487a92e..0be87804eed54 100644 --- a/lib/private/legacy/OC_User.php +++ b/lib/private/legacy/OC_User.php @@ -200,7 +200,7 @@ public static function loginWithApache(\OCP\Authentication\IApacheBackend $backe if (empty($password)) { $tokenProvider = \OC::$server->get(IProvider::class); $token = $tokenProvider->getToken($userSession->getSession()->getId()); - $token->setScope(['sso-based-login' => true]); + $token->setScope(['password-unconfirmable' => true]); $tokenProvider->updateToken($token); } diff --git a/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php index ed51837acbfc2..280a10fe90a58 100644 --- a/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php +++ b/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php @@ -198,7 +198,7 @@ public function testSSO() { $token = $this->createMock(IToken::class); $token->method('getScopeAsArray') - ->willReturn(['sso-based-login' => true]); + ->willReturn(['password-unconfirmable' => true]); $this->tokenProvider->expects($this->once()) ->method('getToken') ->with($sessionId)