Passed
Push — master ( 050b91...deb7d2 )
by Roeland
10:46 queued 11s
created

Manager   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 325
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 134
dl 0
loc 325
rs 9.68
c 0
b 0
f 0
wmc 34

11 Methods

Rating   Name   Duplication   Size   Complexity  
A clearTwoFactorPending() 0 5 2
A getProvider() 0 3 1
A prepareTwoFactorLogin() 0 7 1
A fixMissingProviderStates() 0 19 4
A getProviderSet() 0 11 1
A isProviderMissing() 0 31 6
A isTwoFactorAuthenticated() 0 13 2
A verifyChallenge() 0 37 4
A publishEvent() 0 12 2
A __construct() 0 16 1
B needsSecondFactor() 0 52 10
1
<?php
2
3
declare(strict_types = 1);
4
/**
5
 * @copyright Copyright (c) 2016, ownCloud, Inc.
6
 *
7
 * @author Christoph Wurst <[email protected]>
8
 * @author Lukas Reschke <[email protected]>
9
 * @author Robin Appelman <[email protected]>
10
 * @author Roeland Jago Douma <[email protected]>
11
 *
12
 * @license AGPL-3.0
13
 *
14
 * This code is free software: you can redistribute it and/or modify
15
 * it under the terms of the GNU Affero General Public License, version 3,
16
 * as published by the Free Software Foundation.
17
 *
18
 * This program is distributed in the hope that it will be useful,
19
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21
 * GNU Affero General Public License for more details.
22
 *
23
 * You should have received a copy of the GNU Affero General Public License, version 3,
24
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
25
 *
26
 */
