You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
nextcloud/lib/private/Authentication/TwoFactorAuth/Manager.php

392 lines
12 KiB
PHP

<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Joas Schilling <coding@schilljs.com>
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OC\Authentication\TwoFactorAuth;
use BadMethodCallException;
use Exception;
use OC\Authentication\Token\IProvider as TokenProvider;
use OCP\Activity\IManager;
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->tokenProvider->invalidateTokenById($userId, (int)$tokenId);
}
}
}