1<?php
2
3namespace Elgg\Users;
4
5use Elgg\Config;
6use Elgg\Database\UsersTable;
7use Elgg\I18n\Translator;
8use Elgg\PasswordService;
9use Elgg\PluginHooksService;
10use Elgg\Validation\ValidationResults;
11use ElggUser;
12use RegistrationException;
13use Elgg\Email;
14use Elgg\Email\Address;
15use Elgg\EmailService;
16use Elgg\Security\PasswordGeneratorService;
17
18/**
19 * User accounts service
20 */
21class Accounts {
22
23	/**
24	 * @var Config
25	 */
26	protected $config;
27
28	/**
29	 * @var Translator
30	 */
31	protected $translator;
32
33	/**
34	 * @var PasswordService
35	 */
36	protected $passwords;
37
38	/**
39	 * @var UsersTable
40	 */
41	protected $users;
42
43	/**
44	 * @var PluginHooksService
45	 */
46	protected $hooks;
47
48	/**
49	 * @var EmailService
50	 */
51	protected $email;
52
53	/**
54	 * @var PasswordGeneratorService
55	 */
56	protected $password_generator;
57
58	/**
59	 * Constructor
60	 *
61	 * @param Config                   $config             Config
62	 * @param Translator               $translator         Translator
63	 * @param PasswordService          $passwords          Passwords
64	 * @param UsersTable               $users              Users table
65	 * @param PluginHooksService       $hooks              Plugin hooks service
66	 * @param EmailService             $email              Email service
67	 * @param PasswordGeneratorService $password_generator Password generator service
68	 */
69	public function __construct(
70		Config $config,
71		Translator $translator,
72		PasswordService $passwords,
73		UsersTable $users,
74		PluginHooksService $hooks,
75		EmailService $email,
76		PasswordGeneratorService $password_generator
77	) {
78		$this->config = $config;
79		$this->translator = $translator;
80		$this->passwords = $passwords;
81		$this->users = $users;
82		$this->hooks = $hooks;
83		$this->email = $email;
84		$this->password_generator = $password_generator;
85	}
86
87	/**
88	 * Validate registration details to ensure they can be used to register a new user account
89	 *
90	 * @param string       $username              The username of the new user
91	 * @param string|array $password              The password
92	 *                                            Can be an array [$password, $oonfirm_password]
93	 * @param string       $name                  The user's display name
94	 * @param string       $email                 The user's email address
95	 * @param bool         $allow_multiple_emails Allow the same email address to be
96	 *                                            registered multiple times?
97	 *
98	 * @return ValidationResults
99	 */
100	public function validateAccountData($username, $password, $name, $email, $allow_multiple_emails = false) {
101
102		return elgg_call(ELGG_SHOW_DISABLED_ENTITIES, function () use ($username, $email, $password, $name, $allow_multiple_emails) {
103			$results = new ValidationResults();
104
105			if (empty($name)) {
106				$error = $this->translator->translate('registration:noname');
107				$results->fail('name', $name, $error);
108			} else {
109				$results->pass('name', $name);
110			}
111
112			try {
113				$this->assertValidEmail($email, !$allow_multiple_emails);
114
115				$results->pass('email', $email);
116			} catch (RegistrationException $ex) {
117				$results->fail('email', $email, $ex->getMessage());
118			}
119
120			try {
121				$this->assertValidPassword($password);
122
123				$results->pass('password', $password);
124			} catch (RegistrationException $ex) {
125				$results->fail('password', $password, $ex->getMessage());
126			}
127
128			try {
129				$this->assertValidUsername($username, true);
130
131				$results->pass('username', $username);
132			} catch (RegistrationException $ex) {
133				$results->fail('username', $username, $ex->getMessage());
134			}
135
136			return $results;
137		});
138	}
139
140	/**
141	 * Assert that given registration details are valid and can be used to register the user
142	 *
143	 * @param string $username              The username of the new user
144	 * @param string $password              The password
145	 * @param string $name                  The user's display name
146	 * @param string $email                 The user's email address
147	 * @param bool   $allow_multiple_emails Allow the same email address to be
148	 *                                      registered multiple times?
149	 *
150	 * @return void
151	 * @throws RegistrationException
152	 */
153	public function assertValidAccountData($username, $password, $name, $email, $allow_multiple_emails = false) {
154
155		$results = $this->validateAccountData($username, $password, $name, $email, $allow_multiple_emails);
156
157		foreach ($results->all() as $result) {
158			if (!$result->isValid()) {
159				throw new RegistrationException($result->getError());
160			}
161		}
162
163	}
164
165	/**
166	 * Registers a user, returning false if the username already exists
167	 *
168	 * @param string $username              The username of the new user
169	 * @param string $password              The password
170	 * @param string $name                  The user's display name
171	 * @param string $email                 The user's email address
172	 * @param bool   $allow_multiple_emails Allow the same email address to be
173	 *                                      registered multiple times?
174	 * @param string $subtype               Subtype of the user entity
175	 *
176	 * @return int|false The new user's GUID; false on failure
177	 * @throws RegistrationException
178	 */
179	public function register($username, $password, $name, $email, $allow_multiple_emails = false, $subtype = null) {
180
181		$this->assertValidAccountData($username, $password, $name, $email, $allow_multiple_emails);
182
183		// Create user
184		$constructor = ElggUser::class;
185		if (isset($subtype)) {
186			$class = elgg_get_entity_class('user', $subtype);
187			if ($class && class_exists($class) && is_subclass_of($class, ElggUser::class)) {
188				$constructor = $class;
189			}
190		}
191
192		$user = new $constructor();
193		/* @var $user ElggUser */
194
195		if (isset($subtype)) {
196			$user->subtype = $subtype;
197		}
198
199		$user->username = $username;
200		$user->email = $email;
201		$user->name = $name;
202		$user->access_id = ACCESS_PUBLIC;
203		$user->owner_guid = 0; // Users aren't owned by anyone, even if they are admin created.
204		$user->container_guid = 0; // Users aren't contained by anyone, even if they are admin created.
205		$user->language = $this->translator->getCurrentLanguage();
206
207		if ($user->save() === false) {
208			return false;
209		}
210
211		// doing this after save to prevent metadata save notices on unwritable metadata password_hash
212		$user->setPassword($password);
213
214		// Turn on email notifications by default
215		$user->setNotificationSetting('email', true);
216
217		return $user->getGUID();
218	}
219
220	/**
221	 * Simple function which ensures that a username contains only valid characters.
222	 *
223	 * This should only permit chars that are valid on the file system as well.
224	 *
225	 * @param string $username            Username
226	 * @param bool   $assert_unregistered Also assert that the username has not yet been registered
227	 *
228	 * @return void
229	 * @throws RegistrationException
230	 */
231	public function assertValidUsername($username, $assert_unregistered = false) {
232
233		if (elgg_strlen($username) < $this->config->minusername) {
234			$msg = $this->translator->translate('registration:usernametooshort', [$this->config->minusername]);
235			throw new RegistrationException($msg);
236		}
237
238		// username in the database has a limit of 128 characters
239		if (strlen($username) > 128) {
240			$msg = $this->translator->translate('registration:usernametoolong', [128]);
241			throw new RegistrationException($msg);
242		}
243
244		// Whitelist all supported route characters
245		// @see Elgg\Router\RouteRegistrationService::register()
246		// @link https://github.com/Elgg/Elgg/issues/12518
247		$whitelist = '/[\p{L}\p{M}\p{Nd}._-]+/';
248		if (!preg_match_all($whitelist, $username)) {
249			throw new RegistrationException($this->translator->translate('registration:invalidchars'));
250		}
251
252		// Belts and braces
253		// @todo Tidy into main unicode
254		$blacklist2 = '\'/\\"*& ?#%^(){}[]~?<>;|¬`@+=,:';
255
256		$blacklist2 = $this->hooks->trigger(
257			'username:character_blacklist',
258			'user',
259			['blacklist' => $blacklist2],
260			$blacklist2
261		);
262
263		for ($n = 0; $n < elgg_strlen($blacklist2); $n++) {
264			if (elgg_strpos($username, $blacklist2[$n]) !== false) {
265				$msg = $this->translator->translate('registration:invalidchars', [$blacklist2[$n], $blacklist2]);
266				$msg = htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
267				throw new RegistrationException($msg);
268			}
269		}
270
271		$result = $this->hooks->trigger(
272			'registeruser:validate:username',
273			'all',
274			['username' => $username],
275			true
276		);
277
278		if (!$result) {
279			throw new RegistrationException($this->translator->translate('registration:usernamenotvalid'));
280		}
281
282		if ($assert_unregistered) {
283			$exists = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function () use ($username) {
284				return $this->users->getByUsername($username);
285			});
286
287			if ($exists) {
288				throw new RegistrationException($this->translator->translate('registration:userexists'));
289			}
290		}
291	}
292
293	/**
294	 * Simple validation of a password
295	 *
296	 * @param string|array $password Clear text password
297	 *                               Can be an array [$password, $confirm_password]
298	 *
299	 * @return void
300	 * @throws RegistrationException
301	 */
302	public function assertValidPassword($password) {
303
304		if (is_array($password)) {
305			list($password, $password2) = $password;
306
307			if (empty($password) || empty($password2)) {
308				throw new RegistrationException(elgg_echo('RegistrationException:EmptyPassword'));
309			}
310
311			if (strcmp($password, $password2) != 0) {
312				throw new RegistrationException(elgg_echo('RegistrationException:PasswordMismatch'));
313			}
314		}
315
316		$result = $this->hooks->trigger(
317			'registeruser:validate:password',
318			'all',
319			['password' => $password],
320			true
321		);
322
323		if (!$result) {
324			throw new RegistrationException($this->translator->translate('registration:passwordnotvalid'));
325		}
326	}
327
328	/**
329	 * Assert that user can authenticate with the given password
330	 *
331	 * @param ElggUser $user     User entity
332	 * @param string   $password Password
333	 *
334	 * @return void
335	 * @throws RegistrationException
336	 */
337	public function assertCurrentPassword(ElggUser $user, $password) {
338		if (!$this->passwords->verify($password, $user->password_hash)) {
339			throw new RegistrationException($this->translator->translate('LoginException:PasswordFailure'));
340		}
341	}
342
343	/**
344	 * Simple validation of a email.
345	 *
346	 * @param string $address             Email address
347	 * @param bool   $assert_unregistered Also assert that the email address has not yet been used for a user account
348	 *
349	 * @return void
350	 * @throws RegistrationException
351	 */
352	public function assertValidEmail($address, $assert_unregistered = false) {
353		if (!$this->isValidEmail($address)) {
354			throw new RegistrationException($this->translator->translate('registration:notemail'));
355		}
356
357		$result = $this->hooks->trigger(
358			'registeruser:validate:email',
359			'all',
360			['email' => $address],
361			true
362		);
363
364		if (!$result) {
365			throw new RegistrationException($this->translator->translate('registration:emailnotvalid'));
366		}
367
368		if ($assert_unregistered) {
369			$exists = elgg_call(ELGG_IGNORE_ACCESS | ELGG_SHOW_DISABLED_ENTITIES, function () use ($address) {
370				return $this->users->getByEmail($address);
371			});
372
373			if ($exists) {
374				throw new RegistrationException($this->translator->translate('registration:dupeemail'));
375			}
376		}
377	}
378
379	/**
380	 * Validates an email address.
381	 *
382	 * @param string $address Email address
383	 *
384	 * @return bool
385	 */
386	public function isValidEmail($address) {
387		return filter_var($address, FILTER_VALIDATE_EMAIL) === $address;
388	}
389
390	/**
391	 * Send out an e-mail to the new email address the user wanted
392	 *
393	 * @param \ElggUser $user  user with new e-mail address
394	 * @param string    $email E-mail address
395	 *
396	 * @return bool
397	 * @throws \InvalidParameterException
398	 */
399	public function requestNewEmailValidation(\ElggUser $user, $email) {
400		if (!$this->isValidEmail($email)) {
401			throw new \InvalidParameterException($this->translator->translate('registration:notemail'));
402		}
403
404		$site = elgg_get_site_entity();
405
406		$user->setPrivateSetting('new_email', $email);
407
408		$url = elgg_generate_url('account:email:confirm', [
409			'guid' => $user->guid,
410		]);
411		$url = elgg_http_get_signed_url($url, '+1 hour');
412
413		$notification = Email::factory([
414			'from' => $site,
415			'to' => new Address($email, $user->getDisplayName()),
416			'subject' => $this->translator->translate('email:request:email:subject', [], $user->getLanguage()),
417			'body' => $this->translator->translate('email:request:email:body', [
418				$user->getDisplayName(),
419				$site->getDisplayName(),
420				$url,
421			], $user->getLanguage()),
422		]);
423
424		return $this->email->send($notification);
425	}
426}
427