diff options
Diffstat (limited to 'lib/private/Authentication')
67 files changed, 5035 insertions, 0 deletions
diff --git a/lib/private/Authentication/Events/ARemoteWipeEvent.php b/lib/private/Authentication/Events/ARemoteWipeEvent.php new file mode 100644 index 00000000000..ba1e93d26ae --- /dev/null +++ b/lib/private/Authentication/Events/ARemoteWipeEvent.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Events; + +use OC\Authentication\Token\IToken; +use OCP\EventDispatcher\Event; + +abstract class ARemoteWipeEvent extends Event { + /** @var IToken */ + private $token; + + public function __construct(IToken $token) { + parent::__construct(); + $this->token = $token; + } + + public function getToken(): IToken { + return $this->token; + } +} diff --git a/lib/private/Authentication/Events/AppPasswordCreatedEvent.php b/lib/private/Authentication/Events/AppPasswordCreatedEvent.php new file mode 100644 index 00000000000..bf502ade0cc --- /dev/null +++ b/lib/private/Authentication/Events/AppPasswordCreatedEvent.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Events; + +use OCP\Authentication\Token\IToken; +use OCP\EventDispatcher\Event; + +class AppPasswordCreatedEvent extends Event { + public function __construct( + private IToken $token, + ) { + parent::__construct(); + } + + public function getToken(): IToken { + return $this->token; + } +} diff --git a/lib/private/Authentication/Events/LoginFailed.php b/lib/private/Authentication/Events/LoginFailed.php new file mode 100644 index 00000000000..23eeaef87ad --- /dev/null +++ b/lib/private/Authentication/Events/LoginFailed.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Events; + +use OCP\EventDispatcher\Event; + +class LoginFailed extends Event { + private string $loginName; + private ?string $password; + + public function __construct(string $loginName, ?string $password) { + parent::__construct(); + + $this->loginName = $loginName; + $this->password = $password; + } + + public function getLoginName(): string { + return $this->loginName; + } + + public function getPassword(): ?string { + return $this->password; + } +} diff --git a/lib/private/Authentication/Events/RemoteWipeFinished.php b/lib/private/Authentication/Events/RemoteWipeFinished.php new file mode 100644 index 00000000000..9704ebff3f5 --- /dev/null +++ b/lib/private/Authentication/Events/RemoteWipeFinished.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Events; + +class RemoteWipeFinished extends ARemoteWipeEvent { +} diff --git a/lib/private/Authentication/Events/RemoteWipeStarted.php b/lib/private/Authentication/Events/RemoteWipeStarted.php new file mode 100644 index 00000000000..ad0f72f0e09 --- /dev/null +++ b/lib/private/Authentication/Events/RemoteWipeStarted.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Events; + +class RemoteWipeStarted extends ARemoteWipeEvent { +} diff --git a/lib/private/Authentication/Exceptions/ExpiredTokenException.php b/lib/private/Authentication/Exceptions/ExpiredTokenException.php new file mode 100644 index 00000000000..eed2358d29d --- /dev/null +++ b/lib/private/Authentication/Exceptions/ExpiredTokenException.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Exceptions; + +use OC\Authentication\Token\IToken; + +/** + * @deprecated 28.0.0 use {@see \OCP\Authentication\Exceptions\ExpiredTokenException} instead + */ +class ExpiredTokenException extends \OCP\Authentication\Exceptions\ExpiredTokenException { + public function __construct( + IToken $token, + ) { + parent::__construct($token); + } + + public function getToken(): IToken { + $token = parent::getToken(); + /** @var IToken $token We know that we passed OC interface from constructor */ + return $token; + } +} diff --git a/lib/private/Authentication/Exceptions/InvalidProviderException.php b/lib/private/Authentication/Exceptions/InvalidProviderException.php new file mode 100644 index 00000000000..9dbf3a7782a --- /dev/null +++ b/lib/private/Authentication/Exceptions/InvalidProviderException.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Exceptions; + +use Exception; +use Throwable; + +class InvalidProviderException extends Exception { + public function __construct(string $providerId, ?Throwable $previous = null) { + parent::__construct("The provider '$providerId' does not exist'", 0, $previous); + } +} diff --git a/lib/private/Authentication/Exceptions/InvalidTokenException.php b/lib/private/Authentication/Exceptions/InvalidTokenException.php new file mode 100644 index 00000000000..d23f767e69b --- /dev/null +++ b/lib/private/Authentication/Exceptions/InvalidTokenException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Exceptions; + +/** + * @deprecated 28.0.0 use OCP version instead + */ +class InvalidTokenException extends \OCP\Authentication\Exceptions\InvalidTokenException { +} diff --git a/lib/private/Authentication/Exceptions/LoginRequiredException.php b/lib/private/Authentication/Exceptions/LoginRequiredException.php new file mode 100644 index 00000000000..d18ec4f1fbf --- /dev/null +++ b/lib/private/Authentication/Exceptions/LoginRequiredException.php @@ -0,0 +1,13 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Exceptions; + +use Exception; + +class LoginRequiredException extends Exception { +} diff --git a/lib/private/Authentication/Exceptions/PasswordLoginForbiddenException.php b/lib/private/Authentication/Exceptions/PasswordLoginForbiddenException.php new file mode 100644 index 00000000000..ec833a5a3d0 --- /dev/null +++ b/lib/private/Authentication/Exceptions/PasswordLoginForbiddenException.php @@ -0,0 +1,13 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Exceptions; + +use Exception; + +class PasswordLoginForbiddenException extends Exception { +} diff --git a/lib/private/Authentication/Exceptions/PasswordlessTokenException.php b/lib/private/Authentication/Exceptions/PasswordlessTokenException.php new file mode 100644 index 00000000000..a11e9a5f8d5 --- /dev/null +++ b/lib/private/Authentication/Exceptions/PasswordlessTokenException.php @@ -0,0 +1,13 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Exceptions; + +use Exception; + +class PasswordlessTokenException extends Exception { +} diff --git a/lib/private/Authentication/Exceptions/TokenPasswordExpiredException.php b/lib/private/Authentication/Exceptions/TokenPasswordExpiredException.php new file mode 100644 index 00000000000..aa331f31fd0 --- /dev/null +++ b/lib/private/Authentication/Exceptions/TokenPasswordExpiredException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Exceptions; + +class TokenPasswordExpiredException extends ExpiredTokenException { +} diff --git a/lib/private/Authentication/Exceptions/TwoFactorAuthRequiredException.php b/lib/private/Authentication/Exceptions/TwoFactorAuthRequiredException.php new file mode 100644 index 00000000000..8873b2c9f85 --- /dev/null +++ b/lib/private/Authentication/Exceptions/TwoFactorAuthRequiredException.php @@ -0,0 +1,13 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Exceptions; + +use Exception; + +class TwoFactorAuthRequiredException extends Exception { +} diff --git a/lib/private/Authentication/Exceptions/UserAlreadyLoggedInException.php b/lib/private/Authentication/Exceptions/UserAlreadyLoggedInException.php new file mode 100644 index 00000000000..257f59772fc --- /dev/null +++ b/lib/private/Authentication/Exceptions/UserAlreadyLoggedInException.php @@ -0,0 +1,13 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Exceptions; + +use Exception; + +class UserAlreadyLoggedInException extends Exception { +} diff --git a/lib/private/Authentication/Exceptions/WipeTokenException.php b/lib/private/Authentication/Exceptions/WipeTokenException.php new file mode 100644 index 00000000000..6bf0565434a --- /dev/null +++ b/lib/private/Authentication/Exceptions/WipeTokenException.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Exceptions; + +use OC\Authentication\Token\IToken; + +/** + * @deprecated 28.0.0 use {@see \OCP\Authentication\Exceptions\WipeTokenException} instead + */ +class WipeTokenException extends \OCP\Authentication\Exceptions\WipeTokenException { + public function __construct( + IToken $token, + ) { + parent::__construct($token); + } + + public function getToken(): IToken { + $token = parent::getToken(); + /** @var IToken $token We know that we passed OC interface from constructor */ + return $token; + } +} diff --git a/lib/private/Authentication/Listeners/LoginFailedListener.php b/lib/private/Authentication/Listeners/LoginFailedListener.php new file mode 100644 index 00000000000..0358887bb86 --- /dev/null +++ b/lib/private/Authentication/Listeners/LoginFailedListener.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Listeners; + +use OC\Authentication\Events\LoginFailed; +use OCP\Authentication\Events\AnyLoginFailedEvent; +use OCP\Authentication\Events\LoginFailedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\EventDispatcher\IEventListener; +use OCP\IUserManager; +use OCP\Util; + +/** + * @template-implements IEventListener<\OC\Authentication\Events\LoginFailed> + */ +class LoginFailedListener implements IEventListener { + /** @var IEventDispatcher */ + private $dispatcher; + + /** @var IUserManager */ + private $userManager; + + public function __construct(IEventDispatcher $dispatcher, IUserManager $userManager) { + $this->dispatcher = $dispatcher; + $this->userManager = $userManager; + } + + public function handle(Event $event): void { + if (!($event instanceof LoginFailed)) { + return; + } + + $this->dispatcher->dispatchTyped(new AnyLoginFailedEvent($event->getLoginName(), $event->getPassword())); + + $uid = $event->getLoginName(); + Util::emitHook( + '\OCA\Files_Sharing\API\Server2Server', + 'preLoginNameUsedAsUserName', + ['uid' => &$uid] + ); + if ($this->userManager->userExists($uid)) { + $this->dispatcher->dispatchTyped(new LoginFailedEvent($uid)); + } + } +} diff --git a/lib/private/Authentication/Listeners/RemoteWipeActivityListener.php b/lib/private/Authentication/Listeners/RemoteWipeActivityListener.php new file mode 100644 index 00000000000..457630eff27 --- /dev/null +++ b/lib/private/Authentication/Listeners/RemoteWipeActivityListener.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Listeners; + +use BadMethodCallException; +use OC\Authentication\Events\RemoteWipeFinished; +use OC\Authentication\Events\RemoteWipeStarted; +use OC\Authentication\Token\IToken; +use OCP\Activity\IManager as IActvityManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** + * @template-implements IEventListener<\OC\Authentication\Events\ARemoteWipeEvent> + */ +class RemoteWipeActivityListener implements IEventListener { + /** @var IActvityManager */ + private $activityManager; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(IActvityManager $activityManager, + LoggerInterface $logger) { + $this->activityManager = $activityManager; + $this->logger = $logger; + } + + public function handle(Event $event): void { + if ($event instanceof RemoteWipeStarted) { + $this->publishActivity('remote_wipe_start', $event->getToken()); + } elseif ($event instanceof RemoteWipeFinished) { + $this->publishActivity('remote_wipe_finish', $event->getToken()); + } + } + + private function publishActivity(string $event, IToken $token): void { + $activity = $this->activityManager->generateEvent(); + $activity->setApp('core') + ->setType('security') + ->setAuthor($token->getUID()) + ->setAffectedUser($token->getUID()) + ->setSubject($event, [ + 'name' => $token->getName(), + ]); + try { + $this->activityManager->publish($activity); + } catch (BadMethodCallException $e) { + $this->logger->warning('could not publish activity', [ + 'app' => 'core', + 'exception' => $e, + ]); + } + } +} diff --git a/lib/private/Authentication/Listeners/RemoteWipeEmailListener.php b/lib/private/Authentication/Listeners/RemoteWipeEmailListener.php new file mode 100644 index 00000000000..96878c44123 --- /dev/null +++ b/lib/private/Authentication/Listeners/RemoteWipeEmailListener.php @@ -0,0 +1,155 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Listeners; + +use Exception; +use OC\Authentication\Events\RemoteWipeFinished; +use OC\Authentication\Events\RemoteWipeStarted; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IL10N; +use OCP\IUser; +use OCP\IUserManager; +use OCP\L10N\IFactory as IL10nFactory; +use OCP\Mail\IMailer; +use OCP\Mail\IMessage; +use Psr\Log\LoggerInterface; +use function substr; + +/** + * @template-implements IEventListener<\OC\Authentication\Events\ARemoteWipeEvent> + */ +class RemoteWipeEmailListener implements IEventListener { + /** @var IMailer */ + private $mailer; + + /** @var IUserManager */ + private $userManager; + + /** @var IL10N */ + private $l10n; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(IMailer $mailer, + IUserManager $userManager, + IL10nFactory $l10nFactory, + LoggerInterface $logger) { + $this->mailer = $mailer; + $this->userManager = $userManager; + $this->l10n = $l10nFactory->get('core'); + $this->logger = $logger; + } + + /** + * @param Event $event + */ + public function handle(Event $event): void { + if ($event instanceof RemoteWipeStarted) { + $uid = $event->getToken()->getUID(); + $user = $this->userManager->get($uid); + if ($user === null) { + $this->logger->warning("not sending a wipe started email because user <$uid> does not exist (anymore)"); + return; + } + if ($user->getEMailAddress() === null) { + $this->logger->info("not sending a wipe started email because user <$uid> has no email set"); + return; + } + + try { + $this->mailer->send( + $this->getWipingStartedMessage($event, $user) + ); + } catch (Exception $e) { + $this->logger->error("Could not send remote wipe started email to <$uid>", [ + 'exception' => $e, + ]); + } + } elseif ($event instanceof RemoteWipeFinished) { + $uid = $event->getToken()->getUID(); + $user = $this->userManager->get($uid); + if ($user === null) { + $this->logger->warning("not sending a wipe finished email because user <$uid> does not exist (anymore)"); + return; + } + if ($user->getEMailAddress() === null) { + $this->logger->info("not sending a wipe finished email because user <$uid> has no email set"); + return; + } + + try { + $this->mailer->send( + $this->getWipingFinishedMessage($event, $user) + ); + } catch (Exception $e) { + $this->logger->error("Could not send remote wipe finished email to <$uid>", [ + 'exception' => $e, + ]); + } + } + } + + private function getWipingStartedMessage(RemoteWipeStarted $event, IUser $user): IMessage { + $message = $this->mailer->createMessage(); + $emailTemplate = $this->mailer->createEMailTemplate('auth.RemoteWipeStarted'); + $plainHeading = $this->l10n->t('Wiping of device %s has started', [$event->getToken()->getName()]); + $htmlHeading = $this->l10n->t('Wiping of device »%s« has started', [$event->getToken()->getName()]); + $emailTemplate->setSubject( + $this->l10n->t( + '»%s« started remote wipe', + [ + substr($event->getToken()->getName(), 0, 15) + ] + ) + ); + $emailTemplate->addHeader(); + $emailTemplate->addHeading( + $htmlHeading, + $plainHeading + ); + $emailTemplate->addBodyText( + $this->l10n->t('Device or application »%s« has started the remote wipe process. You will receive another email once the process has finished', [$event->getToken()->getName()]) + ); + $emailTemplate->addFooter(); + $message->setTo([$user->getEMailAddress()]); + $message->useTemplate($emailTemplate); + + return $message; + } + + private function getWipingFinishedMessage(RemoteWipeFinished $event, IUser $user): IMessage { + $message = $this->mailer->createMessage(); + $emailTemplate = $this->mailer->createEMailTemplate('auth.RemoteWipeFinished'); + $plainHeading = $this->l10n->t('Wiping of device %s has finished', [$event->getToken()->getName()]); + $htmlHeading = $this->l10n->t('Wiping of device »%s« has finished', [$event->getToken()->getName()]); + $emailTemplate->setSubject( + $this->l10n->t( + '»%s« finished remote wipe', + [ + substr($event->getToken()->getName(), 0, 15) + ] + ) + ); + $emailTemplate->addHeader(); + $emailTemplate->addHeading( + $htmlHeading, + $plainHeading + ); + $emailTemplate->addBodyText( + $this->l10n->t('Device or application »%s« has finished the remote wipe process.', [$event->getToken()->getName()]) + ); + $emailTemplate->addFooter(); + $message->setTo([$user->getEMailAddress()]); + $message->useTemplate($emailTemplate); + + return $message; + } +} diff --git a/lib/private/Authentication/Listeners/RemoteWipeNotificationsListener.php b/lib/private/Authentication/Listeners/RemoteWipeNotificationsListener.php new file mode 100644 index 00000000000..5781c1edf16 --- /dev/null +++ b/lib/private/Authentication/Listeners/RemoteWipeNotificationsListener.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Listeners; + +use OC\Authentication\Events\RemoteWipeFinished; +use OC\Authentication\Events\RemoteWipeStarted; +use OC\Authentication\Token\IToken; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Notification\IManager as INotificationManager; + +/** + * @template-implements IEventListener<\OC\Authentication\Events\ARemoteWipeEvent> + */ +class RemoteWipeNotificationsListener implements IEventListener { + /** @var INotificationManager */ + private $notificationManager; + + /** @var ITimeFactory */ + private $timeFactory; + + public function __construct(INotificationManager $notificationManager, + ITimeFactory $timeFactory) { + $this->notificationManager = $notificationManager; + $this->timeFactory = $timeFactory; + } + + public function handle(Event $event): void { + if ($event instanceof RemoteWipeStarted) { + $this->sendNotification('remote_wipe_start', $event->getToken()); + } elseif ($event instanceof RemoteWipeFinished) { + $this->sendNotification('remote_wipe_finish', $event->getToken()); + } + } + + private function sendNotification(string $event, IToken $token): void { + $notification = $this->notificationManager->createNotification(); + $notification->setApp('auth') + ->setUser($token->getUID()) + ->setDateTime($this->timeFactory->getDateTime()) + ->setObject('token', (string)$token->getId()) + ->setSubject($event, [ + 'name' => $token->getName(), + ]); + $this->notificationManager->notify($notification); + } +} diff --git a/lib/private/Authentication/Listeners/UserDeletedFilesCleanupListener.php b/lib/private/Authentication/Listeners/UserDeletedFilesCleanupListener.php new file mode 100644 index 00000000000..a619021d192 --- /dev/null +++ b/lib/private/Authentication/Listeners/UserDeletedFilesCleanupListener.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Listeners; + +use OC\Files\Cache\Cache; +use OC\Files\Storage\Wrapper\Wrapper; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Config\IMountProviderCollection; +use OCP\Files\Storage\IStorage; +use OCP\User\Events\BeforeUserDeletedEvent; +use OCP\User\Events\UserDeletedEvent; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener<BeforeUserDeletedEvent|UserDeletedEvent> */ +class UserDeletedFilesCleanupListener implements IEventListener { + /** @var array<string,IStorage> */ + private $homeStorageCache = []; + + public function __construct( + private IMountProviderCollection $mountProviderCollection, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + $user = $event->getUser(); + + // since we can't reliably get the user home storage after the user is deleted + // but the user deletion might get canceled during the before event + // we only cache the user home storage during the before event and then do the + // action deletion during the after event + + if ($event instanceof BeforeUserDeletedEvent) { + $this->logger->debug('Prepare deleting storage for user {userId}', ['userId' => $user->getUID()]); + + $userHome = $this->mountProviderCollection->getHomeMountForUser($user); + $storage = $userHome->getStorage(); + if (!$storage) { + throw new \Exception('Account has no home storage'); + } + + // remove all wrappers, so we do the delete directly on the home storage bypassing any wrapper + while ($storage->instanceOfStorage(Wrapper::class)) { + /** @var Wrapper $storage */ + $storage = $storage->getWrapperStorage(); + } + + $this->homeStorageCache[$event->getUser()->getUID()] = $storage; + } + if ($event instanceof UserDeletedEvent) { + if (!isset($this->homeStorageCache[$user->getUID()])) { + throw new \Exception('UserDeletedEvent fired without matching BeforeUserDeletedEvent'); + } + $storage = $this->homeStorageCache[$user->getUID()]; + $cache = $storage->getCache(); + $storage->rmdir(''); + $this->logger->debug('Deleted storage for user {userId}', ['userId' => $user->getUID()]); + + if ($cache instanceof Cache) { + $cache->clear(); + } else { + throw new \Exception('Home storage has invalid cache'); + } + } + } +} diff --git a/lib/private/Authentication/Listeners/UserDeletedStoreCleanupListener.php b/lib/private/Authentication/Listeners/UserDeletedStoreCleanupListener.php new file mode 100644 index 00000000000..5f21c640780 --- /dev/null +++ b/lib/private/Authentication/Listeners/UserDeletedStoreCleanupListener.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Listeners; + +use OC\Authentication\TwoFactorAuth\Registry; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\User\Events\UserDeletedEvent; + +/** + * @template-implements IEventListener<\OCP\User\Events\UserDeletedEvent> + */ +class UserDeletedStoreCleanupListener implements IEventListener { + /** @var Registry */ + private $registry; + + public function __construct(Registry $registry) { + $this->registry = $registry; + } + + public function handle(Event $event): void { + if (!($event instanceof UserDeletedEvent)) { + return; + } + + $this->registry->deleteUserData($event->getUser()); + } +} diff --git a/lib/private/Authentication/Listeners/UserDeletedTokenCleanupListener.php b/lib/private/Authentication/Listeners/UserDeletedTokenCleanupListener.php new file mode 100644 index 00000000000..3631c04432c --- /dev/null +++ b/lib/private/Authentication/Listeners/UserDeletedTokenCleanupListener.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Listeners; + +use OC\Authentication\Token\Manager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\User\Events\UserDeletedEvent; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * @template-implements IEventListener<\OCP\User\Events\UserDeletedEvent> + */ +class UserDeletedTokenCleanupListener implements IEventListener { + /** @var Manager */ + private $manager; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(Manager $manager, + LoggerInterface $logger) { + $this->manager = $manager; + $this->logger = $logger; + } + + public function handle(Event $event): void { + if (!($event instanceof UserDeletedEvent)) { + // Unrelated + return; + } + + /** + * Catch any exception during this process as any failure here shouldn't block the + * user deletion. + */ + try { + $uid = $event->getUser()->getUID(); + $tokens = $this->manager->getTokenByUser($uid); + foreach ($tokens as $token) { + $this->manager->invalidateTokenById($uid, $token->getId()); + } + } catch (Throwable $e) { + $this->logger->error('Could not clean up auth tokens after user deletion: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } +} diff --git a/lib/private/Authentication/Listeners/UserDeletedWebAuthnCleanupListener.php b/lib/private/Authentication/Listeners/UserDeletedWebAuthnCleanupListener.php new file mode 100644 index 00000000000..67f8ff7cfcd --- /dev/null +++ b/lib/private/Authentication/Listeners/UserDeletedWebAuthnCleanupListener.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Authentication\Listeners; + +use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\User\Events\UserDeletedEvent; + +/** @template-implements IEventListener<UserDeletedEvent> */ +class UserDeletedWebAuthnCleanupListener implements IEventListener { + /** @var PublicKeyCredentialMapper */ + private $credentialMapper; + + public function __construct(PublicKeyCredentialMapper $credentialMapper) { + $this->credentialMapper = $credentialMapper; + } + + public function handle(Event $event): void { + if (!($event instanceof UserDeletedEvent)) { + return; + } + + $this->credentialMapper->deleteByUid($event->getUser()->getUID()); + } +} diff --git a/lib/private/Authentication/Listeners/UserLoggedInListener.php b/lib/private/Authentication/Listeners/UserLoggedInListener.php new file mode 100644 index 00000000000..a8d4baeafa1 --- /dev/null +++ b/lib/private/Authentication/Listeners/UserLoggedInListener.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Listeners; + +use OC\Authentication\Token\Manager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\User\Events\PostLoginEvent; + +/** + * @template-implements IEventListener<\OCP\User\Events\PostLoginEvent> + */ +class UserLoggedInListener implements IEventListener { + /** @var Manager */ + private $manager; + + public function __construct(Manager $manager) { + $this->manager = $manager; + } + + public function handle(Event $event): void { + if (!($event instanceof PostLoginEvent)) { + return; + } + + // prevent setting an empty pw as result of pw-less-login + if ($event->getPassword() === '') { + return; + } + + // If this is already a token login there is nothing to do + if ($event->isTokenLogin()) { + return; + } + + $this->manager->updatePasswords($event->getUser()->getUID(), $event->getPassword()); + } +} diff --git a/lib/private/Authentication/Login/ALoginCommand.php b/lib/private/Authentication/Login/ALoginCommand.php new file mode 100644 index 00000000000..a9f51f0da9e --- /dev/null +++ b/lib/private/Authentication/Login/ALoginCommand.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +abstract class ALoginCommand { + /** @var ALoginCommand */ + protected $next; + + public function setNext(ALoginCommand $next): ALoginCommand { + $this->next = $next; + return $next; + } + + protected function processNextOrFinishSuccessfully(LoginData $loginData): LoginResult { + if ($this->next !== null) { + return $this->next->process($loginData); + } else { + return LoginResult::success($loginData); + } + } + + abstract public function process(LoginData $loginData): LoginResult; +} diff --git a/lib/private/Authentication/Login/Chain.php b/lib/private/Authentication/Login/Chain.php new file mode 100644 index 00000000000..fc90d9225a7 --- /dev/null +++ b/lib/private/Authentication/Login/Chain.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +class Chain { + public function __construct( + private PreLoginHookCommand $preLoginHookCommand, + private UserDisabledCheckCommand $userDisabledCheckCommand, + private UidLoginCommand $uidLoginCommand, + private LoggedInCheckCommand $loggedInCheckCommand, + private CompleteLoginCommand $completeLoginCommand, + private CreateSessionTokenCommand $createSessionTokenCommand, + private ClearLostPasswordTokensCommand $clearLostPasswordTokensCommand, + private UpdateLastPasswordConfirmCommand $updateLastPasswordConfirmCommand, + private SetUserTimezoneCommand $setUserTimezoneCommand, + private TwoFactorCommand $twoFactorCommand, + private FinishRememberedLoginCommand $finishRememberedLoginCommand, + private FlowV2EphemeralSessionsCommand $flowV2EphemeralSessionsCommand, + ) { + } + + public function process(LoginData $loginData): LoginResult { + $chain = $this->preLoginHookCommand; + $chain + ->setNext($this->userDisabledCheckCommand) + ->setNext($this->uidLoginCommand) + ->setNext($this->loggedInCheckCommand) + ->setNext($this->completeLoginCommand) + ->setNext($this->flowV2EphemeralSessionsCommand) + ->setNext($this->createSessionTokenCommand) + ->setNext($this->clearLostPasswordTokensCommand) + ->setNext($this->updateLastPasswordConfirmCommand) + ->setNext($this->setUserTimezoneCommand) + ->setNext($this->twoFactorCommand) + ->setNext($this->finishRememberedLoginCommand); + + return $chain->process($loginData); + } +} diff --git a/lib/private/Authentication/Login/ClearLostPasswordTokensCommand.php b/lib/private/Authentication/Login/ClearLostPasswordTokensCommand.php new file mode 100644 index 00000000000..40369c383ac --- /dev/null +++ b/lib/private/Authentication/Login/ClearLostPasswordTokensCommand.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OCP\IConfig; + +class ClearLostPasswordTokensCommand extends ALoginCommand { + /** @var IConfig */ + private $config; + + public function __construct(IConfig $config) { + $this->config = $config; + } + + /** + * User has successfully logged in, now remove the password reset link, when it is available + */ + public function process(LoginData $loginData): LoginResult { + $this->config->deleteUserValue( + $loginData->getUser()->getUID(), + 'core', + 'lostpassword' + ); + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/CompleteLoginCommand.php b/lib/private/Authentication/Login/CompleteLoginCommand.php new file mode 100644 index 00000000000..ec6fdf75f40 --- /dev/null +++ b/lib/private/Authentication/Login/CompleteLoginCommand.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\User\Session; + +class CompleteLoginCommand extends ALoginCommand { + /** @var Session */ + private $userSession; + + public function __construct(Session $userSession) { + $this->userSession = $userSession; + } + + public function process(LoginData $loginData): LoginResult { + $this->userSession->completeLogin( + $loginData->getUser(), + [ + 'loginName' => $loginData->getUsername(), + 'password' => $loginData->getPassword(), + ] + ); + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/CreateSessionTokenCommand.php b/lib/private/Authentication/Login/CreateSessionTokenCommand.php new file mode 100644 index 00000000000..7619ad90d93 --- /dev/null +++ b/lib/private/Authentication/Login/CreateSessionTokenCommand.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\Authentication\Token\IToken; +use OC\User\Session; +use OCP\IConfig; + +class CreateSessionTokenCommand extends ALoginCommand { + /** @var IConfig */ + private $config; + + /** @var Session */ + private $userSession; + + public function __construct(IConfig $config, + Session $userSession) { + $this->config = $config; + $this->userSession = $userSession; + } + + public function process(LoginData $loginData): LoginResult { + $tokenType = IToken::REMEMBER; + if ($this->config->getSystemValueInt('remember_login_cookie_lifetime', 60 * 60 * 24 * 15) === 0) { + $loginData->setRememberLogin(false); + $tokenType = IToken::DO_NOT_REMEMBER; + } + + if ($loginData->getPassword() === '') { + $this->userSession->createSessionToken( + $loginData->getRequest(), + $loginData->getUser()->getUID(), + $loginData->getUsername(), + null, + $tokenType + ); + $this->userSession->updateTokens( + $loginData->getUser()->getUID(), + '' + ); + } else { + $this->userSession->createSessionToken( + $loginData->getRequest(), + $loginData->getUser()->getUID(), + $loginData->getUsername(), + $loginData->getPassword(), + $tokenType + ); + $this->userSession->updateTokens( + $loginData->getUser()->getUID(), + $loginData->getPassword() + ); + } + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/FinishRememberedLoginCommand.php b/lib/private/Authentication/Login/FinishRememberedLoginCommand.php new file mode 100644 index 00000000000..3eb1f8f1a65 --- /dev/null +++ b/lib/private/Authentication/Login/FinishRememberedLoginCommand.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\User\Session; +use OCP\IConfig; + +class FinishRememberedLoginCommand extends ALoginCommand { + /** @var Session */ + private $userSession; + /** @var IConfig */ + private $config; + + public function __construct(Session $userSession, IConfig $config) { + $this->userSession = $userSession; + $this->config = $config; + } + + public function process(LoginData $loginData): LoginResult { + if ($loginData->isRememberLogin() && !$this->config->getSystemValueBool('auto_logout', false)) { + $this->userSession->createRememberMeToken($loginData->getUser()); + } + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/FlowV2EphemeralSessionsCommand.php b/lib/private/Authentication/Login/FlowV2EphemeralSessionsCommand.php new file mode 100644 index 00000000000..82dd829334d --- /dev/null +++ b/lib/private/Authentication/Login/FlowV2EphemeralSessionsCommand.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\Core\Controller\ClientFlowLoginV2Controller; +use OCP\ISession; +use OCP\IURLGenerator; + +class FlowV2EphemeralSessionsCommand extends ALoginCommand { + public function __construct( + private ISession $session, + private IURLGenerator $urlGenerator, + ) { + } + + public function process(LoginData $loginData): LoginResult { + $loginV2GrantRoute = $this->urlGenerator->linkToRoute('core.ClientFlowLoginV2.grantPage'); + if (str_starts_with($loginData->getRedirectUrl() ?? '', $loginV2GrantRoute)) { + $this->session->set(ClientFlowLoginV2Controller::EPHEMERAL_NAME, true); + } + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/LoggedInCheckCommand.php b/lib/private/Authentication/Login/LoggedInCheckCommand.php new file mode 100644 index 00000000000..b6b59ced6ce --- /dev/null +++ b/lib/private/Authentication/Login/LoggedInCheckCommand.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\Authentication\Events\LoginFailed; +use OC\Core\Controller\LoginController; +use OCP\EventDispatcher\IEventDispatcher; +use Psr\Log\LoggerInterface; + +class LoggedInCheckCommand extends ALoginCommand { + /** @var LoggerInterface */ + private $logger; + /** @var IEventDispatcher */ + private $dispatcher; + + public function __construct(LoggerInterface $logger, + IEventDispatcher $dispatcher) { + $this->logger = $logger; + $this->dispatcher = $dispatcher; + } + + public function process(LoginData $loginData): LoginResult { + if ($loginData->getUser() === false) { + $loginName = $loginData->getUsername(); + $password = $loginData->getPassword(); + $ip = $loginData->getRequest()->getRemoteAddress(); + + $this->logger->warning("Login failed: $loginName (Remote IP: $ip)"); + + $this->dispatcher->dispatchTyped(new LoginFailed($loginName, $password)); + + return LoginResult::failure($loginData, LoginController::LOGIN_MSG_INVALIDPASSWORD); + } + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/LoginData.php b/lib/private/Authentication/Login/LoginData.php new file mode 100644 index 00000000000..1ad97a9d559 --- /dev/null +++ b/lib/private/Authentication/Login/LoginData.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OCP\IRequest; +use OCP\IUser; + +class LoginData { + /** @var IRequest */ + private $request; + + /** @var string */ + private $username; + + /** @var string */ + private $password; + + /** @var string */ + private $redirectUrl; + + /** @var string */ + private $timeZone; + + /** @var string */ + private $timeZoneOffset; + + /** @var IUser|false|null */ + private $user = null; + + /** @var bool */ + private $rememberLogin = true; + + public function __construct(IRequest $request, + string $username, + ?string $password, + ?string $redirectUrl = null, + string $timeZone = '', + string $timeZoneOffset = '') { + $this->request = $request; + $this->username = $username; + $this->password = $password; + $this->redirectUrl = $redirectUrl; + $this->timeZone = $timeZone; + $this->timeZoneOffset = $timeZoneOffset; + } + + public function getRequest(): IRequest { + return $this->request; + } + + public function setUsername(string $username): void { + $this->username = $username; + } + + public function getUsername(): string { + return $this->username; + } + + public function getPassword(): ?string { + return $this->password; + } + + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + + public function getTimeZone(): string { + return $this->timeZone; + } + + public function getTimeZoneOffset(): string { + return $this->timeZoneOffset; + } + + /** + * @param IUser|false|null $user + */ + public function setUser($user) { + $this->user = $user; + } + + /** + * @return false|IUser|null + */ + public function getUser() { + return $this->user; + } + + public function setRememberLogin(bool $rememberLogin): void { + $this->rememberLogin = $rememberLogin; + } + + public function isRememberLogin(): bool { + return $this->rememberLogin; + } +} diff --git a/lib/private/Authentication/Login/LoginResult.php b/lib/private/Authentication/Login/LoginResult.php new file mode 100644 index 00000000000..95e87b520e3 --- /dev/null +++ b/lib/private/Authentication/Login/LoginResult.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\Core\Controller\LoginController; + +class LoginResult { + /** @var bool */ + private $success; + + /** @var LoginData */ + private $loginData; + + /** @var string|null */ + private $redirectUrl; + + /** @var string|null */ + private $errorMessage; + + private function __construct(bool $success, LoginData $loginData) { + $this->success = $success; + $this->loginData = $loginData; + } + + private function setRedirectUrl(string $url) { + $this->redirectUrl = $url; + } + + private function setErrorMessage(string $msg) { + $this->errorMessage = $msg; + } + + public static function success(LoginData $data, ?string $redirectUrl = null) { + $result = new static(true, $data); + if ($redirectUrl !== null) { + $result->setRedirectUrl($redirectUrl); + } + return $result; + } + + /** + * @param LoginController::LOGIN_MSG_*|null $msg + */ + public static function failure(LoginData $data, ?string $msg = null): LoginResult { + $result = new static(false, $data); + if ($msg !== null) { + $result->setErrorMessage($msg); + } + return $result; + } + + public function isSuccess(): bool { + return $this->success; + } + + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + + public function getErrorMessage(): ?string { + return $this->errorMessage; + } +} diff --git a/lib/private/Authentication/Login/PreLoginHookCommand.php b/lib/private/Authentication/Login/PreLoginHookCommand.php new file mode 100644 index 00000000000..d5aa174094d --- /dev/null +++ b/lib/private/Authentication/Login/PreLoginHookCommand.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\Hooks\PublicEmitter; +use OCP\IUserManager; + +class PreLoginHookCommand extends ALoginCommand { + /** @var IUserManager */ + private $userManager; + + public function __construct(IUserManager $userManager) { + $this->userManager = $userManager; + } + + public function process(LoginData $loginData): LoginResult { + if ($this->userManager instanceof PublicEmitter) { + $this->userManager->emit( + '\OC\User', + 'preLogin', + [ + $loginData->getUsername(), + $loginData->getPassword(), + ] + ); + } + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/SetUserTimezoneCommand.php b/lib/private/Authentication/Login/SetUserTimezoneCommand.php new file mode 100644 index 00000000000..90bc444ae7d --- /dev/null +++ b/lib/private/Authentication/Login/SetUserTimezoneCommand.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OCP\IConfig; +use OCP\ISession; + +class SetUserTimezoneCommand extends ALoginCommand { + /** @var IConfig */ + private $config; + + /** @var ISession */ + private $session; + + public function __construct(IConfig $config, + ISession $session) { + $this->config = $config; + $this->session = $session; + } + + public function process(LoginData $loginData): LoginResult { + if ($loginData->getTimeZoneOffset() !== '' && $this->isValidTimezone($loginData->getTimeZone())) { + $this->config->setUserValue( + $loginData->getUser()->getUID(), + 'core', + 'timezone', + $loginData->getTimeZone() + ); + $this->session->set( + 'timezone', + $loginData->getTimeZoneOffset() + ); + } + + return $this->processNextOrFinishSuccessfully($loginData); + } + + private function isValidTimezone(?string $value): bool { + return $value && in_array($value, \DateTimeZone::listIdentifiers()); + } +} diff --git a/lib/private/Authentication/Login/TwoFactorCommand.php b/lib/private/Authentication/Login/TwoFactorCommand.php new file mode 100644 index 00000000000..fc5285221a2 --- /dev/null +++ b/lib/private/Authentication/Login/TwoFactorCommand.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\Authentication\TwoFactorAuth\Manager; +use OC\Authentication\TwoFactorAuth\MandatoryTwoFactor; +use OCP\Authentication\TwoFactorAuth\IProvider; +use OCP\IURLGenerator; +use function array_pop; +use function count; + +class TwoFactorCommand extends ALoginCommand { + /** @var Manager */ + private $twoFactorManager; + + /** @var MandatoryTwoFactor */ + private $mandatoryTwoFactor; + + /** @var IURLGenerator */ + private $urlGenerator; + + public function __construct(Manager $twoFactorManager, + MandatoryTwoFactor $mandatoryTwoFactor, + IURLGenerator $urlGenerator) { + $this->twoFactorManager = $twoFactorManager; + $this->mandatoryTwoFactor = $mandatoryTwoFactor; + $this->urlGenerator = $urlGenerator; + } + + public function process(LoginData $loginData): LoginResult { + if (!$this->twoFactorManager->isTwoFactorAuthenticated($loginData->getUser())) { + return $this->processNextOrFinishSuccessfully($loginData); + } + + $this->twoFactorManager->prepareTwoFactorLogin($loginData->getUser(), $loginData->isRememberLogin()); + + $providerSet = $this->twoFactorManager->getProviderSet($loginData->getUser()); + $loginProviders = $this->twoFactorManager->getLoginSetupProviders($loginData->getUser()); + $providers = $providerSet->getPrimaryProviders(); + if (empty($providers) + && !$providerSet->isProviderMissing() + && !empty($loginProviders) + && $this->mandatoryTwoFactor->isEnforcedFor($loginData->getUser())) { + // No providers set up, but 2FA is enforced and setup providers are available + $url = 'core.TwoFactorChallenge.setupProviders'; + $urlParams = []; + } elseif (!$providerSet->isProviderMissing() && count($providers) === 1) { + // Single provider (and no missing ones), hence we can redirect to that provider's challenge page directly + /* @var $provider IProvider */ + $provider = array_pop($providers); + $url = 'core.TwoFactorChallenge.showChallenge'; + $urlParams = [ + 'challengeProviderId' => $provider->getId(), + ]; + } else { + $url = 'core.TwoFactorChallenge.selectChallenge'; + $urlParams = []; + } + + if ($loginData->getRedirectUrl() !== null) { + $urlParams['redirect_url'] = $loginData->getRedirectUrl(); + } + + return LoginResult::success( + $loginData, + $this->urlGenerator->linkToRoute($url, $urlParams) + ); + } +} diff --git a/lib/private/Authentication/Login/UidLoginCommand.php b/lib/private/Authentication/Login/UidLoginCommand.php new file mode 100644 index 00000000000..511b5f61e0e --- /dev/null +++ b/lib/private/Authentication/Login/UidLoginCommand.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\User\Manager; +use OCP\IUser; + +class UidLoginCommand extends ALoginCommand { + /** @var Manager */ + private $userManager; + + public function __construct(Manager $userManager) { + $this->userManager = $userManager; + } + + /** + * @param LoginData $loginData + * + * @return LoginResult + */ + public function process(LoginData $loginData): LoginResult { + /* @var $loginResult IUser */ + $user = $this->userManager->checkPasswordNoLogging( + $loginData->getUsername(), + $loginData->getPassword() + ); + + $loginData->setUser($user); + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/UpdateLastPasswordConfirmCommand.php b/lib/private/Authentication/Login/UpdateLastPasswordConfirmCommand.php new file mode 100644 index 00000000000..0582239e9de --- /dev/null +++ b/lib/private/Authentication/Login/UpdateLastPasswordConfirmCommand.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OCP\ISession; + +class UpdateLastPasswordConfirmCommand extends ALoginCommand { + /** @var ISession */ + private $session; + + public function __construct(ISession $session) { + $this->session = $session; + } + + public function process(LoginData $loginData): LoginResult { + $this->session->set( + 'last-password-confirm', + $loginData->getUser()->getLastLogin() + ); + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/UserDisabledCheckCommand.php b/lib/private/Authentication/Login/UserDisabledCheckCommand.php new file mode 100644 index 00000000000..8777aa6dcea --- /dev/null +++ b/lib/private/Authentication/Login/UserDisabledCheckCommand.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OC\Core\Controller\LoginController; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class UserDisabledCheckCommand extends ALoginCommand { + /** @var IUserManager */ + private $userManager; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(IUserManager $userManager, + LoggerInterface $logger) { + $this->userManager = $userManager; + $this->logger = $logger; + } + + public function process(LoginData $loginData): LoginResult { + $user = $this->userManager->get($loginData->getUsername()); + if ($user !== null && $user->isEnabled() === false) { + $username = $loginData->getUsername(); + $ip = $loginData->getRequest()->getRemoteAddress(); + + $this->logger->warning("Login failed: $username disabled (Remote IP: $ip)"); + + return LoginResult::failure($loginData, LoginController::LOGIN_MSG_USERDISABLED); + } + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/Login/WebAuthnChain.php b/lib/private/Authentication/Login/WebAuthnChain.php new file mode 100644 index 00000000000..ae523c43da6 --- /dev/null +++ b/lib/private/Authentication/Login/WebAuthnChain.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +class WebAuthnChain { + /** @var UserDisabledCheckCommand */ + private $userDisabledCheckCommand; + + /** @var LoggedInCheckCommand */ + private $loggedInCheckCommand; + + /** @var CompleteLoginCommand */ + private $completeLoginCommand; + + /** @var CreateSessionTokenCommand */ + private $createSessionTokenCommand; + + /** @var ClearLostPasswordTokensCommand */ + private $clearLostPasswordTokensCommand; + + /** @var UpdateLastPasswordConfirmCommand */ + private $updateLastPasswordConfirmCommand; + + /** @var SetUserTimezoneCommand */ + private $setUserTimezoneCommand; + + /** @var TwoFactorCommand */ + private $twoFactorCommand; + + /** @var FinishRememberedLoginCommand */ + private $finishRememberedLoginCommand; + + /** @var WebAuthnLoginCommand */ + private $webAuthnLoginCommand; + + public function __construct(UserDisabledCheckCommand $userDisabledCheckCommand, + WebAuthnLoginCommand $webAuthnLoginCommand, + LoggedInCheckCommand $loggedInCheckCommand, + CompleteLoginCommand $completeLoginCommand, + CreateSessionTokenCommand $createSessionTokenCommand, + ClearLostPasswordTokensCommand $clearLostPasswordTokensCommand, + UpdateLastPasswordConfirmCommand $updateLastPasswordConfirmCommand, + SetUserTimezoneCommand $setUserTimezoneCommand, + TwoFactorCommand $twoFactorCommand, + FinishRememberedLoginCommand $finishRememberedLoginCommand, + ) { + $this->userDisabledCheckCommand = $userDisabledCheckCommand; + $this->webAuthnLoginCommand = $webAuthnLoginCommand; + $this->loggedInCheckCommand = $loggedInCheckCommand; + $this->completeLoginCommand = $completeLoginCommand; + $this->createSessionTokenCommand = $createSessionTokenCommand; + $this->clearLostPasswordTokensCommand = $clearLostPasswordTokensCommand; + $this->updateLastPasswordConfirmCommand = $updateLastPasswordConfirmCommand; + $this->setUserTimezoneCommand = $setUserTimezoneCommand; + $this->twoFactorCommand = $twoFactorCommand; + $this->finishRememberedLoginCommand = $finishRememberedLoginCommand; + } + + public function process(LoginData $loginData): LoginResult { + $chain = $this->userDisabledCheckCommand; + $chain + ->setNext($this->webAuthnLoginCommand) + ->setNext($this->loggedInCheckCommand) + ->setNext($this->completeLoginCommand) + ->setNext($this->createSessionTokenCommand) + ->setNext($this->clearLostPasswordTokensCommand) + ->setNext($this->updateLastPasswordConfirmCommand) + ->setNext($this->setUserTimezoneCommand) + ->setNext($this->twoFactorCommand) + ->setNext($this->finishRememberedLoginCommand); + + return $chain->process($loginData); + } +} diff --git a/lib/private/Authentication/Login/WebAuthnLoginCommand.php b/lib/private/Authentication/Login/WebAuthnLoginCommand.php new file mode 100644 index 00000000000..8f14e5b3f6d --- /dev/null +++ b/lib/private/Authentication/Login/WebAuthnLoginCommand.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Login; + +use OCP\IUserManager; + +class WebAuthnLoginCommand extends ALoginCommand { + /** @var IUserManager */ + private $userManager; + + public function __construct(IUserManager $userManager) { + $this->userManager = $userManager; + } + + public function process(LoginData $loginData): LoginResult { + $user = $this->userManager->get($loginData->getUsername()); + $loginData->setUser($user); + if ($user === null) { + $loginData->setUser(false); + } + + return $this->processNextOrFinishSuccessfully($loginData); + } +} diff --git a/lib/private/Authentication/LoginCredentials/Credentials.php b/lib/private/Authentication/LoginCredentials/Credentials.php new file mode 100644 index 00000000000..3414034b33c --- /dev/null +++ b/lib/private/Authentication/LoginCredentials/Credentials.php @@ -0,0 +1,52 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\LoginCredentials; + +use OCP\Authentication\LoginCredentials\ICredentials; + +class Credentials implements ICredentials { + /** @var string */ + private $uid; + + /** @var string */ + private $loginName; + + /** @var string */ + private $password; + + /** + * @param string $uid + * @param string $loginName + * @param string $password + */ + public function __construct($uid, $loginName, $password) { + $this->uid = $uid; + $this->loginName = $loginName; + $this->password = $password; + } + + /** + * @return string + */ + public function getUID() { + return $this->uid; + } + + /** + * @return string + */ + public function getLoginName() { + return $this->loginName; + } + + /** + * @return string + */ + public function getPassword() { + return $this->password; + } +} diff --git a/lib/private/Authentication/LoginCredentials/Store.php b/lib/private/Authentication/LoginCredentials/Store.php new file mode 100644 index 00000000000..67c5712715c --- /dev/null +++ b/lib/private/Authentication/LoginCredentials/Store.php @@ -0,0 +1,119 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\LoginCredentials; + +use Exception; +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OC\Authentication\Token\IProvider; +use OCP\Authentication\Exceptions\CredentialsUnavailableException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\LoginCredentials\ICredentials; +use OCP\Authentication\LoginCredentials\IStore; +use OCP\ISession; +use OCP\Security\ICrypto; +use OCP\Session\Exceptions\SessionNotAvailableException; +use OCP\Util; +use Psr\Log\LoggerInterface; + +class Store implements IStore { + /** @var ISession */ + private $session; + + /** @var LoggerInterface */ + private $logger; + + /** @var IProvider|null */ + private $tokenProvider; + + public function __construct( + ISession $session, + LoggerInterface $logger, + private readonly ICrypto $crypto, + ?IProvider $tokenProvider = null, + ) { + $this->session = $session; + $this->logger = $logger; + $this->tokenProvider = $tokenProvider; + + Util::connectHook('OC_User', 'post_login', $this, 'authenticate'); + } + + /** + * Hook listener on post login + * + * @param array $params + */ + public function authenticate(array $params) { + if ($params['password'] !== null) { + $params['password'] = $this->crypto->encrypt((string)$params['password']); + } + $this->session->set('login_credentials', json_encode($params)); + } + + /** + * Replace the session implementation + * + * @param ISession $session + */ + public function setSession(ISession $session) { + $this->session = $session; + } + + /** + * @since 12 + * + * @return ICredentials the login credentials of the current user + * @throws CredentialsUnavailableException + */ + public function getLoginCredentials(): ICredentials { + if ($this->tokenProvider === null) { + throw new CredentialsUnavailableException(); + } + + $trySession = false; + try { + $sessionId = $this->session->getId(); + $token = $this->tokenProvider->getToken($sessionId); + + $uid = $token->getUID(); + $user = $token->getLoginName(); + $password = $this->tokenProvider->getPassword($token, $sessionId); + + return new Credentials($uid, $user, $password); + } catch (SessionNotAvailableException $ex) { + $this->logger->debug('could not get login credentials because session is unavailable', ['app' => 'core', 'exception' => $ex]); + } catch (InvalidTokenException $ex) { + $this->logger->debug('could not get login credentials because the token is invalid: ' . $ex->getMessage(), ['app' => 'core']); + $trySession = true; + } catch (PasswordlessTokenException $ex) { + $this->logger->debug('could not get login credentials because the token has no password', ['app' => 'core', 'exception' => $ex]); + $trySession = true; + } + + if ($trySession && $this->session->exists('login_credentials')) { + /** @var array $creds */ + $creds = json_decode($this->session->get('login_credentials'), true); + if ($creds['password'] !== null) { + try { + $creds['password'] = $this->crypto->decrypt($creds['password']); + } catch (Exception $e) { + //decryption failed, continue with old password as it is + } + } + return new Credentials( + $creds['uid'], + $creds['loginName'] ?? $this->session->get('loginname') ?? $creds['uid'], // Pre 20 didn't have a loginName property, hence fall back to the session value and then to the UID + $creds['password'] + ); + } + + // If we reach this line, an exception was thrown. + throw new CredentialsUnavailableException(); + } +} diff --git a/lib/private/Authentication/Notifications/Notifier.php b/lib/private/Authentication/Notifications/Notifier.php new file mode 100644 index 00000000000..a81e385d8b1 --- /dev/null +++ b/lib/private/Authentication/Notifications/Notifier.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Notifications; + +use OCP\L10N\IFactory as IL10nFactory; +use OCP\Notification\INotification; +use OCP\Notification\INotifier; +use OCP\Notification\UnknownNotificationException; + +class Notifier implements INotifier { + /** @var IL10nFactory */ + private $factory; + + public function __construct(IL10nFactory $l10nFactory) { + $this->factory = $l10nFactory; + } + + /** + * @inheritDoc + */ + public function prepare(INotification $notification, string $languageCode): INotification { + if ($notification->getApp() !== 'auth') { + // Not my app => throw + throw new UnknownNotificationException(); + } + + // Read the language from the notification + $l = $this->factory->get('lib', $languageCode); + + switch ($notification->getSubject()) { + case 'remote_wipe_start': + $notification->setParsedSubject( + $l->t('Remote wipe started') + )->setParsedMessage( + $l->t('A remote wipe was started on device %s', $notification->getSubjectParameters()) + ); + + return $notification; + case 'remote_wipe_finish': + $notification->setParsedSubject( + $l->t('Remote wipe finished') + )->setParsedMessage( + $l->t('The remote wipe on %s has finished', $notification->getSubjectParameters()) + ); + + return $notification; + default: + // Unknown subject => Unknown notification => throw + throw new UnknownNotificationException(); + } + } + + /** + * Identifier of the notifier, only use [a-z0-9_] + * + * @return string + * @since 17.0.0 + */ + public function getID(): string { + return 'auth'; + } + + /** + * Human readable name describing the notifier + * + * @return string + * @since 17.0.0 + */ + public function getName(): string { + return $this->factory->get('lib')->t('Authentication'); + } +} diff --git a/lib/private/Authentication/Token/INamedToken.php b/lib/private/Authentication/Token/INamedToken.php new file mode 100644 index 00000000000..9a90cfc7d76 --- /dev/null +++ b/lib/private/Authentication/Token/INamedToken.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +interface INamedToken extends IToken { + /** + * Set token name + * + * @param string $name + * @return void + */ + public function setName(string $name): void; +} diff --git a/lib/private/Authentication/Token/IProvider.php b/lib/private/Authentication/Token/IProvider.php new file mode 100644 index 00000000000..d47427e79bf --- /dev/null +++ b/lib/private/Authentication/Token/IProvider.php @@ -0,0 +1,172 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Token; + +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IToken as OCPIToken; + +interface IProvider { + /** + * Create and persist a new token + * + * @param string $token + * @param string $uid + * @param string $loginName + * @param string|null $password + * @param string $name Name will be trimmed to 120 chars when longer + * @param int $type token type + * @param int $remember whether the session token should be used for remember-me + * @return OCPIToken + * @throws \RuntimeException when OpenSSL reports a problem + */ + public function generateToken(string $token, + string $uid, + string $loginName, + ?string $password, + string $name, + int $type = OCPIToken::TEMPORARY_TOKEN, + int $remember = OCPIToken::DO_NOT_REMEMBER, + ?array $scope = null, + ): OCPIToken; + + /** + * Get a token by token id + * + * @param string $tokenId + * @throws InvalidTokenException + * @throws ExpiredTokenException + * @throws WipeTokenException + * @return OCPIToken + */ + public function getToken(string $tokenId): OCPIToken; + + /** + * Get a token by token id + * + * @param int $tokenId + * @throws InvalidTokenException + * @throws ExpiredTokenException + * @throws WipeTokenException + * @return OCPIToken + */ + public function getTokenById(int $tokenId): OCPIToken; + + /** + * Duplicate an existing session token + * + * @param string $oldSessionId + * @param string $sessionId + * @throws InvalidTokenException + * @throws \RuntimeException when OpenSSL reports a problem + * @return OCPIToken The new token + */ + public function renewSessionToken(string $oldSessionId, string $sessionId): OCPIToken; + + /** + * Invalidate (delete) the given session token + * + * @param string $token + */ + public function invalidateToken(string $token); + + /** + * Invalidate (delete) the given token + * + * @param string $uid + * @param int $id + */ + public function invalidateTokenById(string $uid, int $id); + + /** + * Invalidate (delete) old session tokens + */ + public function invalidateOldTokens(); + + /** + * Invalidate (delete) tokens last used before a given date + */ + public function invalidateLastUsedBefore(string $uid, int $before): void; + + /** + * Save the updated token + * + * @param OCPIToken $token + */ + public function updateToken(OCPIToken $token); + + /** + * Update token activity timestamp + * + * @param OCPIToken $token + */ + public function updateTokenActivity(OCPIToken $token); + + /** + * Get all tokens of a user + * + * The provider may limit the number of result rows in case of an abuse + * where a high number of (session) tokens is generated + * + * @param string $uid + * @return OCPIToken[] + */ + public function getTokenByUser(string $uid): array; + + /** + * Get the (unencrypted) password of the given token + * + * @param OCPIToken $savedToken + * @param string $tokenId + * @throws InvalidTokenException + * @throws PasswordlessTokenException + * @return string + */ + public function getPassword(OCPIToken $savedToken, string $tokenId): string; + + /** + * Encrypt and set the password of the given token + * + * @param OCPIToken $token + * @param string $tokenId + * @param string $password + * @throws InvalidTokenException + */ + public function setPassword(OCPIToken $token, string $tokenId, string $password); + + /** + * Rotate the token. Useful for for example oauth tokens + * + * @param OCPIToken $token + * @param string $oldTokenId + * @param string $newTokenId + * @return OCPIToken + * @throws \RuntimeException when OpenSSL reports a problem + */ + public function rotate(OCPIToken $token, string $oldTokenId, string $newTokenId): OCPIToken; + + /** + * Marks a token as having an invalid password. + * + * @param OCPIToken $token + * @param string $tokenId + */ + public function markPasswordInvalid(OCPIToken $token, string $tokenId); + + /** + * Update all the passwords of $uid if required + * + * @param string $uid + * @param string $password + */ + public function updatePasswords(string $uid, string $password); +} diff --git a/lib/private/Authentication/Token/IToken.php b/lib/private/Authentication/Token/IToken.php new file mode 100644 index 00000000000..2028a0b328c --- /dev/null +++ b/lib/private/Authentication/Token/IToken.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Token; + +use OCP\Authentication\Token\IToken as OCPIToken; + +/** + * @deprecated 28.0.0 use {@see \OCP\Authentication\Token\IToken} instead + */ +interface IToken extends OCPIToken { +} diff --git a/lib/private/Authentication/Token/IWipeableToken.php b/lib/private/Authentication/Token/IWipeableToken.php new file mode 100644 index 00000000000..fc1476785cd --- /dev/null +++ b/lib/private/Authentication/Token/IWipeableToken.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +interface IWipeableToken extends IToken { + /** + * Mark the token for remote wipe + */ + public function wipe(): void; +} diff --git a/lib/private/Authentication/Token/Manager.php b/lib/private/Authentication/Token/Manager.php new file mode 100644 index 00000000000..6953f47b004 --- /dev/null +++ b/lib/private/Authentication/Token/Manager.php @@ -0,0 +1,243 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OC\Authentication\Exceptions\InvalidTokenException as OcInvalidTokenException; +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IProvider as OCPIProvider; +use OCP\Authentication\Token\IToken as OCPIToken; + +class Manager implements IProvider, OCPIProvider { + /** @var PublicKeyTokenProvider */ + private $publicKeyTokenProvider; + + public function __construct(PublicKeyTokenProvider $publicKeyTokenProvider) { + $this->publicKeyTokenProvider = $publicKeyTokenProvider; + } + + /** + * Create and persist a new token + * + * @param string $token + * @param string $uid + * @param string $loginName + * @param string|null $password + * @param string $name Name will be trimmed to 120 chars when longer + * @param int $type token type + * @param int $remember whether the session token should be used for remember-me + * @return OCPIToken + */ + public function generateToken(string $token, + string $uid, + string $loginName, + $password, + string $name, + int $type = OCPIToken::TEMPORARY_TOKEN, + int $remember = OCPIToken::DO_NOT_REMEMBER, + ?array $scope = null, + ): OCPIToken { + if (mb_strlen($name) > 128) { + $name = mb_substr($name, 0, 120) . '…'; + } + + try { + return $this->publicKeyTokenProvider->generateToken( + $token, + $uid, + $loginName, + $password, + $name, + $type, + $remember, + $scope, + ); + } catch (UniqueConstraintViolationException $e) { + // It's rare, but if two requests of the same session (e.g. env-based SAML) + // try to create the session token they might end up here at the same time + // because we use the session ID as token and the db token is created anew + // with every request. + // + // If the UIDs match, then this should be fine. + $existing = $this->getToken($token); + if ($existing->getUID() !== $uid) { + throw new \Exception('Token conflict handled, but UIDs do not match. This should not happen', 0, $e); + } + return $existing; + } + } + + /** + * Save the updated token + * + * @param OCPIToken $token + * @throws InvalidTokenException + */ + public function updateToken(OCPIToken $token) { + $provider = $this->getProvider($token); + $provider->updateToken($token); + } + + /** + * Update token activity timestamp + * + * @throws InvalidTokenException + * @param OCPIToken $token + */ + public function updateTokenActivity(OCPIToken $token) { + $provider = $this->getProvider($token); + $provider->updateTokenActivity($token); + } + + /** + * @param string $uid + * @return OCPIToken[] + */ + public function getTokenByUser(string $uid): array { + return $this->publicKeyTokenProvider->getTokenByUser($uid); + } + + /** + * Get a token by token + * + * @param string $tokenId + * @throws InvalidTokenException + * @throws \RuntimeException when OpenSSL reports a problem + * @return OCPIToken + */ + public function getToken(string $tokenId): OCPIToken { + try { + return $this->publicKeyTokenProvider->getToken($tokenId); + } catch (WipeTokenException $e) { + throw $e; + } catch (ExpiredTokenException $e) { + throw $e; + } catch (InvalidTokenException $e) { + throw $e; + } + } + + /** + * Get a token by token id + * + * @param int $tokenId + * @throws InvalidTokenException + * @return OCPIToken + */ + public function getTokenById(int $tokenId): OCPIToken { + try { + return $this->publicKeyTokenProvider->getTokenById($tokenId); + } catch (ExpiredTokenException $e) { + throw $e; + } catch (WipeTokenException $e) { + throw $e; + } catch (InvalidTokenException $e) { + throw $e; + } + } + + /** + * @param string $oldSessionId + * @param string $sessionId + * @throws InvalidTokenException + * @return OCPIToken + */ + public function renewSessionToken(string $oldSessionId, string $sessionId): OCPIToken { + try { + return $this->publicKeyTokenProvider->renewSessionToken($oldSessionId, $sessionId); + } catch (ExpiredTokenException $e) { + throw $e; + } catch (InvalidTokenException $e) { + throw $e; + } + } + + /** + * @param OCPIToken $savedToken + * @param string $tokenId session token + * @throws InvalidTokenException + * @throws PasswordlessTokenException + * @return string + */ + public function getPassword(OCPIToken $savedToken, string $tokenId): string { + $provider = $this->getProvider($savedToken); + return $provider->getPassword($savedToken, $tokenId); + } + + public function setPassword(OCPIToken $token, string $tokenId, string $password) { + $provider = $this->getProvider($token); + $provider->setPassword($token, $tokenId, $password); + } + + public function invalidateToken(string $token) { + $this->publicKeyTokenProvider->invalidateToken($token); + } + + public function invalidateTokenById(string $uid, int $id) { + $this->publicKeyTokenProvider->invalidateTokenById($uid, $id); + } + + public function invalidateOldTokens() { + $this->publicKeyTokenProvider->invalidateOldTokens(); + } + + public function invalidateLastUsedBefore(string $uid, int $before): void { + $this->publicKeyTokenProvider->invalidateLastUsedBefore($uid, $before); + } + + /** + * @param OCPIToken $token + * @param string $oldTokenId + * @param string $newTokenId + * @return OCPIToken + * @throws InvalidTokenException + * @throws \RuntimeException when OpenSSL reports a problem + */ + public function rotate(OCPIToken $token, string $oldTokenId, string $newTokenId): OCPIToken { + if ($token instanceof PublicKeyToken) { + return $this->publicKeyTokenProvider->rotate($token, $oldTokenId, $newTokenId); + } + + /** @psalm-suppress DeprecatedClass We have to throw the OC version so both OC and OCP catches catch it */ + throw new OcInvalidTokenException(); + } + + /** + * @param OCPIToken $token + * @return IProvider + * @throws InvalidTokenException + */ + private function getProvider(OCPIToken $token): IProvider { + if ($token instanceof PublicKeyToken) { + return $this->publicKeyTokenProvider; + } + /** @psalm-suppress DeprecatedClass We have to throw the OC version so both OC and OCP catches catch it */ + throw new OcInvalidTokenException(); + } + + + public function markPasswordInvalid(OCPIToken $token, string $tokenId) { + $this->getProvider($token)->markPasswordInvalid($token, $tokenId); + } + + public function updatePasswords(string $uid, string $password) { + $this->publicKeyTokenProvider->updatePasswords($uid, $password); + } + + public function invalidateTokensOfUser(string $uid, ?string $clientName) { + $tokens = $this->getTokenByUser($uid); + foreach ($tokens as $token) { + if ($clientName === null || ($token->getName() === $clientName)) { + $this->invalidateTokenById($uid, $token->getId()); + } + } + } +} diff --git a/lib/private/Authentication/Token/PublicKeyToken.php b/lib/private/Authentication/Token/PublicKeyToken.php new file mode 100644 index 00000000000..be427ab4839 --- /dev/null +++ b/lib/private/Authentication/Token/PublicKeyToken.php @@ -0,0 +1,219 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +use OCP\AppFramework\Db\Entity; +use OCP\Authentication\Token\IToken; +use OCP\DB\Types; + +/** + * @method void setId(int $id) + * @method void setUid(string $uid); + * @method void setLoginName(string $loginname) + * @method string getToken() + * @method void setType(int $type) + * @method int getType() + * @method void setRemember(int $remember) + * @method void setLastActivity(int $lastactivity) + * @method int getLastActivity() + * @method string getPrivateKey() + * @method void setPrivateKey(string $key) + * @method string getPublicKey() + * @method void setPublicKey(string $key) + * @method void setVersion(int $version) + * @method bool getPasswordInvalid() + * @method string getPasswordHash() + * @method setPasswordHash(string $hash) + */ +class PublicKeyToken extends Entity implements INamedToken, IWipeableToken { + public const VERSION = 2; + + /** @var string user UID */ + protected $uid; + + /** @var string login name used for generating the token */ + protected $loginName; + + /** @var string encrypted user password */ + protected $password; + + /** @var string hashed user password */ + protected $passwordHash; + + /** @var string token name (e.g. browser/OS) */ + protected $name; + + /** @var string */ + protected $token; + + /** @var int */ + protected $type; + + /** @var int */ + protected $remember; + + /** @var int */ + protected $lastActivity; + + /** @var int */ + protected $lastCheck; + + /** @var string */ + protected $scope; + + /** @var int */ + protected $expires; + + /** @var string */ + protected $privateKey; + + /** @var string */ + protected $publicKey; + + /** @var int */ + protected $version; + + /** @var bool */ + protected $passwordInvalid; + + public function __construct() { + $this->addType('uid', 'string'); + $this->addType('loginName', 'string'); + $this->addType('password', 'string'); + $this->addType('passwordHash', 'string'); + $this->addType('name', 'string'); + $this->addType('token', 'string'); + $this->addType('type', Types::INTEGER); + $this->addType('remember', Types::INTEGER); + $this->addType('lastActivity', Types::INTEGER); + $this->addType('lastCheck', Types::INTEGER); + $this->addType('scope', 'string'); + $this->addType('expires', Types::INTEGER); + $this->addType('publicKey', 'string'); + $this->addType('privateKey', 'string'); + $this->addType('version', Types::INTEGER); + $this->addType('passwordInvalid', Types::BOOLEAN); + } + + public function getId(): int { + return $this->id; + } + + public function getUID(): string { + return $this->uid; + } + + /** + * Get the login name used when generating the token + * + * @return string + */ + public function getLoginName(): string { + return parent::getLoginName(); + } + + /** + * Get the (encrypted) login password + */ + public function getPassword(): ?string { + return parent::getPassword(); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'lastActivity' => $this->lastActivity, + 'type' => $this->type, + 'scope' => $this->getScopeAsArray() + ]; + } + + /** + * Get the timestamp of the last password check + * + * @return int + */ + public function getLastCheck(): int { + return parent::getLastCheck(); + } + + /** + * Get the timestamp of the last password check + */ + public function setLastCheck(int $time): void { + parent::setLastCheck($time); + } + + public function getScope(): string { + $scope = parent::getScope(); + if ($scope === null) { + return ''; + } + + return $scope; + } + + public function getScopeAsArray(): array { + $scope = json_decode($this->getScope(), true); + if (!$scope) { + return [ + IToken::SCOPE_FILESYSTEM => true + ]; + } + return $scope; + } + + public function setScope(array|string|null $scope): void { + if (is_array($scope)) { + parent::setScope(json_encode($scope)); + } else { + parent::setScope((string)$scope); + } + } + + public function getName(): string { + return parent::getName(); + } + + public function setName(string $name): void { + parent::setName($name); + } + + public function getRemember(): int { + return parent::getRemember(); + } + + public function setToken(string $token): void { + parent::setToken($token); + } + + public function setPassword(?string $password = null): void { + parent::setPassword($password); + } + + public function setExpires($expires): void { + parent::setExpires($expires); + } + + /** + * @return int|null + */ + public function getExpires() { + return parent::getExpires(); + } + + public function setPasswordInvalid(bool $invalid) { + parent::setPasswordInvalid($invalid); + } + + public function wipe(): void { + parent::setType(IToken::WIPE_TOKEN); + } +} diff --git a/lib/private/Authentication/Token/PublicKeyTokenMapper.php b/lib/private/Authentication/Token/PublicKeyTokenMapper.php new file mode 100644 index 00000000000..9aabd69e57a --- /dev/null +++ b/lib/private/Authentication/Token/PublicKeyTokenMapper.php @@ -0,0 +1,252 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\Authentication\Token\IToken; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<PublicKeyToken> + */ +class PublicKeyTokenMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'authtoken'); + } + + /** + * Invalidate (delete) a given token + */ + public function invalidate(string $token) { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + /** + * @param int $olderThan + * @param int $type + * @param int|null $remember + */ + public function invalidateOld(int $olderThan, int $type = IToken::TEMPORARY_TOKEN, ?int $remember = null) { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $delete = $qb->delete($this->tableName) + ->where($qb->expr()->lt('last_activity', $qb->createNamedParameter($olderThan, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + if ($remember !== null) { + $delete->andWhere($qb->expr()->eq('remember', $qb->createNamedParameter($remember, IQueryBuilder::PARAM_INT))); + } + $delete->executeStatement(); + } + + public function invalidateLastUsedBefore(string $uid, int $before): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->lt('last_activity', $qb->createNamedParameter($before, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement(); + } + + /** + * Get the user UID for the given token + * + * @throws DoesNotExistException + */ + public function getToken(string $token): PublicKeyToken { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + + $data = $result->fetch(); + $result->closeCursor(); + if ($data === false) { + throw new DoesNotExistException('token does not exist'); + } + return PublicKeyToken::fromRow($data); + } + + /** + * Get the token for $id + * + * @throws DoesNotExistException + */ + public function getTokenById(int $id): PublicKeyToken { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + + $data = $result->fetch(); + $result->closeCursor(); + if ($data === false) { + throw new DoesNotExistException('token does not exist'); + } + return PublicKeyToken::fromRow($data); + } + + /** + * Get all tokens of a user + * + * The provider may limit the number of result rows in case of an abuse + * where a high number of (session) tokens is generated + * + * @param string $uid + * @return PublicKeyToken[] + */ + public function getTokenByUser(string $uid): array { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))) + ->setMaxResults(1000); + $result = $qb->executeQuery(); + $data = $result->fetchAll(); + $result->closeCursor(); + + $entities = array_map(function ($row) { + return PublicKeyToken::fromRow($row); + }, $data); + + return $entities; + } + + public function getTokenByUserAndId(string $uid, int $id): ?string { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('token') + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + return $qb->executeQuery()->fetchOne() ?: null; + } + + /** + * delete all auth token which belong to a specific client if the client was deleted + * + * @param string $name + */ + public function deleteByName(string $name) { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->eq('name', $qb->createNamedParameter($name), IQueryBuilder::PARAM_STR)) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } + + public function deleteTempToken(PublicKeyToken $except) { + $qb = $this->db->getQueryBuilder(); + + $qb->delete($this->tableName) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($except->getUID()))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter(IToken::TEMPORARY_TOKEN))) + ->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($except->getId()))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } + + public function hasExpiredTokens(string $uid): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))) + ->andWhere($qb->expr()->eq('password_invalid', $qb->createNamedParameter(true), IQueryBuilder::PARAM_BOOL)) + ->setMaxResults(1); + + $cursor = $qb->executeQuery(); + $data = $cursor->fetchAll(); + $cursor->closeCursor(); + + return count($data) === 1; + } + + /** + * Update the last activity timestamp + * + * In highly concurrent setups it can happen that two parallel processes + * trigger the update at (nearly) the same time. In that special case it's + * not necessary to hit the database with two actual updates. Therefore the + * target last activity is included in the WHERE clause with a few seconds + * of tolerance. + * + * Example: + * - process 1 (P1) reads the token at timestamp 1500 + * - process 1 (P2) reads the token at timestamp 1501 + * - activity update interval is 100 + * + * This means + * + * - P1 will see a last_activity smaller than the current time and update + * the token row + * - If P2 reads after P1 had written, it will see 1600 as last activity + * and the comparison on last_activity won't be truthy. This means no rows + * need to be updated a second time + * - If P2 reads before P1 had written, it will see 1501 as last activity, + * but the comparison on last_activity will still not be truthy and the + * token row is not updated a second time + * + * @param IToken $token + * @param int $now + */ + public function updateActivity(IToken $token, int $now): void { + $qb = $this->db->getQueryBuilder(); + $update = $qb->update($this->getTableName()) + ->set('last_activity', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $qb->expr()->lt('last_activity', $qb->createNamedParameter($now - 15, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT) + ); + $update->executeStatement(); + } + + public function updateHashesForUser(string $userId, string $passwordHash): void { + $qb = $this->db->getQueryBuilder(); + $update = $qb->update($this->getTableName()) + ->set('password_hash', $qb->createNamedParameter($passwordHash)) + ->where( + $qb->expr()->eq('uid', $qb->createNamedParameter($userId)) + ); + $update->executeStatement(); + } + + public function getFirstTokenForUser(string $userId): ?PublicKeyToken { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($userId))) + ->setMaxResults(1) + ->orderBy('id'); + $result = $qb->executeQuery(); + + $data = $result->fetch(); + $result->closeCursor(); + if ($data === false) { + return null; + } + return PublicKeyToken::fromRow($data); + } +} diff --git a/lib/private/Authentication/Token/PublicKeyTokenProvider.php b/lib/private/Authentication/Token/PublicKeyTokenProvider.php new file mode 100644 index 00000000000..12c3a1d535b --- /dev/null +++ b/lib/private/Authentication/Token/PublicKeyTokenProvider.php @@ -0,0 +1,566 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +use OC\Authentication\Exceptions\ExpiredTokenException; +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OC\Authentication\Exceptions\TokenPasswordExpiredException; +use OC\Authentication\Exceptions\WipeTokenException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\TTransactional; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Token\IToken as OCPIToken; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IUserManager; +use OCP\Security\ICrypto; +use OCP\Security\IHasher; +use Psr\Log\LoggerInterface; + +class PublicKeyTokenProvider implements IProvider { + public const TOKEN_MIN_LENGTH = 22; + /** Token cache TTL in seconds */ + private const TOKEN_CACHE_TTL = 10; + + use TTransactional; + + /** @var PublicKeyTokenMapper */ + private $mapper; + + /** @var ICrypto */ + private $crypto; + + /** @var IConfig */ + private $config; + + private IDBConnection $db; + + /** @var LoggerInterface */ + private $logger; + + /** @var ITimeFactory */ + private $time; + + /** @var ICache */ + private $cache; + + /** @var IHasher */ + private $hasher; + + public function __construct(PublicKeyTokenMapper $mapper, + ICrypto $crypto, + IConfig $config, + IDBConnection $db, + LoggerInterface $logger, + ITimeFactory $time, + IHasher $hasher, + ICacheFactory $cacheFactory) { + $this->mapper = $mapper; + $this->crypto = $crypto; + $this->config = $config; + $this->db = $db; + $this->logger = $logger; + $this->time = $time; + + $this->cache = $cacheFactory->isLocalCacheAvailable() + ? $cacheFactory->createLocal('authtoken_') + : $cacheFactory->createInMemory(); + $this->hasher = $hasher; + } + + /** + * {@inheritDoc} + */ + public function generateToken(string $token, + string $uid, + string $loginName, + ?string $password, + string $name, + int $type = OCPIToken::TEMPORARY_TOKEN, + int $remember = OCPIToken::DO_NOT_REMEMBER, + ?array $scope = null, + ): OCPIToken { + if (strlen($token) < self::TOKEN_MIN_LENGTH) { + $exception = new InvalidTokenException('Token is too short, minimum of ' . self::TOKEN_MIN_LENGTH . ' characters is required, ' . strlen($token) . ' characters given'); + $this->logger->error('Invalid token provided when generating new token', ['exception' => $exception]); + throw $exception; + } + + if (mb_strlen($name) > 128) { + $name = mb_substr($name, 0, 120) . '…'; + } + + // We need to check against one old token to see if there is a password + // hash that we can reuse for detecting outdated passwords + $randomOldToken = $this->mapper->getFirstTokenForUser($uid); + $oldTokenMatches = $randomOldToken && $randomOldToken->getPasswordHash() && $password !== null && $this->hasher->verify(sha1($password) . $password, $randomOldToken->getPasswordHash()); + + $dbToken = $this->newToken($token, $uid, $loginName, $password, $name, $type, $remember); + + if ($oldTokenMatches) { + $dbToken->setPasswordHash($randomOldToken->getPasswordHash()); + } + + if ($scope !== null) { + $dbToken->setScope($scope); + } + + $this->mapper->insert($dbToken); + + if (!$oldTokenMatches && $password !== null) { + $this->updatePasswords($uid, $password); + } + + // Add the token to the cache + $this->cacheToken($dbToken); + + return $dbToken; + } + + public function getToken(string $tokenId): OCPIToken { + /** + * Token length: 72 + * @see \OC\Core\Controller\ClientFlowLoginController::generateAppPassword + * @see \OC\Core\Controller\AppPasswordController::getAppPassword + * @see \OC\Core\Command\User\AddAppPassword::execute + * @see \OC\Core\Service\LoginFlowV2Service::flowDone + * @see \OCA\Talk\MatterbridgeManager::generatePassword + * @see \OCA\Preferred_Providers\Controller\PasswordController::generateAppPassword + * @see \OCA\GlobalSiteSelector\TokenHandler::generateAppPassword + * + * Token length: 22-256 - https://www.php.net/manual/en/session.configuration.php#ini.session.sid-length + * @see \OC\User\Session::createSessionToken + * + * Token length: 29 + * @see \OCA\Settings\Controller\AuthSettingsController::generateRandomDeviceToken + * @see \OCA\Registration\Service\RegistrationService::generateAppPassword + */ + if (strlen($tokenId) < self::TOKEN_MIN_LENGTH) { + throw new InvalidTokenException('Token is too short for a generated token, should be the password during basic auth'); + } + + $tokenHash = $this->hashToken($tokenId); + if ($token = $this->getTokenFromCache($tokenHash)) { + $this->checkToken($token); + return $token; + } + + try { + $token = $this->mapper->getToken($tokenHash); + $this->cacheToken($token); + } catch (DoesNotExistException $ex) { + try { + $token = $this->mapper->getToken($this->hashTokenWithEmptySecret($tokenId)); + $this->rotate($token, $tokenId, $tokenId); + } catch (DoesNotExistException) { + $this->cacheInvalidHash($tokenHash); + throw new InvalidTokenException('Token does not exist: ' . $ex->getMessage(), 0, $ex); + } + } + + $this->checkToken($token); + + return $token; + } + + /** + * @throws InvalidTokenException when token doesn't exist + */ + private function getTokenFromCache(string $tokenHash): ?PublicKeyToken { + $serializedToken = $this->cache->get($tokenHash); + if ($serializedToken === false) { + return null; + } + + if ($serializedToken === null) { + return null; + } + + $token = unserialize($serializedToken, [ + 'allowed_classes' => [PublicKeyToken::class], + ]); + + return $token instanceof PublicKeyToken ? $token : null; + } + + private function cacheToken(PublicKeyToken $token): void { + $this->cache->set($token->getToken(), serialize($token), self::TOKEN_CACHE_TTL); + } + + private function cacheInvalidHash(string $tokenHash): void { + // Invalid entries can be kept longer in cache since it’s unlikely to reuse them + $this->cache->set($tokenHash, false, self::TOKEN_CACHE_TTL * 2); + } + + public function getTokenById(int $tokenId): OCPIToken { + try { + $token = $this->mapper->getTokenById($tokenId); + } catch (DoesNotExistException $ex) { + throw new InvalidTokenException("Token with ID $tokenId does not exist: " . $ex->getMessage(), 0, $ex); + } + + $this->checkToken($token); + + return $token; + } + + private function checkToken($token): void { + if ((int)$token->getExpires() !== 0 && $token->getExpires() < $this->time->getTime()) { + throw new ExpiredTokenException($token); + } + + if ($token->getType() === OCPIToken::WIPE_TOKEN) { + throw new WipeTokenException($token); + } + + if ($token->getPasswordInvalid() === true) { + //The password is invalid we should throw an TokenPasswordExpiredException + throw new TokenPasswordExpiredException($token); + } + } + + public function renewSessionToken(string $oldSessionId, string $sessionId): OCPIToken { + return $this->atomic(function () use ($oldSessionId, $sessionId) { + $token = $this->getToken($oldSessionId); + + if (!($token instanceof PublicKeyToken)) { + throw new InvalidTokenException('Invalid token type'); + } + + $password = null; + if (!is_null($token->getPassword())) { + $privateKey = $this->decrypt($token->getPrivateKey(), $oldSessionId); + $password = $this->decryptPassword($token->getPassword(), $privateKey); + } + + $scope = $token->getScope() === '' ? null : $token->getScopeAsArray(); + $newToken = $this->generateToken( + $sessionId, + $token->getUID(), + $token->getLoginName(), + $password, + $token->getName(), + OCPIToken::TEMPORARY_TOKEN, + $token->getRemember(), + $scope, + ); + $this->cacheToken($newToken); + + $this->cacheInvalidHash($token->getToken()); + $this->mapper->delete($token); + + return $newToken; + }, $this->db); + } + + public function invalidateToken(string $token) { + $tokenHash = $this->hashToken($token); + $this->mapper->invalidate($this->hashToken($token)); + $this->mapper->invalidate($this->hashTokenWithEmptySecret($token)); + $this->cacheInvalidHash($tokenHash); + } + + public function invalidateTokenById(string $uid, int $id) { + $token = $this->mapper->getTokenById($id); + if ($token->getUID() !== $uid) { + return; + } + $this->mapper->invalidate($token->getToken()); + $this->cacheInvalidHash($token->getToken()); + + } + + public function invalidateOldTokens() { + $olderThan = $this->time->getTime() - $this->config->getSystemValueInt('session_lifetime', 60 * 60 * 24); + $this->logger->debug('Invalidating session tokens older than ' . date('c', $olderThan), ['app' => 'cron']); + $this->mapper->invalidateOld($olderThan, OCPIToken::TEMPORARY_TOKEN, OCPIToken::DO_NOT_REMEMBER); + + $rememberThreshold = $this->time->getTime() - $this->config->getSystemValueInt('remember_login_cookie_lifetime', 60 * 60 * 24 * 15); + $this->logger->debug('Invalidating remembered session tokens older than ' . date('c', $rememberThreshold), ['app' => 'cron']); + $this->mapper->invalidateOld($rememberThreshold, OCPIToken::TEMPORARY_TOKEN, OCPIToken::REMEMBER); + + $wipeThreshold = $this->time->getTime() - $this->config->getSystemValueInt('token_auth_wipe_token_retention', 60 * 60 * 24 * 60); + $this->logger->debug('Invalidating auth tokens marked for remote wipe older than ' . date('c', $wipeThreshold), ['app' => 'cron']); + $this->mapper->invalidateOld($wipeThreshold, OCPIToken::WIPE_TOKEN); + + $authTokenThreshold = $this->time->getTime() - $this->config->getSystemValueInt('token_auth_token_retention', 60 * 60 * 24 * 365); + $this->logger->debug('Invalidating auth tokens older than ' . date('c', $authTokenThreshold), ['app' => 'cron']); + $this->mapper->invalidateOld($authTokenThreshold, OCPIToken::PERMANENT_TOKEN); + } + + public function invalidateLastUsedBefore(string $uid, int $before): void { + $this->mapper->invalidateLastUsedBefore($uid, $before); + } + + public function updateToken(OCPIToken $token) { + if (!($token instanceof PublicKeyToken)) { + throw new InvalidTokenException('Invalid token type'); + } + $this->mapper->update($token); + $this->cacheToken($token); + } + + public function updateTokenActivity(OCPIToken $token) { + if (!($token instanceof PublicKeyToken)) { + throw new InvalidTokenException('Invalid token type'); + } + + $activityInterval = $this->config->getSystemValueInt('token_auth_activity_update', 60); + $activityInterval = min(max($activityInterval, 0), 300); + + /** @var PublicKeyToken $token */ + $now = $this->time->getTime(); + if ($token->getLastActivity() < ($now - $activityInterval)) { + $token->setLastActivity($now); + $this->mapper->updateActivity($token, $now); + $this->cacheToken($token); + } + } + + public function getTokenByUser(string $uid): array { + return $this->mapper->getTokenByUser($uid); + } + + public function getPassword(OCPIToken $savedToken, string $tokenId): string { + if (!($savedToken instanceof PublicKeyToken)) { + throw new InvalidTokenException('Invalid token type'); + } + + if ($savedToken->getPassword() === null) { + throw new PasswordlessTokenException(); + } + + // Decrypt private key with tokenId + $privateKey = $this->decrypt($savedToken->getPrivateKey(), $tokenId); + + // Decrypt password with private key + return $this->decryptPassword($savedToken->getPassword(), $privateKey); + } + + public function setPassword(OCPIToken $token, string $tokenId, string $password) { + if (!($token instanceof PublicKeyToken)) { + throw new InvalidTokenException('Invalid token type'); + } + + $this->atomic(function () use ($password, $token) { + // When changing passwords all temp tokens are deleted + $this->mapper->deleteTempToken($token); + + // Update the password for all tokens + $tokens = $this->mapper->getTokenByUser($token->getUID()); + $hashedPassword = $this->hashPassword($password); + foreach ($tokens as $t) { + $publicKey = $t->getPublicKey(); + $t->setPassword($this->encryptPassword($password, $publicKey)); + $t->setPasswordHash($hashedPassword); + $this->updateToken($t); + } + }, $this->db); + } + + private function hashPassword(string $password): string { + return $this->hasher->hash(sha1($password) . $password); + } + + public function rotate(OCPIToken $token, string $oldTokenId, string $newTokenId): OCPIToken { + if (!($token instanceof PublicKeyToken)) { + throw new InvalidTokenException('Invalid token type'); + } + + // Decrypt private key with oldTokenId + $privateKey = $this->decrypt($token->getPrivateKey(), $oldTokenId); + // Encrypt with the new token + $token->setPrivateKey($this->encrypt($privateKey, $newTokenId)); + + $token->setToken($this->hashToken($newTokenId)); + $this->updateToken($token); + + return $token; + } + + private function encrypt(string $plaintext, string $token): string { + $secret = $this->config->getSystemValueString('secret'); + return $this->crypto->encrypt($plaintext, $token . $secret); + } + + /** + * @throws InvalidTokenException + */ + private function decrypt(string $cipherText, string $token): string { + $secret = $this->config->getSystemValueString('secret'); + try { + return $this->crypto->decrypt($cipherText, $token . $secret); + } catch (\Exception $ex) { + // Retry with empty secret as a fallback for instances where the secret might not have been set by accident + try { + return $this->crypto->decrypt($cipherText, $token); + } catch (\Exception $ex2) { + // Delete the invalid token + $this->invalidateToken($token); + throw new InvalidTokenException('Could not decrypt token password: ' . $ex->getMessage(), 0, $ex2); + } + } + } + + private function encryptPassword(string $password, string $publicKey): string { + openssl_public_encrypt($password, $encryptedPassword, $publicKey, OPENSSL_PKCS1_OAEP_PADDING); + $encryptedPassword = base64_encode($encryptedPassword); + + return $encryptedPassword; + } + + private function decryptPassword(string $encryptedPassword, string $privateKey): string { + $encryptedPassword = base64_decode($encryptedPassword); + openssl_private_decrypt($encryptedPassword, $password, $privateKey, OPENSSL_PKCS1_OAEP_PADDING); + + return $password; + } + + private function hashToken(string $token): string { + $secret = $this->config->getSystemValueString('secret'); + return hash('sha512', $token . $secret); + } + + /** + * @deprecated 26.0.0 Fallback for instances where the secret might not have been set by accident + */ + private function hashTokenWithEmptySecret(string $token): string { + return hash('sha512', $token); + } + + /** + * @throws \RuntimeException when OpenSSL reports a problem + */ + private function newToken(string $token, + string $uid, + string $loginName, + $password, + string $name, + int $type, + int $remember): PublicKeyToken { + $dbToken = new PublicKeyToken(); + $dbToken->setUid($uid); + $dbToken->setLoginName($loginName); + + $config = array_merge([ + 'digest_alg' => 'sha512', + 'private_key_bits' => $password !== null && strlen($password) > 250 ? 4096 : 2048, + ], $this->config->getSystemValue('openssl', [])); + + // Generate new key + $res = openssl_pkey_new($config); + if ($res === false) { + $this->logOpensslError(); + throw new \RuntimeException('OpenSSL reported a problem'); + } + + if (openssl_pkey_export($res, $privateKey, null, $config) === false) { + $this->logOpensslError(); + throw new \RuntimeException('OpenSSL reported a problem'); + } + + // Extract the public key from $res to $pubKey + $publicKey = openssl_pkey_get_details($res); + $publicKey = $publicKey['key']; + + $dbToken->setPublicKey($publicKey); + $dbToken->setPrivateKey($this->encrypt($privateKey, $token)); + + if (!is_null($password) && $this->config->getSystemValueBool('auth.storeCryptedPassword', true)) { + if (strlen($password) > IUserManager::MAX_PASSWORD_LENGTH) { + throw new \RuntimeException('Trying to save a password with more than 469 characters is not supported. If you want to use big passwords, disable the auth.storeCryptedPassword option in config.php'); + } + $dbToken->setPassword($this->encryptPassword($password, $publicKey)); + $dbToken->setPasswordHash($this->hashPassword($password)); + } + + $dbToken->setName($name); + $dbToken->setToken($this->hashToken($token)); + $dbToken->setType($type); + $dbToken->setRemember($remember); + $dbToken->setLastActivity($this->time->getTime()); + $dbToken->setLastCheck($this->time->getTime()); + $dbToken->setVersion(PublicKeyToken::VERSION); + + return $dbToken; + } + + public function markPasswordInvalid(OCPIToken $token, string $tokenId) { + if (!($token instanceof PublicKeyToken)) { + throw new InvalidTokenException('Invalid token type'); + } + + $token->setPasswordInvalid(true); + $this->mapper->update($token); + $this->cacheToken($token); + } + + public function updatePasswords(string $uid, string $password) { + // prevent setting an empty pw as result of pw-less-login + if ($password === '' || !$this->config->getSystemValueBool('auth.storeCryptedPassword', true)) { + return; + } + + $this->atomic(function () use ($password, $uid) { + // Update the password for all tokens + $tokens = $this->mapper->getTokenByUser($uid); + $newPasswordHash = null; + + /** + * - true: The password hash could not be verified anymore + * and the token needs to be updated with the newly encrypted password + * - false: The hash could still be verified + * - missing: The hash needs to be verified + */ + $hashNeedsUpdate = []; + + foreach ($tokens as $t) { + if (!isset($hashNeedsUpdate[$t->getPasswordHash()])) { + if ($t->getPasswordHash() === null) { + $hashNeedsUpdate[$t->getPasswordHash() ?: ''] = true; + } elseif (!$this->hasher->verify(sha1($password) . $password, $t->getPasswordHash())) { + $hashNeedsUpdate[$t->getPasswordHash() ?: ''] = true; + } else { + $hashNeedsUpdate[$t->getPasswordHash() ?: ''] = false; + } + } + $needsUpdating = $hashNeedsUpdate[$t->getPasswordHash() ?: ''] ?? true; + + if ($needsUpdating) { + if ($newPasswordHash === null) { + $newPasswordHash = $this->hashPassword($password); + } + + $publicKey = $t->getPublicKey(); + $t->setPassword($this->encryptPassword($password, $publicKey)); + $t->setPasswordHash($newPasswordHash); + $t->setPasswordInvalid(false); + $this->updateToken($t); + } + } + + // If password hashes are different we update them all to be equal so + // that the next execution only needs to verify once + if (count($hashNeedsUpdate) > 1) { + $newPasswordHash = $this->hashPassword($password); + $this->mapper->updateHashesForUser($uid, $newPasswordHash); + } + }, $this->db); + } + + private function logOpensslError() { + $errors = []; + while ($error = openssl_error_string()) { + $errors[] = $error; + } + $this->logger->critical('Something is wrong with your openssl setup: ' . implode(', ', $errors)); + } +} diff --git a/lib/private/Authentication/Token/RemoteWipe.php b/lib/private/Authentication/Token/RemoteWipe.php new file mode 100644 index 00000000000..80ba330b66d --- /dev/null +++ b/lib/private/Authentication/Token/RemoteWipe.php @@ -0,0 +1,134 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\Token; + +use OC\Authentication\Events\RemoteWipeFinished; +use OC\Authentication\Events\RemoteWipeStarted; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUser; +use Psr\Log\LoggerInterface; +use function array_filter; + +class RemoteWipe { + /** @var IProvider */ + private $tokenProvider; + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(IProvider $tokenProvider, + IEventDispatcher $eventDispatcher, + LoggerInterface $logger) { + $this->tokenProvider = $tokenProvider; + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + } + + /** + * @param IToken $token + * @return bool + * + * @throws InvalidTokenException + * @throws WipeTokenException + */ + public function markTokenForWipe(IToken $token): bool { + if (!$token instanceof IWipeableToken) { + return false; + } + + $token->wipe(); + $this->tokenProvider->updateToken($token); + + return true; + } + + /** + * @param IUser $user + * + * @return bool true if any tokens have been marked for remote wipe + */ + public function markAllTokensForWipe(IUser $user): bool { + $tokens = $this->tokenProvider->getTokenByUser($user->getUID()); + + /** @var IWipeableToken[] $wipeable */ + $wipeable = array_filter($tokens, function (IToken $token) { + return $token instanceof IWipeableToken; + }); + + if (empty($wipeable)) { + return false; + } + + foreach ($wipeable as $token) { + $token->wipe(); + $this->tokenProvider->updateToken($token); + } + + return true; + } + + /** + * @param string $token + * + * @return bool whether wiping was started + * @throws InvalidTokenException + * + */ + public function start(string $token): bool { + try { + $this->tokenProvider->getToken($token); + + // We expect a WipedTokenException here. If we reach this point this + // is an ordinary token + return false; + } catch (WipeTokenException $e) { + // Expected -> continue below + } + + $dbToken = $e->getToken(); + + $this->logger->info('user ' . $dbToken->getUID() . ' started a remote wipe'); + + $this->eventDispatcher->dispatch(RemoteWipeStarted::class, new RemoteWipeStarted($dbToken)); + + return true; + } + + /** + * @param string $token + * + * @return bool whether wiping could be finished + * @throws InvalidTokenException + */ + public function finish(string $token): bool { + try { + $this->tokenProvider->getToken($token); + + // We expect a WipedTokenException here. If we reach this point this + // is an ordinary token + return false; + } catch (WipeTokenException $e) { + // Expected -> continue below + } + + $dbToken = $e->getToken(); + + $this->tokenProvider->invalidateToken($token); + + $this->logger->info('user ' . $dbToken->getUID() . ' finished a remote wipe'); + $this->eventDispatcher->dispatch(RemoteWipeFinished::class, new RemoteWipeFinished($dbToken)); + + return true; + } +} diff --git a/lib/private/Authentication/Token/TokenCleanupJob.php b/lib/private/Authentication/Token/TokenCleanupJob.php new file mode 100644 index 00000000000..e6d1e69e9b4 --- /dev/null +++ b/lib/private/Authentication/Token/TokenCleanupJob.php @@ -0,0 +1,26 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\Token; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; + +class TokenCleanupJob extends TimedJob { + private IProvider $provider; + + public function __construct(ITimeFactory $time, IProvider $provider) { + parent::__construct($time); + $this->provider = $provider; + // Run once a day at off-peak time + $this->setInterval(24 * 60 * 60); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + protected function run($argument) { + $this->provider->invalidateOldTokens(); + } +} diff --git a/lib/private/Authentication/TwoFactorAuth/Db/ProviderUserAssignmentDao.php b/lib/private/Authentication/TwoFactorAuth/Db/ProviderUserAssignmentDao.php new file mode 100644 index 00000000000..cc468dbeba0 --- /dev/null +++ b/lib/private/Authentication/TwoFactorAuth/Db/ProviderUserAssignmentDao.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\TwoFactorAuth\Db; + +use OCP\IDBConnection; +use function array_map; + +/** + * Data access object to query and assign (provider_id, uid, enabled) tuples of + * 2FA providers + */ +class ProviderUserAssignmentDao { + public const TABLE_NAME = 'twofactor_providers'; + + /** @var IDBConnection */ + private $conn; + + public function __construct(IDBConnection $dbConn) { + $this->conn = $dbConn; + } + + /** + * Get all assigned provider IDs for the given user ID + * + * @return array<string, bool> where the array key is the provider ID (string) and the + * value is the enabled state (bool) + */ + public function getState(string $uid): array { + $qb = $this->conn->getQueryBuilder(); + + $query = $qb->select('provider_id', 'enabled') + ->from(self::TABLE_NAME) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))); + $result = $query->executeQuery(); + $providers = []; + foreach ($result->fetchAll() as $row) { + $providers[(string)$row['provider_id']] = (int)$row['enabled'] === 1; + } + $result->closeCursor(); + + return $providers; + } + + /** + * Persist a new/updated (provider_id, uid, enabled) tuple + */ + public function persist(string $providerId, string $uid, int $enabled): void { + $conn = $this->conn; + + // Insert a new entry + if ($conn->insertIgnoreConflict(self::TABLE_NAME, [ + 'provider_id' => $providerId, + 'uid' => $uid, + 'enabled' => $enabled, + ])) { + return; + } + + // There is already an entry -> update it + $qb = $conn->getQueryBuilder(); + $updateQuery = $qb->update(self::TABLE_NAME) + ->set('enabled', $qb->createNamedParameter($enabled)) + ->where($qb->expr()->eq('provider_id', $qb->createNamedParameter($providerId))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid))); + $updateQuery->executeStatement(); + } + + /** + * Delete all provider states of a user and return the provider IDs + * + * @return list<array{provider_id: string, uid: string, enabled: bool}> + */ + public function deleteByUser(string $uid): array { + $qb1 = $this->conn->getQueryBuilder(); + $selectQuery = $qb1->select('*') + ->from(self::TABLE_NAME) + ->where($qb1->expr()->eq('uid', $qb1->createNamedParameter($uid))); + $selectResult = $selectQuery->executeQuery(); + $rows = $selectResult->fetchAll(); + $selectResult->closeCursor(); + + $qb2 = $this->conn->getQueryBuilder(); + $deleteQuery = $qb2 + ->delete(self::TABLE_NAME) + ->where($qb2->expr()->eq('uid', $qb2->createNamedParameter($uid))); + $deleteQuery->executeStatement(); + + return array_values(array_map(function (array $row) { + return [ + 'provider_id' => (string)$row['provider_id'], + 'uid' => (string)$row['uid'], + 'enabled' => ((int)$row['enabled']) === 1, + ]; + }, $rows)); + } + + public function deleteAll(string $providerId): void { + $qb = $this->conn->getQueryBuilder(); + + $deleteQuery = $qb->delete(self::TABLE_NAME) + ->where($qb->expr()->eq('provider_id', $qb->createNamedParameter($providerId))); + + $deleteQuery->executeStatement(); + } +} diff --git a/lib/private/Authentication/TwoFactorAuth/EnforcementState.php b/lib/private/Authentication/TwoFactorAuth/EnforcementState.php new file mode 100644 index 00000000000..e02064bc8f7 --- /dev/null +++ b/lib/private/Authentication/TwoFactorAuth/EnforcementState.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\TwoFactorAuth; + +use JsonSerializable; + +class EnforcementState implements JsonSerializable { + /** @var bool */ + private $enforced; + + /** @var array */ + private $enforcedGroups; + + /** @var array */ + private $excludedGroups; + + /** + * EnforcementState constructor. + * + * @param bool $enforced + * @param string[] $enforcedGroups + * @param string[] $excludedGroups + */ + public function __construct(bool $enforced, + array $enforcedGroups = [], + array $excludedGroups = []) { + $this->enforced = $enforced; + $this->enforcedGroups = $enforcedGroups; + $this->excludedGroups = $excludedGroups; + } + + /** + * @return bool + */ + public function isEnforced(): bool { + return $this->enforced; + } + + /** + * @return string[] + */ + public function getEnforcedGroups(): array { + return $this->enforcedGroups; + } + + /** + * @return string[] + */ + public function getExcludedGroups(): array { + return $this->excludedGroups; + } + + public function jsonSerialize(): array { + return [ + 'enforced' => $this->enforced, + 'enforcedGroups' => $this->enforcedGroups, + 'excludedGroups' => $this->excludedGroups, + ]; + } +} diff --git a/lib/private/Authentication/TwoFactorAuth/Manager.php b/lib/private/Authentication/TwoFactorAuth/Manager.php new file mode 100644 index 00000000000..07aa98610ed --- /dev/null +++ b/lib/private/Authentication/TwoFactorAuth/Manager.php @@ -0,0 +1,378 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Authentication\TwoFactorAuth; + +use BadMethodCallException; +use Exception; +use OC\Authentication\Token\IProvider as TokenProvider; +use OCP\Activity\IManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\TwoFactorAuth\IActivatableAtLogin; +use OCP\Authentication\TwoFactorAuth\IProvider; +use OCP\Authentication\TwoFactorAuth\IRegistry; +use OCP\Authentication\TwoFactorAuth\TwoFactorProviderChallengeFailed; +use OCP\Authentication\TwoFactorAuth\TwoFactorProviderChallengePassed; +use OCP\Authentication\TwoFactorAuth\TwoFactorProviderForUserDisabled; +use OCP\Authentication\TwoFactorAuth\TwoFactorProviderForUserEnabled; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; +use OCP\ISession; +use OCP\IUser; +use OCP\Session\Exceptions\SessionNotAvailableException; +use Psr\Log\LoggerInterface; +use function array_diff; +use function array_filter; + +class Manager { + public const SESSION_UID_KEY = 'two_factor_auth_uid'; + public const SESSION_UID_DONE = 'two_factor_auth_passed'; + public const REMEMBER_LOGIN = 'two_factor_remember_login'; + public const BACKUP_CODES_PROVIDER_ID = 'backup_codes'; + + /** @var ProviderLoader */ + private $providerLoader; + + /** @var IRegistry */ + private $providerRegistry; + + /** @var MandatoryTwoFactor */ + private $mandatoryTwoFactor; + + /** @var ISession */ + private $session; + + /** @var IConfig */ + private $config; + + /** @var IManager */ + private $activityManager; + + /** @var LoggerInterface */ + private $logger; + + /** @var TokenProvider */ + private $tokenProvider; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var IEventDispatcher */ + private $dispatcher; + + /** @psalm-var array<string, bool> */ + private $userIsTwoFactorAuthenticated = []; + + public function __construct(ProviderLoader $providerLoader, + IRegistry $providerRegistry, + MandatoryTwoFactor $mandatoryTwoFactor, + ISession $session, + IConfig $config, + IManager $activityManager, + LoggerInterface $logger, + TokenProvider $tokenProvider, + ITimeFactory $timeFactory, + IEventDispatcher $eventDispatcher) { + $this->providerLoader = $providerLoader; + $this->providerRegistry = $providerRegistry; + $this->mandatoryTwoFactor = $mandatoryTwoFactor; + $this->session = $session; + $this->config = $config; + $this->activityManager = $activityManager; + $this->logger = $logger; + $this->tokenProvider = $tokenProvider; + $this->timeFactory = $timeFactory; + $this->dispatcher = $eventDispatcher; + } + + /** + * Determine whether the user must provide a second factor challenge + */ + public function isTwoFactorAuthenticated(IUser $user): bool { + if (isset($this->userIsTwoFactorAuthenticated[$user->getUID()])) { + return $this->userIsTwoFactorAuthenticated[$user->getUID()]; + } + + if ($this->mandatoryTwoFactor->isEnforcedFor($user)) { + return true; + } + + $providerStates = $this->providerRegistry->getProviderStates($user); + $providers = $this->providerLoader->getProviders($user); + $fixedStates = $this->fixMissingProviderStates($providerStates, $providers, $user); + $enabled = array_filter($fixedStates); + $providerIds = array_keys($enabled); + $providerIdsWithoutBackupCodes = array_diff($providerIds, [self::BACKUP_CODES_PROVIDER_ID]); + + $this->userIsTwoFactorAuthenticated[$user->getUID()] = !empty($providerIdsWithoutBackupCodes); + return $this->userIsTwoFactorAuthenticated[$user->getUID()]; + } + + /** + * Get a 2FA provider by its ID + */ + public function getProvider(IUser $user, string $challengeProviderId): ?IProvider { + $providers = $this->getProviderSet($user)->getProviders(); + return $providers[$challengeProviderId] ?? null; + } + + /** + * @return IActivatableAtLogin[] + * @throws Exception + */ + public function getLoginSetupProviders(IUser $user): array { + $providers = $this->providerLoader->getProviders($user); + return array_filter($providers, function (IProvider $provider) { + return ($provider instanceof IActivatableAtLogin); + }); + } + + /** + * Check if the persistant mapping of enabled/disabled state of each available + * provider is missing an entry and add it to the registry in that case. + * + * @todo remove in Nextcloud 17 as by then all providers should have been updated + * + * @param array<string, bool> $providerStates + * @param IProvider[] $providers + * @param IUser $user + * @return array<string, bool> the updated $providerStates variable + */ + private function fixMissingProviderStates(array $providerStates, + array $providers, IUser $user): array { + foreach ($providers as $provider) { + if (isset($providerStates[$provider->getId()])) { + // All good + continue; + } + + $enabled = $provider->isTwoFactorAuthEnabledForUser($user); + if ($enabled) { + $this->providerRegistry->enableProviderFor($provider, $user); + } else { + $this->providerRegistry->disableProviderFor($provider, $user); + } + $providerStates[$provider->getId()] = $enabled; + } + + return $providerStates; + } + + /** + * @param array $states + * @param IProvider[] $providers + */ + private function isProviderMissing(array $states, array $providers): bool { + $indexed = []; + foreach ($providers as $provider) { + $indexed[$provider->getId()] = $provider; + } + + $missing = []; + foreach ($states as $providerId => $enabled) { + if (!$enabled) { + // Don't care + continue; + } + + if (!isset($indexed[$providerId])) { + $missing[] = $providerId; + $this->logger->alert("two-factor auth provider '$providerId' failed to load", + [ + 'app' => 'core', + ]); + } + } + + if (!empty($missing)) { + // There was at least one provider missing + $this->logger->alert(count($missing) . ' two-factor auth providers failed to load', ['app' => 'core']); + + return true; + } + + // If we reach this, there was not a single provider missing + return false; + } + + /** + * Get the list of 2FA providers for the given user + * + * @param IUser $user + * @throws Exception + */ + public function getProviderSet(IUser $user): ProviderSet { + $providerStates = $this->providerRegistry->getProviderStates($user); + $providers = $this->providerLoader->getProviders($user); + + $fixedStates = $this->fixMissingProviderStates($providerStates, $providers, $user); + $isProviderMissing = $this->isProviderMissing($fixedStates, $providers); + + $enabled = array_filter($providers, function (IProvider $provider) use ($fixedStates) { + return $fixedStates[$provider->getId()]; + }); + return new ProviderSet($enabled, $isProviderMissing); + } + + /** + * Verify the given challenge + * + * @param string $providerId + * @param IUser $user + * @param string $challenge + * @return boolean + */ + public function verifyChallenge(string $providerId, IUser $user, string $challenge): bool { + $provider = $this->getProvider($user, $providerId); + if ($provider === null) { + return false; + } + + $passed = $provider->verifyChallenge($user, $challenge); + if ($passed) { + if ($this->session->get(self::REMEMBER_LOGIN) === true) { + // TODO: resolve cyclic dependency and use DI + \OC::$server->getUserSession()->createRememberMeToken($user); + } + $this->session->remove(self::SESSION_UID_KEY); + $this->session->remove(self::REMEMBER_LOGIN); + $this->session->set(self::SESSION_UID_DONE, $user->getUID()); + + // Clear token from db + $sessionId = $this->session->getId(); + $token = $this->tokenProvider->getToken($sessionId); + $tokenId = $token->getId(); + $this->config->deleteUserValue($user->getUID(), 'login_token_2fa', (string)$tokenId); + + $this->dispatcher->dispatchTyped(new TwoFactorProviderForUserEnabled($user, $provider)); + $this->dispatcher->dispatchTyped(new TwoFactorProviderChallengePassed($user, $provider)); + + $this->publishEvent($user, 'twofactor_success', [ + 'provider' => $provider->getDisplayName(), + ]); + } else { + $this->dispatcher->dispatchTyped(new TwoFactorProviderForUserDisabled($user, $provider)); + $this->dispatcher->dispatchTyped(new TwoFactorProviderChallengeFailed($user, $provider)); + + $this->publishEvent($user, 'twofactor_failed', [ + 'provider' => $provider->getDisplayName(), + ]); + } + return $passed; + } + + /** + * Push a 2fa event the user's activity stream + * + * @param IUser $user + * @param string $event + * @param array $params + */ + private function publishEvent(IUser $user, string $event, array $params) { + $activity = $this->activityManager->generateEvent(); + $activity->setApp('core') + ->setType('security') + ->setAuthor($user->getUID()) + ->setAffectedUser($user->getUID()) + ->setSubject($event, $params); + try { + $this->activityManager->publish($activity); + } catch (BadMethodCallException $e) { + $this->logger->warning('could not publish activity', ['app' => 'core', 'exception' => $e]); + } + } + + /** + * Check if the currently logged in user needs to pass 2FA + * + * @param IUser $user the currently logged in user + * @return boolean + */ + public function needsSecondFactor(?IUser $user = null): bool { + if ($user === null) { + return false; + } + + // If we are authenticated using an app password or AppAPI Auth, skip all this + if ($this->session->exists('app_password') || $this->session->get('app_api') === true) { + return false; + } + + // First check if the session tells us we should do 2FA (99% case) + if (!$this->session->exists(self::SESSION_UID_KEY)) { + // Check if the session tells us it is 2FA authenticated already + if ($this->session->exists(self::SESSION_UID_DONE) + && $this->session->get(self::SESSION_UID_DONE) === $user->getUID()) { + return false; + } + + /* + * If the session is expired check if we are not logged in by a token + * that still needs 2FA auth + */ + try { + $sessionId = $this->session->getId(); + $token = $this->tokenProvider->getToken($sessionId); + $tokenId = $token->getId(); + $tokensNeeding2FA = $this->config->getUserKeys($user->getUID(), 'login_token_2fa'); + + if (!\in_array((string)$tokenId, $tokensNeeding2FA, true)) { + $this->session->set(self::SESSION_UID_DONE, $user->getUID()); + return false; + } + } catch (InvalidTokenException|SessionNotAvailableException $e) { + } + } + + if (!$this->isTwoFactorAuthenticated($user)) { + // There is no second factor any more -> let the user pass + // This prevents infinite redirect loops when a user is about + // to solve the 2FA challenge, and the provider app is + // disabled the same time + $this->session->remove(self::SESSION_UID_KEY); + + $keys = $this->config->getUserKeys($user->getUID(), 'login_token_2fa'); + foreach ($keys as $key) { + $this->config->deleteUserValue($user->getUID(), 'login_token_2fa', $key); + } + return false; + } + + return true; + } + + /** + * Prepare the 2FA login + * + * @param IUser $user + * @param boolean $rememberMe + */ + public function prepareTwoFactorLogin(IUser $user, bool $rememberMe) { + $this->session->set(self::SESSION_UID_KEY, $user->getUID()); + $this->session->set(self::REMEMBER_LOGIN, $rememberMe); + + $id = $this->session->getId(); + $token = $this->tokenProvider->getToken($id); + $this->config->setUserValue($user->getUID(), 'login_token_2fa', (string)$token->getId(), (string)$this->timeFactory->getTime()); + } + + public function clearTwoFactorPending(string $userId) { + $tokensNeeding2FA = $this->config->getUserKeys($userId, 'login_token_2fa'); + + foreach ($tokensNeeding2FA as $tokenId) { + $this->config->deleteUserValue($userId, 'login_token_2fa', $tokenId); + + try { + $this->tokenProvider->invalidateTokenById($userId, (int)$tokenId); + } catch (DoesNotExistException $e) { + } + } + } +} diff --git a/lib/private/Authentication/TwoFactorAuth/MandatoryTwoFactor.php b/lib/private/Authentication/TwoFactorAuth/MandatoryTwoFactor.php new file mode 100644 index 00000000000..37c9d3fc550 --- /dev/null +++ b/lib/private/Authentication/TwoFactorAuth/MandatoryTwoFactor.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\TwoFactorAuth; + +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IUser; + +class MandatoryTwoFactor { + /** @var IConfig */ + private $config; + + /** @var IGroupManager */ + private $groupManager; + + public function __construct(IConfig $config, IGroupManager $groupManager) { + $this->config = $config; + $this->groupManager = $groupManager; + } + + /** + * Get the state of enforced two-factor auth + */ + public function getState(): EnforcementState { + return new EnforcementState( + $this->config->getSystemValue('twofactor_enforced', 'false') === 'true', + $this->config->getSystemValue('twofactor_enforced_groups', []), + $this->config->getSystemValue('twofactor_enforced_excluded_groups', []) + ); + } + + /** + * Set the state of enforced two-factor auth + */ + public function setState(EnforcementState $state) { + $this->config->setSystemValue('twofactor_enforced', $state->isEnforced() ? 'true' : 'false'); + $this->config->setSystemValue('twofactor_enforced_groups', $state->getEnforcedGroups()); + $this->config->setSystemValue('twofactor_enforced_excluded_groups', $state->getExcludedGroups()); + } + + /** + * Check if two-factor auth is enforced for a specific user + * + * The admin(s) can enforce two-factor auth system-wide, for certain groups only + * and also have the option to exclude users of certain groups. This method will + * check their membership of those groups. + * + * @param IUser $user + * + * @return bool + */ + public function isEnforcedFor(IUser $user): bool { + $state = $this->getState(); + if (!$state->isEnforced()) { + return false; + } + $uid = $user->getUID(); + + /* + * If there is a list of enforced groups, we only enforce 2FA for members of those groups. + * For all the other users it is not enforced (overruling the excluded groups list). + */ + if (!empty($state->getEnforcedGroups())) { + foreach ($state->getEnforcedGroups() as $group) { + if ($this->groupManager->isInGroup($uid, $group)) { + return true; + } + } + // Not a member of any of these groups -> no 2FA enforced + return false; + } + + /** + * If the user is member of an excluded group, 2FA won't be enforced. + */ + foreach ($state->getExcludedGroups() as $group) { + if ($this->groupManager->isInGroup($uid, $group)) { + return false; + } + } + + /** + * No enforced groups configured and user not member of an excluded groups, + * so 2FA is enforced. + */ + return true; + } +} diff --git a/lib/private/Authentication/TwoFactorAuth/ProviderLoader.php b/lib/private/Authentication/TwoFactorAuth/ProviderLoader.php new file mode 100644 index 00000000000..7e674a01dd8 --- /dev/null +++ b/lib/private/Authentication/TwoFactorAuth/ProviderLoader.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\TwoFactorAuth; + +use Exception; +use OC\AppFramework\Bootstrap\Coordinator; +use OCP\App\IAppManager; +use OCP\AppFramework\QueryException; +use OCP\Authentication\TwoFactorAuth\IProvider; +use OCP\IUser; + +class ProviderLoader { + public const BACKUP_CODES_APP_ID = 'twofactor_backupcodes'; + + public function __construct( + private IAppManager $appManager, + private Coordinator $coordinator, + ) { + } + + /** + * Get the list of 2FA providers for the given user + * + * @return IProvider[] + * @throws Exception + */ + public function getProviders(IUser $user): array { + $allApps = $this->appManager->getEnabledAppsForUser($user); + $providers = []; + + foreach ($allApps as $appId) { + $info = $this->appManager->getAppInfo($appId); + if (isset($info['two-factor-providers'])) { + /** @var string[] $providerClasses */ + $providerClasses = $info['two-factor-providers']; + foreach ($providerClasses as $class) { + try { + $this->loadTwoFactorApp($appId); + $provider = \OCP\Server::get($class); + $providers[$provider->getId()] = $provider; + } catch (QueryException $exc) { + // Provider class can not be resolved + throw new Exception("Could not load two-factor auth provider $class"); + } + } + } + } + + $registeredProviders = $this->coordinator->getRegistrationContext()?->getTwoFactorProviders() ?? []; + foreach ($registeredProviders as $provider) { + try { + $this->loadTwoFactorApp($provider->getAppId()); + $providerInstance = \OCP\Server::get($provider->getService()); + $providers[$providerInstance->getId()] = $providerInstance; + } catch (QueryException $exc) { + // Provider class can not be resolved + throw new Exception('Could not load two-factor auth provider ' . $provider->getService()); + } + } + + return $providers; + } + + /** + * Load an app by ID if it has not been loaded yet + */ + protected function loadTwoFactorApp(string $appId): void { + if (!$this->appManager->isAppLoaded($appId)) { + $this->appManager->loadApp($appId); + } + } +} diff --git a/lib/private/Authentication/TwoFactorAuth/ProviderManager.php b/lib/private/Authentication/TwoFactorAuth/ProviderManager.php new file mode 100644 index 00000000000..5ce4c598154 --- /dev/null +++ b/lib/private/Authentication/TwoFactorAuth/ProviderManager.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\TwoFactorAuth; + +use OC\Authentication\Exceptions\InvalidProviderException; +use OCP\Authentication\TwoFactorAuth\IActivatableByAdmin; +use OCP\Authentication\TwoFactorAuth\IDeactivatableByAdmin; +use OCP\Authentication\TwoFactorAuth\IProvider; +use OCP\Authentication\TwoFactorAuth\IRegistry; +use OCP\IUser; + +class ProviderManager { + /** @var ProviderLoader */ + private $providerLoader; + + /** @var IRegistry */ + private $providerRegistry; + + public function __construct(ProviderLoader $providerLoader, IRegistry $providerRegistry) { + $this->providerLoader = $providerLoader; + $this->providerRegistry = $providerRegistry; + } + + private function getProvider(string $providerId, IUser $user): IProvider { + $providers = $this->providerLoader->getProviders($user); + + if (!isset($providers[$providerId])) { + throw new InvalidProviderException($providerId); + } + + return $providers[$providerId]; + } + + /** + * Try to enable the provider with the given id for the given user + * + * @param IUser $user + * + * @return bool whether the provider supports this operation + */ + public function tryEnableProviderFor(string $providerId, IUser $user): bool { + $provider = $this->getProvider($providerId, $user); + + if ($provider instanceof IActivatableByAdmin) { + $provider->enableFor($user); + $this->providerRegistry->enableProviderFor($provider, $user); + return true; + } else { + return false; + } + } + + /** + * Try to disable the provider with the given id for the given user + * + * @param IUser $user + * + * @return bool whether the provider supports this operation + */ + public function tryDisableProviderFor(string $providerId, IUser $user): bool { + $provider = $this->getProvider($providerId, $user); + + if ($provider instanceof IDeactivatableByAdmin) { + $provider->disableFor($user); + $this->providerRegistry->disableProviderFor($provider, $user); + return true; + } else { + return false; + } + } +} diff --git a/lib/private/Authentication/TwoFactorAuth/ProviderSet.php b/lib/private/Authentication/TwoFactorAuth/ProviderSet.php new file mode 100644 index 00000000000..15b82be6dec --- /dev/null +++ b/lib/private/Authentication/TwoFactorAuth/ProviderSet.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\TwoFactorAuth; + +use OCA\TwoFactorBackupCodes\Provider\BackupCodesProvider; +use OCP\Authentication\TwoFactorAuth\IProvider; +use function array_filter; + +/** + * Contains all two-factor provider information for the two-factor login challenge + */ +class ProviderSet { + /** @var IProvider */ + private $providers; + + /** @var bool */ + private $providerMissing; + + /** + * @param IProvider[] $providers + * @param bool $providerMissing + */ + public function __construct(array $providers, bool $providerMissing) { + $this->providers = []; + foreach ($providers as $provider) { + $this->providers[$provider->getId()] = $provider; + } + $this->providerMissing = $providerMissing; + } + + /** + * @param string $providerId + * @return IProvider|null + */ + public function getProvider(string $providerId) { + return $this->providers[$providerId] ?? null; + } + + /** + * @return IProvider[] + */ + public function getProviders(): array { + return $this->providers; + } + + /** + * @return IProvider[] + */ + public function getPrimaryProviders(): array { + return array_filter($this->providers, function (IProvider $provider) { + return !($provider instanceof BackupCodesProvider); + }); + } + + public function isProviderMissing(): bool { + return $this->providerMissing; + } +} diff --git a/lib/private/Authentication/TwoFactorAuth/Registry.php b/lib/private/Authentication/TwoFactorAuth/Registry.php new file mode 100644 index 00000000000..544f60c4f97 --- /dev/null +++ b/lib/private/Authentication/TwoFactorAuth/Registry.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\TwoFactorAuth; + +use OC\Authentication\TwoFactorAuth\Db\ProviderUserAssignmentDao; +use OCP\Authentication\TwoFactorAuth\IProvider; +use OCP\Authentication\TwoFactorAuth\IRegistry; +use OCP\Authentication\TwoFactorAuth\RegistryEvent; +use OCP\Authentication\TwoFactorAuth\TwoFactorProviderDisabled; +use OCP\Authentication\TwoFactorAuth\TwoFactorProviderForUserRegistered; +use OCP\Authentication\TwoFactorAuth\TwoFactorProviderForUserUnregistered; +use OCP\Authentication\TwoFactorAuth\TwoFactorProviderUserDeleted; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUser; + +class Registry implements IRegistry { + /** @var ProviderUserAssignmentDao */ + private $assignmentDao; + + /** @var IEventDispatcher */ + private $dispatcher; + + public function __construct(ProviderUserAssignmentDao $assignmentDao, + IEventDispatcher $dispatcher) { + $this->assignmentDao = $assignmentDao; + $this->dispatcher = $dispatcher; + } + + public function getProviderStates(IUser $user): array { + return $this->assignmentDao->getState($user->getUID()); + } + + public function enableProviderFor(IProvider $provider, IUser $user) { + $this->assignmentDao->persist($provider->getId(), $user->getUID(), 1); + + $event = new RegistryEvent($provider, $user); + $this->dispatcher->dispatch(self::EVENT_PROVIDER_ENABLED, $event); + $this->dispatcher->dispatchTyped(new TwoFactorProviderForUserRegistered($user, $provider)); + } + + public function disableProviderFor(IProvider $provider, IUser $user) { + $this->assignmentDao->persist($provider->getId(), $user->getUID(), 0); + + $event = new RegistryEvent($provider, $user); + $this->dispatcher->dispatch(self::EVENT_PROVIDER_DISABLED, $event); + $this->dispatcher->dispatchTyped(new TwoFactorProviderForUserUnregistered($user, $provider)); + } + + public function deleteUserData(IUser $user): void { + foreach ($this->assignmentDao->deleteByUser($user->getUID()) as $provider) { + $event = new TwoFactorProviderDisabled($provider['provider_id']); + $this->dispatcher->dispatchTyped($event); + $this->dispatcher->dispatchTyped(new TwoFactorProviderUserDeleted($user, $provider['provider_id'])); + } + } + + public function cleanUp(string $providerId) { + $this->assignmentDao->deleteAll($providerId); + } +} diff --git a/lib/private/Authentication/WebAuthn/CredentialRepository.php b/lib/private/Authentication/WebAuthn/CredentialRepository.php new file mode 100644 index 00000000000..203f2ef9020 --- /dev/null +++ b/lib/private/Authentication/WebAuthn/CredentialRepository.php @@ -0,0 +1,81 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\WebAuthn; + +use OC\Authentication\WebAuthn\Db\PublicKeyCredentialEntity; +use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper; +use OCP\AppFramework\Db\IMapperException; +use Webauthn\PublicKeyCredentialSource; +use Webauthn\PublicKeyCredentialSourceRepository; +use Webauthn\PublicKeyCredentialUserEntity; + +class CredentialRepository implements PublicKeyCredentialSourceRepository { + /** @var PublicKeyCredentialMapper */ + private $credentialMapper; + + public function __construct(PublicKeyCredentialMapper $credentialMapper) { + $this->credentialMapper = $credentialMapper; + } + + public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource { + try { + $entity = $this->credentialMapper->findOneByCredentialId($publicKeyCredentialId); + return $entity->toPublicKeyCredentialSource(); + } catch (IMapperException $e) { + return null; + } + } + + /** + * @return PublicKeyCredentialSource[] + */ + public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array { + $uid = $publicKeyCredentialUserEntity->getId(); + $entities = $this->credentialMapper->findAllForUid($uid); + + return array_map(function (PublicKeyCredentialEntity $entity) { + return $entity->toPublicKeyCredentialSource(); + }, $entities); + } + + public function saveAndReturnCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, ?string $name = null, bool $userVerification = false): PublicKeyCredentialEntity { + $oldEntity = null; + + try { + $oldEntity = $this->credentialMapper->findOneByCredentialId($publicKeyCredentialSource->getPublicKeyCredentialId()); + } catch (IMapperException $e) { + } + + $defaultName = false; + if ($name === null) { + $defaultName = true; + $name = 'default'; + } + + $entity = PublicKeyCredentialEntity::fromPublicKeyCrendentialSource($name, $publicKeyCredentialSource, $userVerification); + + if ($oldEntity) { + $entity->setId($oldEntity->getId()); + if ($defaultName) { + $entity->setName($oldEntity->getName()); + } + + // Don't downgrade UV just because it was skipped during a login due to another key + if ($oldEntity->getUserVerification()) { + $entity->setUserVerification(true); + } + } + + return $this->credentialMapper->insertOrUpdate($entity); + } + + public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, ?string $name = null): void { + $this->saveAndReturnCredentialSource($publicKeyCredentialSource, $name); + } +} diff --git a/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php new file mode 100644 index 00000000000..6c4bc3ca81b --- /dev/null +++ b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\WebAuthn\Db; + +use JsonSerializable; +use OCP\AppFramework\Db\Entity; +use Webauthn\PublicKeyCredentialSource; + +/** + * @since 19.0.0 + * + * @method string getUid(); + * @method void setUid(string $uid) + * @method string getName(); + * @method void setName(string $name); + * @method string getPublicKeyCredentialId(); + * @method void setPublicKeyCredentialId(string $id); + * @method string getData(); + * @method void setData(string $data); + * + * @since 30.0.0 Add userVerification attribute + * @method bool|null getUserVerification(); + * @method void setUserVerification(bool $userVerification); + */ +class PublicKeyCredentialEntity extends Entity implements JsonSerializable { + /** @var string */ + protected $name; + + /** @var string */ + protected $uid; + + /** @var string */ + protected $publicKeyCredentialId; + + /** @var string */ + protected $data; + + /** @var bool|null */ + protected $userVerification; + + public function __construct() { + $this->addType('name', 'string'); + $this->addType('uid', 'string'); + $this->addType('publicKeyCredentialId', 'string'); + $this->addType('data', 'string'); + $this->addType('userVerification', 'boolean'); + } + + public static function fromPublicKeyCrendentialSource(string $name, PublicKeyCredentialSource $publicKeyCredentialSource, bool $userVerification): PublicKeyCredentialEntity { + $publicKeyCredentialEntity = new self(); + + $publicKeyCredentialEntity->setName($name); + $publicKeyCredentialEntity->setUid($publicKeyCredentialSource->getUserHandle()); + $publicKeyCredentialEntity->setPublicKeyCredentialId(base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId())); + $publicKeyCredentialEntity->setData(json_encode($publicKeyCredentialSource)); + $publicKeyCredentialEntity->setUserVerification($userVerification); + + return $publicKeyCredentialEntity; + } + + public function toPublicKeyCredentialSource(): PublicKeyCredentialSource { + return PublicKeyCredentialSource::createFromArray( + json_decode($this->getData(), true) + ); + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'name' => $this->getName(), + ]; + } +} diff --git a/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php new file mode 100644 index 00000000000..fa7304157c8 --- /dev/null +++ b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\WebAuthn\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<PublicKeyCredentialEntity> + */ +class PublicKeyCredentialMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'webauthn', PublicKeyCredentialEntity::class); + } + + public function findOneByCredentialId(string $publicKeyCredentialId): PublicKeyCredentialEntity { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('public_key_credential_id', $qb->createNamedParameter(base64_encode($publicKeyCredentialId))) + ); + + return $this->findEntity($qb); + } + + /** + * @return PublicKeyCredentialEntity[] + */ + public function findAllForUid(string $uid): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('uid', $qb->createNamedParameter($uid)) + ); + + return $this->findEntities($qb); + } + + /** + * @param string $uid + * @param int $id + * + * @return PublicKeyCredentialEntity + * @throws DoesNotExistException + */ + public function findById(string $uid, int $id): PublicKeyCredentialEntity { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->andX( + $qb->expr()->eq('id', $qb->createNamedParameter($id)), + $qb->expr()->eq('uid', $qb->createNamedParameter($uid)) + )); + + return $this->findEntity($qb); + } + + /** + * @throws \OCP\DB\Exception + */ + public function deleteByUid(string $uid) { + $qb = $this->db->getQueryBuilder(); + + $qb->delete($this->getTableName()) + ->where( + $qb->expr()->eq('uid', $qb->createNamedParameter($uid)) + ); + $qb->executeStatement(); + } +} diff --git a/lib/private/Authentication/WebAuthn/Manager.php b/lib/private/Authentication/WebAuthn/Manager.php new file mode 100644 index 00000000000..96dc0719b54 --- /dev/null +++ b/lib/private/Authentication/WebAuthn/Manager.php @@ -0,0 +1,255 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Authentication\WebAuthn; + +use Cose\Algorithm\Signature\ECDSA\ES256; +use Cose\Algorithm\Signature\RSA\RS256; +use Cose\Algorithms; +use GuzzleHttp\Psr7\ServerRequest; +use OC\Authentication\WebAuthn\Db\PublicKeyCredentialEntity; +use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IConfig; +use OCP\IUser; +use Psr\Log\LoggerInterface; +use Webauthn\AttestationStatement\AttestationObjectLoader; +use Webauthn\AttestationStatement\AttestationStatementSupportManager; +use Webauthn\AttestationStatement\NoneAttestationStatementSupport; +use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler; +use Webauthn\AuthenticatorAssertionResponse; +use Webauthn\AuthenticatorAssertionResponseValidator; +use Webauthn\AuthenticatorAttestationResponse; +use Webauthn\AuthenticatorAttestationResponseValidator; +use Webauthn\AuthenticatorSelectionCriteria; +use Webauthn\PublicKeyCredentialCreationOptions; +use Webauthn\PublicKeyCredentialDescriptor; +use Webauthn\PublicKeyCredentialLoader; +use Webauthn\PublicKeyCredentialParameters; +use Webauthn\PublicKeyCredentialRequestOptions; +use Webauthn\PublicKeyCredentialRpEntity; +use Webauthn\PublicKeyCredentialUserEntity; +use Webauthn\TokenBinding\TokenBindingNotSupportedHandler; + +class Manager { + /** @var CredentialRepository */ + private $repository; + + /** @var PublicKeyCredentialMapper */ + private $credentialMapper; + + /** @var LoggerInterface */ + private $logger; + + /** @var IConfig */ + private $config; + + public function __construct( + CredentialRepository $repository, + PublicKeyCredentialMapper $credentialMapper, + LoggerInterface $logger, + IConfig $config, + ) { + $this->repository = $repository; + $this->credentialMapper = $credentialMapper; + $this->logger = $logger; + $this->config = $config; + } + + public function startRegistration(IUser $user, string $serverHost): PublicKeyCredentialCreationOptions { + $rpEntity = new PublicKeyCredentialRpEntity( + 'Nextcloud', //Name + $this->stripPort($serverHost), //ID + null //Icon + ); + + $userEntity = new PublicKeyCredentialUserEntity( + $user->getUID(), // Name + $user->getUID(), // ID + $user->getDisplayName() // Display name + // 'https://foo.example.co/avatar/123e4567-e89b-12d3-a456-426655440000' //Icon + ); + + $challenge = random_bytes(32); + + $publicKeyCredentialParametersList = [ + new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256), + new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_RS256), + ]; + + $timeout = 60000; + + $excludedPublicKeyDescriptors = [ + ]; + + $authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria( + AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE, + AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED, + null, + false, + ); + + return new PublicKeyCredentialCreationOptions( + $rpEntity, + $userEntity, + $challenge, + $publicKeyCredentialParametersList, + $authenticatorSelectionCriteria, + PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, + $excludedPublicKeyDescriptors, + $timeout, + ); + } + + public function finishRegister(PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $name, string $data): PublicKeyCredentialEntity { + $tokenBindingHandler = new TokenBindingNotSupportedHandler(); + + $attestationStatementSupportManager = new AttestationStatementSupportManager(); + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport()); + + $attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager); + $publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader); + + // Extension Output Checker Handler + $extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler(); + + // Authenticator Attestation Response Validator + $authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator( + $attestationStatementSupportManager, + $this->repository, + $tokenBindingHandler, + $extensionOutputCheckerHandler + ); + $authenticatorAttestationResponseValidator->setLogger($this->logger); + + try { + // Load the data + $publicKeyCredential = $publicKeyCredentialLoader->load($data); + $response = $publicKeyCredential->response; + + // Check if the response is an Authenticator Attestation Response + if (!$response instanceof AuthenticatorAttestationResponse) { + throw new \RuntimeException('Not an authenticator attestation response'); + } + + // Check the response against the request + $request = ServerRequest::fromGlobals(); + + $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check( + $response, + $publicKeyCredentialCreationOptions, + $request, + ['localhost'], + ); + } catch (\Throwable $exception) { + throw $exception; + } + + // Persist the data + $userVerification = $response->attestationObject->authData->isUserVerified(); + return $this->repository->saveAndReturnCredentialSource($publicKeyCredentialSource, $name, $userVerification); + } + + private function stripPort(string $serverHost): string { + return preg_replace('/(:\d+$)/', '', $serverHost); + } + + public function startAuthentication(string $uid, string $serverHost): PublicKeyCredentialRequestOptions { + // List of registered PublicKeyCredentialDescriptor classes associated to the user + $userVerificationRequirement = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED; + $registeredPublicKeyCredentialDescriptors = array_map(function (PublicKeyCredentialEntity $entity) use (&$userVerificationRequirement) { + if ($entity->getUserVerification() !== true) { + $userVerificationRequirement = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED; + } + $credential = $entity->toPublicKeyCredentialSource(); + return new PublicKeyCredentialDescriptor( + $credential->type, + $credential->publicKeyCredentialId, + ); + }, $this->credentialMapper->findAllForUid($uid)); + + // Public Key Credential Request Options + return new PublicKeyCredentialRequestOptions( + random_bytes(32), // Challenge + $this->stripPort($serverHost), // Relying Party ID + $registeredPublicKeyCredentialDescriptors, // Registered PublicKeyCredentialDescriptor classes + $userVerificationRequirement, + 60000, // Timeout + ); + } + + public function finishAuthentication(PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, string $data, string $uid) { + $attestationStatementSupportManager = new AttestationStatementSupportManager(); + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport()); + + $attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager); + $publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader); + + $tokenBindingHandler = new TokenBindingNotSupportedHandler(); + $extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler(); + $algorithmManager = new \Cose\Algorithm\Manager(); + $algorithmManager->add(new ES256()); + $algorithmManager->add(new RS256()); + + $authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator( + $this->repository, + $tokenBindingHandler, + $extensionOutputCheckerHandler, + $algorithmManager, + ); + $authenticatorAssertionResponseValidator->setLogger($this->logger); + + try { + $this->logger->debug('Loading publickey credentials from: ' . $data); + + // Load the data + $publicKeyCredential = $publicKeyCredentialLoader->load($data); + $response = $publicKeyCredential->response; + + // Check if the response is an Authenticator Attestation Response + if (!$response instanceof AuthenticatorAssertionResponse) { + throw new \RuntimeException('Not an authenticator attestation response'); + } + + // Check the response against the request + $request = ServerRequest::fromGlobals(); + + $publicKeyCredentialSource = $authenticatorAssertionResponseValidator->check( + $publicKeyCredential->rawId, + $response, + $publicKeyCredentialRequestOptions, + $request, + $uid, + ['localhost'], + ); + } catch (\Throwable $e) { + throw $e; + } + + return true; + } + + public function deleteRegistration(IUser $user, int $id): void { + try { + $entry = $this->credentialMapper->findById($user->getUID(), $id); + } catch (DoesNotExistException $e) { + $this->logger->warning("WebAuthn device $id does not exist, can't delete it"); + return; + } + + $this->credentialMapper->delete($entry); + } + + public function isWebAuthnAvailable(): bool { + if (!$this->config->getSystemValueBool('auth.webauthn.enabled', true)) { + return false; + } + + return true; + } +} |