aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Authentication
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/Authentication')
-rw-r--r--lib/private/Authentication/Events/ARemoteWipeEvent.php26
-rw-r--r--lib/private/Authentication/Events/AppPasswordCreatedEvent.php24
-rw-r--r--lib/private/Authentication/Events/LoginFailed.php31
-rw-r--r--lib/private/Authentication/Events/RemoteWipeFinished.php12
-rw-r--r--lib/private/Authentication/Events/RemoteWipeStarted.php12
-rw-r--r--lib/private/Authentication/Exceptions/ExpiredTokenException.php28
-rw-r--r--lib/private/Authentication/Exceptions/InvalidProviderException.php18
-rw-r--r--lib/private/Authentication/Exceptions/InvalidTokenException.php15
-rw-r--r--lib/private/Authentication/Exceptions/LoginRequiredException.php13
-rw-r--r--lib/private/Authentication/Exceptions/PasswordLoginForbiddenException.php13
-rw-r--r--lib/private/Authentication/Exceptions/PasswordlessTokenException.php13
-rw-r--r--lib/private/Authentication/Exceptions/TokenPasswordExpiredException.php12
-rw-r--r--lib/private/Authentication/Exceptions/TwoFactorAuthRequiredException.php13
-rw-r--r--lib/private/Authentication/Exceptions/UserAlreadyLoggedInException.php13
-rw-r--r--lib/private/Authentication/Exceptions/WipeTokenException.php28
-rw-r--r--lib/private/Authentication/Listeners/LoginFailedListener.php52
-rw-r--r--lib/private/Authentication/Listeners/RemoteWipeActivityListener.php62
-rw-r--r--lib/private/Authentication/Listeners/RemoteWipeEmailListener.php155
-rw-r--r--lib/private/Authentication/Listeners/RemoteWipeNotificationsListener.php54
-rw-r--r--lib/private/Authentication/Listeners/UserDeletedFilesCleanupListener.php73
-rw-r--r--lib/private/Authentication/Listeners/UserDeletedStoreCleanupListener.php34
-rw-r--r--lib/private/Authentication/Listeners/UserDeletedTokenCleanupListener.php56
-rw-r--r--lib/private/Authentication/Listeners/UserDeletedWebAuthnCleanupListener.php33
-rw-r--r--lib/private/Authentication/Listeners/UserLoggedInListener.php44
-rw-r--r--lib/private/Authentication/Login/ALoginCommand.php29
-rw-r--r--lib/private/Authentication/Login/Chain.php45
-rw-r--r--lib/private/Authentication/Login/ClearLostPasswordTokensCommand.php33
-rw-r--r--lib/private/Authentication/Login/CompleteLoginCommand.php32
-rw-r--r--lib/private/Authentication/Login/CreateSessionTokenCommand.php63
-rw-r--r--lib/private/Authentication/Login/FinishRememberedLoginCommand.php32
-rw-r--r--lib/private/Authentication/Login/FlowV2EphemeralSessionsCommand.php30
-rw-r--r--lib/private/Authentication/Login/LoggedInCheckCommand.php43
-rw-r--r--lib/private/Authentication/Login/LoginData.php102
-rw-r--r--lib/private/Authentication/Login/LoginResult.php69
-rw-r--r--lib/private/Authentication/Login/PreLoginHookCommand.php36
-rw-r--r--lib/private/Authentication/Login/SetUserTimezoneCommand.php47
-rw-r--r--lib/private/Authentication/Login/TwoFactorCommand.php75
-rw-r--r--lib/private/Authentication/Login/UidLoginCommand.php38
-rw-r--r--lib/private/Authentication/Login/UpdateLastPasswordConfirmCommand.php29
-rw-r--r--lib/private/Authentication/Login/UserDisabledCheckCommand.php41
-rw-r--r--lib/private/Authentication/Login/WebAuthnChain.php80
-rw-r--r--lib/private/Authentication/Login/WebAuthnLoginCommand.php30
-rw-r--r--lib/private/Authentication/LoginCredentials/Credentials.php52
-rw-r--r--lib/private/Authentication/LoginCredentials/Store.php119
-rw-r--r--lib/private/Authentication/Notifications/Notifier.php78
-rw-r--r--lib/private/Authentication/Token/INamedToken.php19
-rw-r--r--lib/private/Authentication/Token/IProvider.php172
-rw-r--r--lib/private/Authentication/Token/IToken.php17
-rw-r--r--lib/private/Authentication/Token/IWipeableToken.php16
-rw-r--r--lib/private/Authentication/Token/Manager.php243
-rw-r--r--lib/private/Authentication/Token/PublicKeyToken.php219
-rw-r--r--lib/private/Authentication/Token/PublicKeyTokenMapper.php252
-rw-r--r--lib/private/Authentication/Token/PublicKeyTokenProvider.php566
-rw-r--r--lib/private/Authentication/Token/RemoteWipe.php134
-rw-r--r--lib/private/Authentication/Token/TokenCleanupJob.php26
-rw-r--r--lib/private/Authentication/TwoFactorAuth/Db/ProviderUserAssignmentDao.php111
-rw-r--r--lib/private/Authentication/TwoFactorAuth/EnforcementState.php66
-rw-r--r--lib/private/Authentication/TwoFactorAuth/Manager.php378
-rw-r--r--lib/private/Authentication/TwoFactorAuth/MandatoryTwoFactor.php94
-rw-r--r--lib/private/Authentication/TwoFactorAuth/ProviderLoader.php78
-rw-r--r--lib/private/Authentication/TwoFactorAuth/ProviderManager.php77
-rw-r--r--lib/private/Authentication/TwoFactorAuth/ProviderSet.php64
-rw-r--r--lib/private/Authentication/TwoFactorAuth/Registry.php66
-rw-r--r--lib/private/Authentication/WebAuthn/CredentialRepository.php81
-rw-r--r--lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php82
-rw-r--r--lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php82
-rw-r--r--lib/private/Authentication/WebAuthn/Manager.php255
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;
+ }
+}