Passed
Push — master ( 62ff4f...9124c6 )
by John
74:39 queued 12s
created

AccountManager::testPropertyScope()   A

Complexity

Conditions 6
Paths 4

Size

Total Lines 20
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 11
nc 4
nop 3
dl 0
loc 20
rs 9.2222
c 0
b 0
f 0
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 * @copyright Copyright (c) 2016, Björn Schießle
5
 *
6
 * @author Bjoern Schiessle <[email protected]>
7
 * @author Björn Schießle <[email protected]>
8
 * @author Christoph Wurst <[email protected]>
9
 * @author Daniel Calviño Sánchez <[email protected]>
10
 * @author Daniel Kesselberg <[email protected]>
11
 * @author Joas Schilling <[email protected]>
12
 * @author Julius Härtl <[email protected]>
13
 * @author Lukas Reschke <[email protected]>
14
 * @author Morris Jobke <[email protected]>
15
 * @author Roeland Jago Douma <[email protected]>
16
 * @author Vincent Petry <[email protected]>
17
 *
18
 * @license AGPL-3.0
19
 *
20
 * This code is free software: you can redistribute it and/or modify
21
 * it under the terms of the GNU Affero General Public License, version 3,
22
 * as published by the Free Software Foundation.
23
 *
24
 * This program is distributed in the hope that it will be useful,
25
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
26
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
27
 * GNU Affero General Public License for more details.
28
 *
29
 * You should have received a copy of the GNU Affero General Public License, version 3,
30
 * along with this program. If not, see <http://www.gnu.org/licenses/>
31
 *
32
 */
33
namespace OC\Accounts;
34
35
use InvalidArgumentException;
36
use libphonenumber\NumberParseException;
37
use libphonenumber\PhoneNumber;
38
use libphonenumber\PhoneNumberFormat;
39
use libphonenumber\PhoneNumberUtil;
40
use OCA\Settings\BackgroundJobs\VerifyUserData;
41
use OCP\Accounts\IAccount;
42
use OCP\Accounts\IAccountManager;
43
use OCP\Accounts\IAccountProperty;
44
use OCP\Accounts\IAccountPropertyCollection;
45
use OCP\Accounts\PropertyDoesNotExistException;
46
use OCP\BackgroundJob\IJobList;
47
use OCP\DB\QueryBuilder\IQueryBuilder;
48
use OCP\IConfig;
49
use OCP\IDBConnection;
50
use OCP\IUser;
51
use Psr\Log\LoggerInterface;
52
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
53
use Symfony\Component\EventDispatcher\GenericEvent;
54
use function array_flip;
55
use function iterator_to_array;
56
use function json_decode;
57
use function json_encode;
58
use function json_last_error;
59
60
/**
61
 * Class AccountManager
62
 *
63
 * Manage system accounts table
64
 *
65
 * @group DB
66
 * @package OC\Accounts
67
 */