27
28
namespace OC\Authentication\TwoFactorAuth;
29
30
use function array_diff;
31
use function array_filter;
32
use BadMethodCallException;
33
use Exception;
34
use OC\Authentication\Exceptions\ExpiredTokenException;
35
use OC\Authentication\Exceptions\InvalidTokenException;
36
use OC\Authentication\Token\IProvider as TokenProvider;
37
use OCP\Activity\IManager;
38
use OCP\AppFramework\Utility\ITimeFactory;
39
use OCP\Authentication\TwoFactorAuth\IProvider;
40
use OCP\Authentication\TwoFactorAuth\IRegistry;
41
use OCP\IConfig;
42
use OCP\ILogger;
43
use OCP\ISession;
44
use OCP\IUser;
45
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
46
use Symfony\Component\EventDispatcher\GenericEvent;
47
48
class Manager {
49
50
	const SESSION_UID_KEY = 'two_factor_auth_uid';
51
	const SESSION_UID_DONE = 'two_factor_auth_passed';
52
	const REMEMBER_LOGIN = 'two_factor_remember_login';
53
	const BACKUP_CODES_PROVIDER_ID = 'backup_codes';
54
55
	/** @var ProviderLoader */
56
	private $providerLoader;
57
58
	/** @var IRegistry */
59
	private $providerRegistry;
60
61
	/** @var MandatoryTwoFactor */
62
	private $mandatoryTwoFactor;
63
64
	/** @var ISession */
65
	private $session;
66
67
	/** @var IConfig */
68
	private $config;
69
70
	/** @var IManager */
71
	private $activityManager;
72
73
	/** @var ILogger */
74
	private $logger;
75
76
	/** @var TokenProvider */
77
	private $tokenProvider;
78
79
	/** @var ITimeFactory */
80
	private $timeFactory;
81
82
	/** @var EventDispatcherInterface */
83
	private $dispatcher;
84
85
	public function __construct(ProviderLoader $providerLoader,
86
								IRegistry $providerRegistry,
87
								MandatoryTwoFactor $mandatoryTwoFactor,
88
								ISession $session, IConfig $config,
89
								IManager $activityManager, ILogger $logger, TokenProvider $tokenProvider,
90
								ITimeFactory $timeFactory, EventDispatcherInterface $eventDispatcher) {
91
		$this->providerLoader = $providerLoader;
92
		$this->providerRegistry = $providerRegistry;
93
		$this->mandatoryTwoFactor = $mandatoryTwoFactor;
94
		$this->session = $session;
95
		$this->config = $config;
96
		$this->activityManager = $activityManager;
97
		$this->logger = $logger;
98
		$this->tokenProvider = $tokenProvider;
99
		$this->timeFactory = $timeFactory;
100
		$this->dispatcher = $eventDispatcher;
101
	}
102
103
	/**
104
	 * Determine whether the user must provide a second factor challenge
105
	 *
106
	 * @param IUser $user
107
	 * @return boolean
108
	 */
109
	public function isTwoFactorAuthenticated(IUser $user): bool {
110
		if ($this->mandatoryTwoFactor->isEnforcedFor($user)) {
111
			return true;
112
		}
113
114
		$providerStates = $this->providerRegistry->getProviderStates($user);
115
		$providers = $this->providerLoader->getProviders($user);
116
		$fixedStates = $this->fixMissingProviderStates($providerStates, $providers, $user);
117
		$enabled = array_filter($fixedStates);
118
		$providerIds = array_keys($enabled);
119
		$providerIdsWithoutBackupCodes = array_diff($providerIds, [self::BACKUP_CODES_PROVIDER_ID]);
120
121
		return !empty($providerIdsWithoutBackupCodes);
122
	}
123
124
	/**
125
	 * Get a 2FA provider by its ID
126
	 *
127
	 * @param IUser $user
128
	 * @param string $challengeProviderId
129
	 * @return IProvider|null
130
	 */
131
	public function getProvider(IUser $user, string $challengeProviderId) {
132
		$providers = $this->getProviderSet($user)->getProviders();
133
		return $providers[$challengeProviderId] ?? null;
134
	}
135
136
	/**
137
	 * Check if the persistant mapping of enabled/disabled state of each available
138
	 * provider is missing an entry and add it to the registry in that case.
139
	 *
140
	 * @todo remove in Nextcloud 17 as by then all providers should have been updated
141
	 *
142
	 * @param string[] $providerStates
143
	 * @param IProvider[] $providers
144
	 * @param IUser $user
145
	 * @return string[] the updated $providerStates variable
146
	 */
147
	private function fixMissingProviderStates(array $providerStates,
148
		array $providers, IUser $user): array {
149
150
		foreach ($providers as $provider) {
151
			if (isset($providerStates[$provider->getId()])) {
152
				// All good
153
				continue;
154
			}
155
156
			$enabled = $provider->isTwoFactorAuthEnabledForUser($user);
157
			if ($enabled) {
158
				$this->providerRegistry->enableProviderFor($provider, $user);
159
			} else {
160
				$this->providerRegistry->disableProviderFor($provider, $user);
161
			}
162
			$providerStates[$provider->getId()] = $enabled;
163
		}
164
165
		return $providerStates;
166
	}
167
168
	/**
169
	 * @param array $states
170
	 * @param IProvider $providers
171
	 */
172
	private function isProviderMissing(array $states, array $providers): bool {
173
		$indexed = [];
174
		foreach ($providers as $provider) {
175
			$indexed[$provider->getId()] = $provider;
176
		}
177
178
		$missing = [];
179
		foreach ($states as $providerId => $enabled) {
180
			if (!$enabled) {
181
				// Don't care
182
				continue;
183
			}
184
185
			if (!isset($indexed[$providerId])) {
186
				$missing[] = $providerId;
187
				$this->logger->alert("two-factor auth provider '$providerId' failed to load",
188
					[
189
					'app' => 'core',
190
				]);
191
			}
192
		}
193
194
		if (!empty($missing)) {
195
			// There was at least one provider missing
196
			$this->logger->alert(count($missing) . " two-factor auth providers failed to load", ['app' => 'core']);
197
198
			return true;
199
		}
200
201
		// If we reach this, there was not a single provider missing
202
		return false;
203
	}
204
205
	/**
206
	 * Get the list of 2FA providers for the given user
207
	 *
208
	 * @param IUser $user
209
	 * @throws Exception
210
	 */
211
	public function getProviderSet(IUser $user): ProviderSet {
212
		$providerStates = $this->providerRegistry->getProviderStates($user);
213
		$providers = $this->providerLoader->getProviders($user);
214
215
		$fixedStates = $this->fixMissingProviderStates($providerStates, $providers, $user);
216
		$isProviderMissing = $this->isProviderMissing($fixedStates, $providers);
217
218
		$enabled = array_filter($providers, function (IProvider $provider) use ($fixedStates) {
219
			return $fixedStates[$provider->getId()];
220
		});
221
		return new ProviderSet($enabled, $isProviderMissing);
222
	}
223
224
	/**
225
	 * Verify the given challenge
226
	 *
227
	 * @param string $providerId
228
	 * @param IUser $user
229
	 * @param string $challenge
230
	 * @return boolean
231
	 */
232
	public function verifyChallenge(string $providerId, IUser $user, string $challenge): bool {
233
		$provider = $this->getProvider($user, $providerId);
234
		if ($provider === null) {
235
			return false;
236
		}
237
238
		$passed = $provider->verifyChallenge($user, $challenge);
239
		if ($passed) {
240
			if ($this->session->get(self::REMEMBER_LOGIN) === true) {
241
				// TODO: resolve cyclic dependency and use DI
242
				\OC::$server->getUserSession()->createRememberMeToken($user);
243
			}
244
			$this->session->remove(self::SESSION_UID_KEY);
245
			$this->session->remove(self::REMEMBER_LOGIN);
246
			$this->session->set(self::SESSION_UID_DONE, $user->getUID());
247
248
			// Clear token from db
249
			$sessionId = $this->session->getId();
250
			$token = $this->tokenProvider->getToken($sessionId);
251
			$tokenId = $token->getId();
252
			$this->config->deleteUserValue($user->getUID(), 'login_token_2fa', $tokenId);
253
254
			$dispatchEvent = new GenericEvent($user, ['provider' => $provider->getDisplayName()]);
255
			$this->dispatcher->dispatch(IProvider::EVENT_SUCCESS, $dispatchEvent);
256
257
			$this->publishEvent($user, 'twofactor_success', [
258
				'provider' => $provider->getDisplayName(),
259
			]);
260
		} else {
261
			$dispatchEvent = new GenericEvent($user, ['provider' => $provider->getDisplayName()]);
262
			$this->dispatcher->dispatch(IProvider::EVENT_FAILED, $dispatchEvent);
263
264
			$this->publishEvent($user, 'twofactor_failed', [
265
				'provider' => $provider->getDisplayName(),
266
			]);
267
		}
268
		return $passed;
269
	}
270
271
	/**
272
	 * Push a 2fa event the user's activity stream
273
	 *
274
	 * @param IUser $user
275
	 * @param string $event
276
	 * @param array $params
277
	 */
278
	private function publishEvent(IUser $user, string $event, array $params) {
279
		$activity = $this->activityManager->generateEvent();
280
		$activity->setApp('core')
281
			->setType('security')
282
			->setAuthor($user->getUID())
283
			->setAffectedUser($user->getUID())
284
			->setSubject($event, $params);
285
		try {
286
			$this->activityManager->publish($activity);
287
		} catch (BadMethodCallException $e) {
288
			$this->logger->warning('could not publish activity', ['app' => 'core']);
289
			$this->logger->logException($e, ['app' => 'core']);
290
		}
291
	}
292
293
	/**
294
	 * Check if the currently logged in user needs to pass 2FA
295
	 *
296
	 * @param IUser $user the currently logged in user
297
	 * @return boolean
298
	 */
299
	public function needsSecondFactor(IUser $user = null): bool {
300
		if ($user === null) {
301
			return false;
302
		}
303
304
		// If we are authenticated using an app password skip all this
305
		if ($this->session->exists('app_password')) {
306
			return false;
307
		}
308
309
		// First check if the session tells us we should do 2FA (99% case)
310
		if (!$this->session->exists(self::SESSION_UID_KEY)) {
311
312
			// Check if the session tells us it is 2FA authenticated already
313
			if ($this->session->exists(self::SESSION_UID_DONE) &&
314
				$this->session->get(self::SESSION_UID_DONE) === $user->getUID()) {
315
				return false;
316
			}
317
318
			/*
319
			 * If the session is expired check if we are not logged in by a token
320
			 * that still needs 2FA auth
321
			 */
322
			try {
323
				$sessionId = $this->session->getId();
324
				$token = $this->tokenProvider->getToken($sessionId);
325
				$tokenId = $token->getId();
326
				$tokensNeeding2FA = $this->config->getUserKeys($user->getUID(), 'login_token_2fa');
327
328
				if (!\in_array($tokenId, $tokensNeeding2FA, true)) {
329
					$this->session->set(self::SESSION_UID_DONE, $user->getUID());
330
					return false;
331
				}
332
			} catch (InvalidTokenException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
333
			}
334
		}
335
336
		if (!$this->isTwoFactorAuthenticated($user)) {
337
			// There is no second factor any more -> let the user pass
338
			//   This prevents infinite redirect loops when a user is about
339
			//   to solve the 2FA challenge, and the provider app is
340
			//   disabled the same time
341
			$this->session->remove(self::SESSION_UID_KEY);
342
343
			$keys = $this->config->getUserKeys($user->getUID(), 'login_token_2fa');
344
			foreach ($keys as $key) {
345
				$this->config->deleteUserValue($user->getUID(), 'login_token_2fa', $key);
346
			}
347
			return false;
348
		}
349
350
		return true;
351
	}
352
353
	/**
354
	 * Prepare the 2FA login
355
	 *
356
	 * @param IUser $user
357
	 * @param boolean $rememberMe
358
	 */
359
	public function prepareTwoFactorLogin(IUser $user, bool $rememberMe) {
360
		$this->session->set(self::SESSION_UID_KEY, $user->getUID());
361
		$this->session->set(self::REMEMBER_LOGIN, $rememberMe);
362
363
		$id = $this->session->getId();
364
		$token = $this->tokenProvider->getToken($id);
365
		$this->config->setUserValue($user->getUID(), 'login_token_2fa', $token->getId(), $this->timeFactory->getTime());
366
	}
367
368
	public function clearTwoFactorPending(string $userId) {
369
		$tokensNeeding2FA = $this->config->getUserKeys($userId, 'login_token_2fa');
370
371
		foreach ($tokensNeeding2FA as $tokenId) {
372
			$this->tokenProvider->invalidateTokenById($userId, $tokenId);
0 ignored issues
show
Bug introduced by
$tokenId of type string is incompatible with the type integer expected by parameter $id of OC\Authentication\Token\...::invalidateTokenById(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

372
			$this->tokenProvider->invalidateTokenById($userId, /** @scrutinizer ignore-type */ $tokenId);
Loading history...
373
		}
374
	}
375
376
}
377