68
class AccountManager implements IAccountManager {
69
	use TAccountsHelper;
70
71
	/** @var  IDBConnection database connection */
72
	private $connection;
73
74
	/** @var IConfig */
75
	private $config;
76
77
	/** @var string table name */
78
	private $table = 'accounts';
79
80
	/** @var string table name */
81
	private $dataTable = 'accounts_data';
82
83
	/** @var EventDispatcherInterface */
84
	private $eventDispatcher;
85
86
	/** @var IJobList */
87
	private $jobList;
88
89
	/** @var LoggerInterface */
90
	private $logger;
91
92
	public function __construct(IDBConnection $connection,
93
								IConfig $config,
94
								EventDispatcherInterface $eventDispatcher,
95
								IJobList $jobList,
96
								LoggerInterface $logger) {
97
		$this->connection = $connection;
98
		$this->config = $config;
99
		$this->eventDispatcher = $eventDispatcher;
100
		$this->jobList = $jobList;
101
		$this->logger = $logger;
102
	}
103
104
	/**
105
	 * @param string $input
106
	 * @return string Provided phone number in E.164 format when it was a valid number
107
	 * @throws InvalidArgumentException When the phone number was invalid or no default region is set and the number doesn't start with a country code
108
	 */
109
	protected function parsePhoneNumber(string $input): string {
110
		$defaultRegion = $this->config->getSystemValueString('default_phone_region', '');
111
112
		if ($defaultRegion === '') {
113
			// When no default region is set, only +49… numbers are valid
114
			if (strpos($input, '+') !== 0) {
115
				throw new InvalidArgumentException(self::PROPERTY_PHONE);
116
			}
117
118
			$defaultRegion = 'EN';
119
		}
120
121
		$phoneUtil = PhoneNumberUtil::getInstance();
122
		try {
123
			$phoneNumber = $phoneUtil->parse($input, $defaultRegion);
124
			if ($phoneNumber instanceof PhoneNumber && $phoneUtil->isValidNumber($phoneNumber)) {
125
				return $phoneUtil->format($phoneNumber, PhoneNumberFormat::E164);
126
			}
127
		} catch (NumberParseException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
128
		}
129
130
		throw new InvalidArgumentException(self::PROPERTY_PHONE);
131
	}
132
133
	/**
134
	 *
135
	 * @param string $input
136
	 * @return string
137
	 * @throws InvalidArgumentException When the website did not have http(s) as protocol or the host name was empty
138
	 */
139
	protected function parseWebsite(string $input): string {
140
		$parts = parse_url($input);
141
		if (!isset($parts['scheme']) || ($parts['scheme'] !== 'https' && $parts['scheme'] !== 'http')) {
142
			throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
143
		}
144
145
		if (!isset($parts['host']) || $parts['host'] === '') {
146
			throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
147
		}
148
149
		return $input;
150
	}
151
152
	/**
153
	 * @param IAccountProperty[] $properties
154
	 */
155
	protected function testValueLengths(array $properties, bool $throwOnData = false): void {
156
		foreach ($properties as $property) {
157
			if (strlen($property->getValue()) > 2048) {
158
				if ($throwOnData) {
159
					throw new InvalidArgumentException();
160
				} else {
161
					$property->setValue('');
162
				}
163
			}
164
		}
165
	}
166
167
	protected function testPropertyScope(IAccountProperty $property, array $allowedScopes, bool $throwOnData): void {
168
		if ($throwOnData && !in_array($property->getScope(), $allowedScopes, true)) {
169
			throw new InvalidArgumentException('scope');
170
		}
171
172
		if (
173
			$property->getScope() === self::SCOPE_PRIVATE
174
			&& in_array($property->getName(), [self::PROPERTY_DISPLAYNAME, self::PROPERTY_EMAIL])
175
		) {
176
			if ($throwOnData) {
177
				// v2-private is not available for these fields
178
				throw new InvalidArgumentException('scope');
179
			} else {
180
				// default to local
181
				$property->setScope(self::SCOPE_LOCAL);
182
			}
183
		} else {
184
			// migrate scope values to the new format
185
			// invalid scopes are mapped to a default value
186
			$property->setScope(AccountProperty::mapScopeToV2($property->getScope()));
187
		}
188
	}
189
190
	protected function sanitizePhoneNumberValue(IAccountProperty $property, bool $throwOnData = false) {
191
		if ($property->getName() !== self::PROPERTY_PHONE) {
192
			if ($throwOnData) {
193
				throw new InvalidArgumentException(sprintf('sanitizePhoneNumberValue can only sanitize phone numbers, %s given', $property->getName()));
194
			}
195
			return;
196
		}
197
		if ($property->getValue() === '') {
198
			return;
199
		}
200
		try {
201
			$property->setValue($this->parsePhoneNumber($property->getValue()));
202
		} catch (InvalidArgumentException $e) {
203
			if ($throwOnData) {
204
				throw $e;
205
			}
206
			$property->setValue('');
207
		}
208
	}
209
210
	protected function sanitizeWebsite(IAccountProperty $property, bool $throwOnData = false) {
211
		if ($property->getName() !== self::PROPERTY_WEBSITE) {
212
			if ($throwOnData) {
213
				throw new InvalidArgumentException(sprintf('sanitizeWebsite can only sanitize web domains, %s given', $property->getName()));
214
			}
215
		}
216
		try {
217
			$property->setValue($this->parseWebsite($property->getValue()));
218
		} catch (InvalidArgumentException $e) {
219
			if ($throwOnData) {
220
				throw $e;
221
			}
222
			$property->setValue('');
223
		}
224
	}
225
226
	protected function updateUser(IUser $user, array $data, bool $throwOnData = false): array {
227
		$oldUserData = $this->getUser($user, false);
228
		$updated = true;
229
230
		if ($oldUserData !== $data) {
231
			$this->updateExistingUser($user, $data);
232
		} else {
233
			// nothing needs to be done if new and old data set are the same
234
			$updated = false;
235
		}
236
237
		if ($updated) {
238
			$this->eventDispatcher->dispatch(
239
				'OC\AccountManager::userUpdated',
0 ignored issues
show
Bug introduced by
'OC\AccountManager::userUpdated' of type string is incompatible with the type object expected by parameter $event of Symfony\Contracts\EventD...erInterface::dispatch(). ( Ignorable by Annotation )

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

239
				/** @scrutinizer ignore-type */ 'OC\AccountManager::userUpdated',
Loading history...
240
				new GenericEvent($user, $data)
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Contracts\EventD...erInterface::dispatch() has too many arguments starting with new Symfony\Component\Ev...ericEvent($user, $data). ( Ignorable by Annotation )

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

240
			$this->eventDispatcher->/** @scrutinizer ignore-call */ 
241
                           dispatch(

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
241
			);
242
		}
243
244
		return $data;
245
	}
246
247
	/**
248
	 * delete user from accounts table
249
	 *
250
	 * @param IUser $user
251
	 */
252
	public function deleteUser(IUser $user) {
253
		$uid = $user->getUID();
254
		$query = $this->connection->getQueryBuilder();
255
		$query->delete($this->table)
256
			->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
257
			->execute();
258
259
		$this->deleteUserData($user);
260
	}
261
262
	/**
263
	 * delete user from accounts table
264
	 *
265
	 * @param IUser $user
266
	 */
267
	public function deleteUserData(IUser $user): void {
268
		$uid = $user->getUID();
269
		$query = $this->connection->getQueryBuilder();
270
		$query->delete($this->dataTable)
271
			->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
272
			->execute();
273
	}
274
275
	/**
276
	 * get stored data from a given user
277
	 */
278
	protected function getUser(IUser $user, bool $insertIfNotExists = true): array {
279
		$uid = $user->getUID();
280
		$query = $this->connection->getQueryBuilder();
281
		$query->select('data')
282
			->from($this->table)
283
			->where($query->expr()->eq('uid', $query->createParameter('uid')))
284
			->setParameter('uid', $uid);
285
		$result = $query->executeQuery();
286
		$accountData = $result->fetchAll();
287
		$result->closeCursor();
288
289
		if (empty($accountData)) {
290
			$userData = $this->buildDefaultUserRecord($user);
291
			if ($insertIfNotExists) {
292
				$this->insertNewUser($user, $userData);
293
			}
294
			return $userData;
295
		}
296
297
		$userDataArray = $this->importFromJson($accountData[0]['data'], $uid);
298
		if ($userDataArray === null || $userDataArray === []) {
299
			return $this->buildDefaultUserRecord($user);
300
		}
301
302
		return $this->addMissingDefaultValues($userDataArray);
303
	}
304
305
	public function searchUsers(string $property, array $values): array {
306
		$chunks = array_chunk($values, 500);
307
		$query = $this->connection->getQueryBuilder();
308
		$query->select('*')
309
			->from($this->dataTable)
310
			->where($query->expr()->eq('name', $query->createNamedParameter($property)))
311
			->andWhere($query->expr()->in('value', $query->createParameter('values')));
312
313
		$matches = [];
314
		foreach ($chunks as $chunk) {
315
			$query->setParameter('values', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
316
			$result = $query->executeQuery();
317
318
			while ($row = $result->fetch()) {
319
				$matches[$row['uid']] = $row['value'];
320
			}
321
			$result->closeCursor();
322
		}
323
324
		$result = array_merge($matches, $this->searchUsersForRelatedCollection($property, $values));
325
326
		return array_flip($result);
327
	}
328
329
	protected function searchUsersForRelatedCollection(string $property, array $values): array {
330
		switch ($property) {
331
			case IAccountManager::PROPERTY_EMAIL:
332
				return array_flip($this->searchUsers(IAccountManager::COLLECTION_EMAIL, $values));
333
			default:
334
				return [];
335
		}
336
	}
337
338
	/**
339
	 * check if we need to ask the server for email verification, if yes we create a cronjob
340
	 *
341
	 */
342
	protected function checkEmailVerification(IAccount $updatedAccount, array $oldData): void {
343
		try {
344
			$property = $updatedAccount->getProperty(self::PROPERTY_EMAIL);
345
		} catch (PropertyDoesNotExistException $e) {
346
			return;
347
		}
348
		$oldMail = isset($oldData[self::PROPERTY_EMAIL]) ? $oldData[self::PROPERTY_EMAIL]['value']['value'] : '';
349
		if ($oldMail !== $property->getValue()) {
350
			$this->jobList->add(VerifyUserData::class,
351
				[
352
					'verificationCode' => '',
353
					'data' => $property->getValue(),
354
					'type' => self::PROPERTY_EMAIL,
355
					'uid' => $updatedAccount->getUser()->getUID(),
356
					'try' => 0,
357
					'lastRun' => time()
358
				]
359
			);
360
361
362
363
364
			$property->setVerified(self::VERIFICATION_IN_PROGRESS);
365
		}
366
	}
367
368
	/**
369
	 * make sure that all expected data are set
370
	 *
371
	 */
372
	protected function addMissingDefaultValues(array $userData): array {
373
		foreach ($userData as $i => $value) {
374
			if (!isset($value['verified'])) {
375
				$userData[$i]['verified'] = self::NOT_VERIFIED;
376
			}
377
		}
378
379
		return $userData;
380
	}
381
382
	protected function updateVerificationStatus(IAccount $updatedAccount, array $oldData): void {
383
		static $propertiesVerifiableByLookupServer = [
384
			self::PROPERTY_TWITTER,
385
			self::PROPERTY_WEBSITE,
386
			self::PROPERTY_EMAIL,
387
		];
388
389
		foreach ($propertiesVerifiableByLookupServer as $propertyName) {
390
			try {
391
				$property = $updatedAccount->getProperty($propertyName);
392
			} catch (PropertyDoesNotExistException $e) {
393
				continue;
394
			}
395
			$wasVerified = isset($oldData[$propertyName])
396
				&& isset($oldData[$propertyName]['verified'])
397
				&& $oldData[$propertyName]['verified'] === self::VERIFIED;
398
			if ((!isset($oldData[$propertyName])
399
					|| !isset($oldData[$propertyName]['value'])
400
					|| $property->getValue() !== $oldData[$propertyName]['value'])
401
				&& ($property->getVerified() !== self::NOT_VERIFIED
402
					|| $wasVerified)
403
				) {
404
				$property->setVerified(self::NOT_VERIFIED);
405
			}
406
		}
407
	}
408
409
410
	/**
411
	 * add new user to accounts table
412
	 *
413
	 * @param IUser $user
414
	 * @param array $data
415
	 */
416
	protected function insertNewUser(IUser $user, array $data): void {
417
		$uid = $user->getUID();
418
		$jsonEncodedData = $this->prepareJson($data);
419
		$query = $this->connection->getQueryBuilder();
420
		$query->insert($this->table)
421
			->values(
422
				[
423
					'uid' => $query->createNamedParameter($uid),
424
					'data' => $query->createNamedParameter($jsonEncodedData),
425
				]
426
			)
427
			->executeStatement();
428
429
		$this->deleteUserData($user);
430
		$this->writeUserData($user, $data);
431
	}
432
433
	protected function prepareJson(array $data): string {
434
		$preparedData = [];
435
		foreach ($data as $dataRow) {
436
			$propertyName = $dataRow['name'];
437
			unset($dataRow['name']);
438
			if (!$this->isCollection($propertyName)) {
439
				$preparedData[$propertyName] = $dataRow;
440
				continue;
441
			}
442
			if (!isset($preparedData[$propertyName])) {
443
				$preparedData[$propertyName] = [];
444
			}
445
			$preparedData[$propertyName][] = $dataRow;
446
		}
447
		return json_encode($preparedData);
448
	}
449
450
	protected function importFromJson(string $json, string $userId): ?array {
451
		$result = [];
452
		$jsonArray = json_decode($json, true);
453
		$jsonError = json_last_error();
454
		if ($jsonError !== JSON_ERROR_NONE) {
455
			$this->logger->critical(
456
				'User data of {uid} contained invalid JSON (error {json_error}), hence falling back to a default user record',
457
				[
458
					'uid' => $userId,
459
					'json_error' => $jsonError
460
				]
461
			);
462
			return null;
463
		}
464
		foreach ($jsonArray as $propertyName => $row) {
465
			if (!$this->isCollection($propertyName)) {
466
				$result[] = array_merge($row, ['name' => $propertyName]);
467
				continue;
468
			}
469
			foreach ($row as $singleRow) {
470
				$result[] = array_merge($singleRow, ['name' => $propertyName]);
471
			}
472
		}
473
		return $result;
474
	}
475
476
	/**
477
	 * update existing user in accounts table
478
	 *
479
	 * @param IUser $user
480
	 * @param array $data
481
	 */
482
	protected function updateExistingUser(IUser $user, array $data): void {
483
		$uid = $user->getUID();
484
		$jsonEncodedData = $this->prepareJson($data);
485
		$query = $this->connection->getQueryBuilder();
486
		$query->update($this->table)
487
			->set('data', $query->createNamedParameter($jsonEncodedData))
488
			->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
489
			->executeStatement();
490
491
		$this->deleteUserData($user);
492
		$this->writeUserData($user, $data);
493
	}
494
495
	protected function writeUserData(IUser $user, array $data): void {
496
		$query = $this->connection->getQueryBuilder();
497
		$query->insert($this->dataTable)
498
			->values(
499
				[
500
					'uid' => $query->createNamedParameter($user->getUID()),
501
					'name' => $query->createParameter('name'),
502
					'value' => $query->createParameter('value'),
503
				]
504
			);
505
		$this->writeUserDataProperties($query, $data);
506
	}
507
508
	protected function writeUserDataProperties(IQueryBuilder $query, array $data): void {
509
		foreach ($data as $property) {
510
			if ($property['name'] === self::PROPERTY_AVATAR) {
511
				continue;
512
			}
513
514
515
			$query->setParameter('name', $property['name'])
516
				->setParameter('value', $property['value'] ?? '');
517
			$query->executeStatement();
518
		}
519
	}
520
521
	/**
522
	 * build default user record in case not data set exists yet
523
	 *
524
	 * @param IUser $user
525
	 * @return array
526
	 */
527
	protected function buildDefaultUserRecord(IUser $user) {
528
		return [
529
530
			[
531
				'name' => self::PROPERTY_DISPLAYNAME,
532
				'value' => $user->getDisplayName(),
533
				'scope' => self::SCOPE_FEDERATED,
534
				'verified' => self::NOT_VERIFIED,
535
			],
536
537
			[
538
				'name' => self::PROPERTY_ADDRESS,
539
				'value' => '',
540
				'scope' => self::SCOPE_LOCAL,
541
				'verified' => self::NOT_VERIFIED,
542
			],
543
544
			[
545
				'name' => self::PROPERTY_WEBSITE,
546
				'value' => '',
547
				'scope' => self::SCOPE_LOCAL,
548
				'verified' => self::NOT_VERIFIED,
549
			],
550
551
			[
552
				'name' => self::PROPERTY_EMAIL,
553
				'value' => $user->getEMailAddress(),
554
				'scope' => self::SCOPE_FEDERATED,
555
				'verified' => self::NOT_VERIFIED,
556
			],
557
558
			[
559
				'name' => self::PROPERTY_AVATAR,
560
				'scope' => self::SCOPE_FEDERATED
561
			],
562
563
			[
564
				'name' => self::PROPERTY_PHONE,
565
				'value' => '',
566
				'scope' => self::SCOPE_LOCAL,
567
				'verified' => self::NOT_VERIFIED,
568
			],
569
570
			[
571
				'name' => self::PROPERTY_TWITTER,
572
				'value' => '',
573
				'scope' => self::SCOPE_LOCAL,
574
				'verified' => self::NOT_VERIFIED,
575
			],
576
577
		];
578
	}
579
580
	private function arrayDataToCollection(IAccount $account, array $data): IAccountPropertyCollection {
581
		$collection = $account->getPropertyCollection($data['name']);
582
583
		$p = new AccountProperty(
584
			$data['name'],
585
			$data['value'] ?? '',
586
			$data['scope'] ?? self::SCOPE_LOCAL,
587
			$data['verified'] ?? self::NOT_VERIFIED,
588
			''
589
		);
590
		$collection->addProperty($p);
591
592
		return $collection;
593
	}
594
595
	private function parseAccountData(IUser $user, $data): Account {
596
		$account = new Account($user);
597
		foreach ($data as $accountData) {
598
			if ($this->isCollection($accountData['name'])) {
599
				$account->setPropertyCollection($this->arrayDataToCollection($account, $accountData));
600
			} else {
601
				$account->setProperty($accountData['name'], $accountData['value'] ?? '', $accountData['scope'] ?? self::SCOPE_LOCAL, $accountData['verified'] ?? self::NOT_VERIFIED);
602
			}
603
		}
604
		return $account;
605
	}
606
607
	public function getAccount(IUser $user): IAccount {
608
		return $this->parseAccountData($user, $this->getUser($user));
609
	}
610
611
	public function updateAccount(IAccount $account): void {
612
		$this->testValueLengths(iterator_to_array($account->getAllProperties()), true);
613
		try {
614
			$property = $account->getProperty(self::PROPERTY_PHONE);
615
			$this->sanitizePhoneNumberValue($property);
616
		} catch (PropertyDoesNotExistException $e) {
617
			//  valid case, nothing to do
618
		}
619
620
		try {
621
			$property = $account->getProperty(self::PROPERTY_WEBSITE);
622
			$this->sanitizeWebsite($property);
623
		} catch (PropertyDoesNotExistException $e) {
624
			//  valid case, nothing to do
625
		}
626
627
		static $allowedScopes = [
628
			self::SCOPE_PRIVATE,
629
			self::SCOPE_LOCAL,
630
			self::SCOPE_FEDERATED,
631
			self::SCOPE_PUBLISHED,
632
			self::VISIBILITY_PRIVATE,
633
			self::VISIBILITY_CONTACTS_ONLY,
634
			self::VISIBILITY_PUBLIC,
635
		];
636
		foreach ($account->getAllProperties() as $property) {
637
			$this->testPropertyScope($property, $allowedScopes, true);
638
		}
639
640
		$oldData = $this->getUser($account->getUser(), false);
641
		$this->updateVerificationStatus($account, $oldData);
642
		$this->checkEmailVerification($account, $oldData);
643
644
		$data = [];
645
		foreach ($account->getAllProperties() as $property) {
646
			$data[] = [
647
				'name' => $property->getName(),
648
				'value' => $property->getValue(),
649
				'scope' => $property->getScope(),
650
				'verified' => $property->getVerified(),
651
			];
652
		}
653
654
		$this->updateUser($account->getUser(), $data, true);
655
	}
656
}
657