Issues (1686)

sources/subs/Auth.subs.php (13 issues)

1
<?php
2
3
/**
4
 * This file has functions in it to do with authentication, user handling, and the like.
5
 *
6
 * @package   ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
9
 *
10
 * This file contains code covered by:
11
 * copyright: 2011 Simple Machines (http://www.simplemachines.org)
12
 *
13
 * @version 2.0 dev
14
 *
15
 */
16
17
use ElkArte\Errors\ErrorContext;
18
use ElkArte\Helper\TokenHash;
19
use ElkArte\Helper\Util;
20
use ElkArte\Languages\Txt;
21
use ElkArte\Request;
22
use ElkArte\User;
23
24
/**
25
 * Sets the login cookie and session based on the id_member and password passed.
26
 *
27
 * What it does:
28
 *
29
 * - password should be already encrypted with the cookie salt.
30
 * - logs the user out if id_member is zero.
31
 * - sets the cookie and session to last the number of seconds specified by cookie_length.
32
 * - when logging out, if the globalCookies setting is enabled, attempts to clear the subdomain's cookie too.
33
 *
34
 * @param int $cookie_length
35
 * @param int $id The id of the member
36
 * @param string $password = ''
37
 * @package Authorization
38
 */
39
function setLoginCookie($cookie_length, $id, $password = '')
40
{
41
	global $cookiename, $boardurl, $modSettings;
42
43
	// If changing state force them to re-address some permission caching.
44
	$_SESSION['mc']['time'] = 0;
45
46
	// Let's be sure it is an int to simplify the regexp used to validate the cookie
47
	$id = (int) $id;
48
49
	// The cookie may already exist, and have been set with different options.
50
	$cookie_state = (empty($modSettings['localCookies']) ? 0 : 1) | (empty($modSettings['globalCookies']) ? 0 : 2);
51
52
	if (isset($_COOKIE[$cookiename]))
53
	{
54
		$array = serializeToJson($_COOKIE[$cookiename], static function ($array_from) use ($cookiename) {
55
			global $modSettings;
56
57
			require_once(SUBSDIR . '/Auth.subs.php');
58
			$_COOKIE[$cookiename] = json_encode($array_from);
59
			setLoginCookie(60 * $modSettings['cookieTime'], $array_from[0], $array_from[1]);
60
		});
61
62
		// Out with the old, in with the new!
63
		if (isset($array[3]) && $array[3] != $cookie_state)
64
		{
65
			$cookie_url = url_parts($array[3] & 1 > 0, $array[3] & 2 > 0);
0 ignored issues
show
$array[3] & 2 > 0 of type integer is incompatible with the type boolean expected by parameter $global of url_parts(). ( Ignorable by Annotation )

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

65
			$cookie_url = url_parts($array[3] & 1 > 0, /** @scrutinizer ignore-type */ $array[3] & 2 > 0);
Loading history...
$array[3] & 1 > 0 of type integer is incompatible with the type boolean expected by parameter $local of url_parts(). ( Ignorable by Annotation )

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

65
			$cookie_url = url_parts(/** @scrutinizer ignore-type */ $array[3] & 1 > 0, $array[3] & 2 > 0);
Loading history...
66
			elk_setcookie($cookiename, json_encode(array(0, '', 0)), time() - 3600, $cookie_url[1], $cookie_url[0]);
67
		}
68
	}
69
70
	// Get the data and path to set it on.
71
	$data = json_encode(empty($id) ? array(0, '', 0) : array($id, $password, time() + $cookie_length, $cookie_state));
72
	$cookie_url = url_parts(!empty($modSettings['localCookies']), !empty($modSettings['globalCookies']));
73
74
	// Set the cookie, $_COOKIE, and session variable.
75
	elk_setcookie($cookiename, $data, time() + $cookie_length, $cookie_url[1], $cookie_url[0]);
76
77
	// If subdomain-independent cookies are on, unset the subdomain-dependent cookie too.
78
	if (empty($id) && !empty($modSettings['globalCookies']))
79
	{
80
		elk_setcookie($cookiename, $data, time() + $cookie_length, $cookie_url[1]);
81
	}
82
83
	// Any alias URLs?  This is mainly for use with frames, etc.
84
	if (!empty($modSettings['forum_alias_urls']))
85
	{
86
		$aliases = explode(',', $modSettings['forum_alias_urls']);
87
88
		$temp = $boardurl;
89
		foreach ($aliases as $alias)
90
		{
91
			// Fake the $boardurl so we can set a different cookie.
92
			$alias = strtr(trim($alias), array('http://' => '', 'https://' => ''));
93
			$boardurl = 'http://' . $alias;
94
95
			$cookie_url = url_parts(!empty($modSettings['localCookies']), !empty($modSettings['globalCookies']));
96
97
			if ($cookie_url[0] == '')
98
			{
99
				$cookie_url[0] = strtok($alias, '/');
100
			}
101
102
			elk_setcookie($cookiename, $data, time() + $cookie_length, $cookie_url[1], $cookie_url[0]);
103
		}
104
105
		$boardurl = $temp;
106
	}
107
108
	$_COOKIE[$cookiename] = $data;
109
110
	// Make sure the user logs in with a new session ID.
111
	if (!isset($_SESSION['login_' . $cookiename]) || $_SESSION['login_' . $cookiename] !== $data)
112
	{
113
		// We need to meddle with the session.
114
		require_once(SOURCEDIR . '/Session.php');
115
116
		// Backup the old session.
117
		$oldSessionData = $_SESSION;
118
119
		// Remove the old session data and file / db entry
120
		$_SESSION = array();
121
		session_destroy();
122
123
		// Recreate and restore the new session.
124
		loadSession();
125
126
		// Get a new session id, and load it with the data
127
		session_regenerate_id();
128
129
		// If we generated new session values, be sure to use them as well
130
		$oldSessionData['session_value'] = $_SESSION['session_value'] ?? $oldSessionData['session_value'];
131
		$oldSessionData['session_var'] = $_SESSION['session_var'] ?? $oldSessionData['session_var'];
132
		$_SESSION = $oldSessionData;
133
134
		$_SESSION['login_' . $cookiename] = $data;
135
	}
136
}
137
138
/**
139
 * Get the domain and path for the cookie
140
 *
141
 * What it does:
142
 *
143
 * - normally, local and global should be the localCookies and globalCookies settings, respectively.
144
 * - uses boardurl to determine these two things.
145
 *
146
 * @param bool $local
147
 * @param bool $global
148
 *
149
 * @return array
150
 * @package Authorization
151
 *
152
 */
153
function url_parts($local, $global)
154
{
155
	global $boardurl, $modSettings;
156
157
	// Parse the URL with PHP to make life easier.
158
	$parsed_url = parse_url($boardurl);
159
160
	// Is local cookies off?
161
	if (empty($parsed_url['path']) || !$local)
162
	{
163
		$parsed_url['path'] = '';
164
	}
165
166
	if (!empty($modSettings['globalCookiesDomain']) && strpos($boardurl, $modSettings['globalCookiesDomain']) !== false)
167
	{
168
		$parsed_url['host'] = $modSettings['globalCookiesDomain'];
169
	}
170
171
	// Globalize cookies across domains (filter out IP-addresses)?
172
	elseif ($global && preg_match('~^\d{1,3}(\.\d{1,3}){3}$~', $parsed_url['host']) == 0 && preg_match('~(?:[^\.]+\.)?([^\.]{2,}\..+)\z~i', $parsed_url['host'], $parts) == 1)
173
	{
174
		$parsed_url['host'] = '.' . $parts[1];
175
	}
176
177
	// We shouldn't use a host at all if both options are off.
178
	elseif (!$local && !$global)
179
	{
180
		$parsed_url['host'] = '';
181
	}
182
183
	// The host also shouldn't be set if there aren't any dots in it.
184
	elseif (!isset($parsed_url['host']) || strpos($parsed_url['host'], '.') === false)
185
	{
186
		$parsed_url['host'] = '';
187
	}
188
189
	return array($parsed_url['host'], $parsed_url['path'] . '/');
190
}
191
192
/**
193
 * Question the verity of the admin by asking for his or her password.
194
 *
195
 * What it does:
196
 *
197
 * - loads Login.template.php and uses the admin_login sub template.
198
 * - sends data to template so the admin is sent on to the page they
199
 *   wanted if their password is correct, otherwise they can try again.
200
 *
201
 * @param string $type = 'admin'
202
 * @package Authorization
203
 */
204
function adminLogin($type = 'admin')
205
{
206
	global $context, $txt;
207
208
	Txt::load('Admin');
209
	theme()->getTemplates()->load('Login');
210
211
	// Validate what type of session check this is.
212
	$types = array();
213
	call_integration_hook('integrate_validateSession', array(&$types));
214
	$type = in_array($type, $types) || $type === 'moderate' ? $type : 'admin';
215
216
	// They used a wrong password, log it and unset that.
217
	if (isset($_POST[$type . '_hash_pass']) || isset($_POST[$type . '_pass']))
218
	{
219
		// log some info along with it! referer, user agent
220
		$req = Request::instance();
221
		$txt['security_wrong'] = sprintf($txt['security_wrong'], $_SERVER['HTTP_REFERER'] ?? $txt['unknown'], $req->user_agent(), User::$info->ip);
0 ignored issues
show
Bug Best Practice introduced by
The property ip does not exist on ElkArte\Helper\ValuesContainer. Since you implemented __get, consider adding a @property annotation.
Loading history...
222
		\ElkArte\Errors\Errors::instance()->log_error($txt['security_wrong'], 'critical');
0 ignored issues
show
The type ElkArte\Errors\Errors was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
223
224
		if (isset($_POST[$type . '_hash_pass']))
225
		{
226
			unset($_POST[$type . '_hash_pass']);
227
		}
228
229
		if (isset($_POST[$type . '_pass']))
230
		{
231
			unset($_POST[$type . '_pass']);
232
		}
233
234
		$context['incorrect_password'] = true;
235
	}
236
237
	createToken('admin-login');
238
239
	// Figure out the get data and post data.
240
	$context['get_data'] = '?' . construct_query_string($_GET);
241
	$context['post_data'] = '';
242
243
	// Now go through $_POST.  Make sure the session hash is sent.
244
	$_POST[$context['session_var']] = $context['session_id'];
245
	foreach ($_POST as $k => $v)
246
	{
247
		$context['post_data'] .= adminLogin_outputPostVars($k, $v);
248
	}
249
250
	// Now we'll use the admin_login sub template of the Login template.
251
	$context['sub_template'] = 'admin_login';
252
253
	// And title the page something like "Login".
254
	if (!isset($context['page_title']))
255
	{
256
		$context['page_title'] = $txt['admin_login'];
257
	}
258
259
	// The type of action.
260
	$context['sessionCheckType'] = $type;
261
262
	obExit();
263
264
	// We MUST exit at this point, because otherwise we CANNOT KNOW that the user is privileged.
265
	trigger_error('Hacking attempt...', E_USER_ERROR);
266
}
267
268
/**
269
 * Used by the adminLogin() function.
270
 *
271
 * What it does:
272
 *  - if 'value' is an array, the function is called recursively.
273
 *
274
 * @param string $k key
275
 * @param string|bool $v value
276
 * @return string 'hidden' HTML form fields, containing key-value-pairs
277
 * @package Authorization
278
 */
279
function adminLogin_outputPostVars($k, $v)
280
{
281
	if (!is_array($v))
0 ignored issues
show
The condition is_array($v) is always false.
Loading history...
282
	{
283
		return '
284
<input type="hidden" name="' . htmlspecialchars($k, ENT_COMPAT, 'UTF-8') . '" value="' . strtr($v, array('"' => '&quot;', '<' => '&lt;', '>' => '&gt;')) . '" />';
0 ignored issues
show
It seems like $v can also be of type boolean; however, parameter $str of strtr() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

284
<input type="hidden" name="' . htmlspecialchars($k, ENT_COMPAT, 'UTF-8') . '" value="' . strtr(/** @scrutinizer ignore-type */ $v, array('"' => '&quot;', '<' => '&lt;', '>' => '&gt;')) . '" />';
Loading history...
285
	}
286
	else
287
	{
288
		$ret = '';
289
		foreach ($v as $k2 => $v2)
290
		{
291
			$ret .= adminLogin_outputPostVars($k . '[' . $k2 . ']', $v2);
292
		}
293
294
		return $ret;
295
	}
296
}
297
298
/**
299
 * Properly urlencodes a string to be used in a query
300
 *
301
 * @param array $get associative array from $_GET
302
 * @return string query string
303
 * @package Authorization
304
 */
305
function construct_query_string($get)
306
{
307
	global $scripturl;
308
309
	$query_string = '';
310
311
	// Awww, darn.  The $scripturl contains GET stuff!
312
	$q = strpos($scripturl, '?');
313
	if ($q !== false)
314
	{
315
		parse_str(preg_replace('/&(\w+)(?=&|$)/', '&$1=', strtr(substr($scripturl, $q + 1), ';', '&')), $temp);
316
317
		foreach ($get as $k => $v)
318
		{
319
			// Only if it's not already in the $scripturl!
320
			if (!isset($temp[$k]))
321
			{
322
				$query_string .= urlencode($k) . '=' . urlencode($v) . ';';
323
			}
324
			// If it changed, put it out there, but with an ampersand.
325
			elseif ($temp[$k] != $v)
326
			{
327
				$query_string .= urlencode($k) . '=' . urlencode($v) . '&amp;';
328
			}
329
		}
330
	}
331
	else
332
	{
333
		// Add up all the data from $_GET into get_data.
334
		foreach ($get as $k => $v)
335
		{
336
			$query_string .= urlencode($k) . '=' . urlencode($v) . ';';
337
		}
338
	}
339
340
	return substr($query_string, 0, -1);
341
}
342
343
/**
344
 * Finds members by email address, username, or real name.
345
 *
346
 * What it does:
347
 *
348
 * - searches for members whose username, display name, or e-mail address match the given pattern of array names.
349
 * - searches only buddies if buddies_only is set.
350
 *
351
 * @param string[]|string $names
352
 * @param bool $use_wildcards = false, accepts wildcards ? and * in the pattern if true
353
 * @param bool $buddies_only = false,
354
 * @param int $max = 500 retrieves a maximum of max members, if passed
355
 * @return array containing information about the matching members
356
 * @package Authorization
357
 */
358
function findMembers($names, $use_wildcards = false, $buddies_only = false, $max = 500)
359 2
{
360
	global $scripturl;
361 2
362
	$db = database();
363
364 2
	// If it's not already an array, make it one.
365
	if (!is_array($names))
366
	{
367
		$names = explode(',', $names);
368
	}
369 2
370 2
	$maybe_email = false;
371
	foreach ($names as $i => $name)
372
	{
373 2
		// Trim, and fix wildcards for each name.
374
		$names[$i] = trim(Util::strtolower($name));
375 2
376
		$maybe_email |= strpos($name, '@') !== false;
377
378 2
		// Make it so standard wildcards will work. (* and ?)
379
		if ($use_wildcards)
380
		{
381
			$names[$i] = strtr($names[$i], array('%' => '\%', '_' => '\_', '*' => '%', '?' => '_', '\'' => '&#039;'));
382
		}
383
		else
384 2
		{
385
			$names[$i] = strtr($names[$i], array('\'' => '&#039;'));
386
		}
387 2
388
		$names[$i] = $db->quote('{string:name}', array('name' => $names[$i]));
389
	}
390
391 2
	// What are we using to compare?
392
	$comparison = $use_wildcards ? 'LIKE' : '=';
393
394 2
	// Nothing found yet.
395
	$results = array();
396
397 2
	// This ensures you can't search someones email address if you can't see it.
398
	$email_condition = allowedTo('moderate_forum') ? '' : '1=0 AND ';
399 2
400
	if ($use_wildcards || $maybe_email)
0 ignored issues
show
Bug Best Practice introduced by
The expression $maybe_email of type false|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
401
	{
402
		$email_condition = '
403
			OR (' . $email_condition . 'email_address ' . $comparison . ' ' . implode(') OR (' . $email_condition . ' email_address ' . $comparison . ' ', $names) . ')';
404
	}
405
406 2
	// Get the case of the columns right - but only if we need to as things like MySQL will go slow needlessly otherwise.
407
	$member_name = '{column_case_insensitive:member_name}';
408
	$real_name = '{column_case_insensitive:real_name}';
409
410 2
	// Search by username, display name, and email address.
411 2
	$db->fetchQuery('
412
		SELECT 
413
			id_member, member_name, real_name, email_address
414 2
		FROM {db_prefix}members
415
		WHERE ({raw:member_name_search}
416
			OR {raw:real_name_search} {raw:email_condition})
417
			' . ($buddies_only ? 'AND id_member IN ({array_int:buddy_list})' : '') . '
418
			AND is_activated IN (1, 11)
419
		LIMIT {int:limit}',
420 2
		array(
421
			'buddy_list' => User::$info->buddies,
0 ignored issues
show
Bug Best Practice introduced by
The property buddies does not exist on ElkArte\Helper\ValuesContainer. Since you implemented __get, consider adding a @property annotation.
Loading history...
422
			'member_name_search' => $member_name . ' ' . $comparison . ' ' . implode(' OR ' . $member_name . ' ' . $comparison . ' ', $names),
423
			'real_name_search' => $real_name . ' ' . $comparison . ' ' . implode(' OR ' . $real_name . ' ' . $comparison . ' ', $names),
424 2
			'email_condition' => $email_condition,
425 2
			'limit' => $max,
426 2
			'recursive' => true,
427 2
		)
428 2
	)->fetch_callback(
429
		function ($row) use (&$results, $scripturl) {
430
			$results[$row['id_member']] = array(
431 2
				'id' => (int) $row['id_member'],
432
				'name' => $row['real_name'],
433 2
				'username' => $row['member_name'],
434 2
				'email' => showEmailAddress($row['id_member']) ? $row['email_address'] : '',
435 2
				'href' => $scripturl . '?action=profile;u=' . $row['id_member'],
436 2
				'link' => '<a href="' . $scripturl . '?action=profile;u=' . $row['id_member'] . '">' . $row['real_name'] . '</a>'
437 2
			);
438 2
		}
439 2
	);
440
441 2
	// Return all the results.
442
	return $results;
443
}
444
445 2
/**
446
 * Generates a random password for a user and emails it to them.
447
 *
448
 * What it does:
449
 *
450
 * - called by ProfileOptions controller when changing someone's username.
451
 * - checks the validity of the new username.
452
 * - generates and sets a new password for the given user.
453
 * - mails the new password to the email address of the user.
454
 * - if username is not set, only a new password is generated and sent.
455
 *
456
 * @param int $memID
457
 * @param string|null $username = null
458
 *
459
 * @throws \ElkArte\Exceptions\Exception
460
 * @package Authorization
461
 *
462
 */
463
function resetPassword($memID, $username = null)
464
{
465
	global $modSettings, $language;
466
467
	// Language... and a required file.
468
	Txt::load('Login');
469
	require_once(SUBSDIR . '/Mail.subs.php');
470
471
	// Get some important details.
472
	require_once(SUBSDIR . '/Members.subs.php');
473
	$result = getBasicMemberData($memID, array('preferences' => true));
474
	$user = $result['member_name'];
475
	$email = $result['email_address'];
476
	$lngfile = $result['lngfile'];
477
	$old_user = '';
478
479
	if ($username !== null)
480
	{
481
		$old_user = $user;
482
		$user = trim($username);
483
	}
484
485
	// Generate a random password.
486
	$tokenizer = new TokenHash();
487
	$newPassword = $tokenizer->generate_hash(14);
488
489
	// Create a db hash for the generated password
490
	$newPassword_sha256 = hash('sha256', strtolower($user) . $newPassword);
491
	$db_hash = password_hash($newPassword_sha256, PASSWORD_BCRYPT, ['cost' => 8]);
492
493
	// Do some checks on the username if needed.
494
	require_once(SUBSDIR . '/Members.subs.php');
495
	if ($username !== null)
496
	{
497
		$errors = ErrorContext::context('reset_pwd', 0);
498
		validateUsername($memID, $user, 'reset_pwd');
499
500
		// If there are "important" errors and you are not an admin: log the first error
501
		// Otherwise grab all of them and don't log anything
502
		$error_severity = $errors->hasErrors(1) && User::$info->is_admin === false ? 1 : null;
0 ignored issues
show
Bug Best Practice introduced by
The property is_admin does not exist on ElkArte\Helper\ValuesContainer. Since you implemented __get, consider adding a @property annotation.
Loading history...
503
		foreach ($errors->prepareErrors($error_severity) as $error)
504
		{
505
			throw new \ElkArte\Exceptions\Exception($error, $error_severity === null ? false : 'general');
506
		}
507
508
		// Update the database...
509
		updateMemberData($memID, array('member_name' => $user, 'passwd' => $db_hash));
510
	}
511
	else
512
	{
513
		updateMemberData($memID, array('passwd' => $db_hash));
514
	}
515
516
	call_integration_hook('integrate_reset_pass', array($old_user, $user, $newPassword));
517
518
	$replacements = array(
519
		'USERNAME' => $user,
520
		'PASSWORD' => $newPassword,
521
	);
522
523
	$emaildata = loadEmailTemplate('change_password', $replacements, empty($lngfile) || empty($modSettings['userLanguage']) ? $language : $lngfile);
524
525
	// Send them the email informing them of the change - then we're done!
526
	sendmail($email, $emaildata['subject'], $emaildata['body'], null, null, false, 0);
527
}
528
529
/**
530
 * Checks a username obeys a load of rules
531
 *
532
 * - Returns null if fine
533
 *
534
 * @param int $memID
535
 * @param string $username
536
 * @param string $ErrorContext
537
 * @param bool $check_reserved_name
538
 * @param bool $fatal pass through to isReservedName
539
 * @return string
540
 * @package Authorization
541
 */
542
function validateUsername($memID, $username, $ErrorContext = 'register', $check_reserved_name = true, $fatal = true)
543
{
544
	global $txt;
545
546
	$errors = ErrorContext::context($ErrorContext, 0);
547
548 6
	// Don't use too long a name.
549
	if (Util::strlen($username) > 25)
550 6
	{
551
		$errors->addError('error_long_name');
552
	}
553 6
554
	// No name?!  How can you register with no name?
555
	if ($username === '')
556
	{
557
		$errors->addError('need_username');
558
	}
559 6
560
	// Only these characters are permitted.
561
	if (in_array($username, array('_', '|')) || preg_match('~[<>&"\'=\\\\]~', preg_replace('~&#(?:\\d{1,7}|x[0-9a-fA-F]{1,6});~', '', $username)) != 0 || strpos($username, '[code') !== false || strpos($username, '[/code') !== false)
562
	{
563
		$errors->addError('error_invalid_characters_username');
564
	}
565 6
566
	if (stripos($username, $txt['guest_title']) !== false)
567
	{
568
		$errors->addError(array('username_reserved', array($txt['guest_title'])), 1);
569
	}
570 6
571
	if ($check_reserved_name)
572
	{
573
		require_once(SUBSDIR . '/Members.subs.php');
574
		if (isReservedName($username, $memID, false, $fatal))
575 6
		{
576
			$errors->addError(array('name_in_use', array(htmlspecialchars($username, ENT_COMPAT, 'UTF-8'))));
577 2
		}
578 2
	}
579
}
580
581
/**
582
 * Checks whether a password meets the current forum rules
583 6
 *
584
 * What it does:
585
 *
586
 * - called when registering/choosing a password.
587
 * - checks the password obeys the current forum settings for password strength.
588
 * - if password checking is enabled, will check that none of the words in restrict_in appear in the password.
589
 * - returns an error identifier if the password is invalid, or null.
590
 *
591
 * @param string $password
592
 * @param string $username
593
 * @param string[] $restrict_in = array()
594
 * @return string an error identifier if the password is invalid
595
 * @package Authorization
596
 */
597
function validatePassword($password, $username, $restrict_in = array())
598
{
599
	global $modSettings, $txt;
600
601
	// Perform basic requirements first.
602
	if (Util::strlen($password) < (empty($modSettings['password_strength']) ? 4 : 8))
603 2
	{
604
		Txt::load('Errors');
605
		$txt['profile_error_password_short'] = sprintf($txt['profile_error_password_short'], empty($modSettings['password_strength']) ? 4 : 8);
606 2
607
		return 'short';
608
	}
609
610
	// Is this enough?
611
	if (empty($modSettings['password_strength']))
612
	{
613
		return null;
614
	}
615 2
616
	// Otherwise, perform the medium strength test - checking if password appears in the restricted string.
617 2
	if (preg_match('~\b' . preg_quote($password, '~') . '\b~', implode(' ', $restrict_in)) != 0)
618
	{
619
		return 'restricted_words';
620
	}
621
	elseif (Util::strpos($password, $username) !== false)
622
	{
623
		return 'restricted_words';
624
	}
625
626
	// If just medium, we're done.
627
	if ($modSettings['password_strength'] == 1)
628
	{
629
		return null;
630
	}
631
632
	// Otherwise, hard test next, check for numbers and letters, uppercase too.
633
	$good = preg_match('~(\D\d|\d\D)~', $password) === 1;
634
	$good = $good && Util::strtolower($password) !== $password;
635
636
	return $good ? null : 'chars';
637
}
638
639
/**
640
 * Checks whether an entered password is correct for the user
641
 *
642
 * What it does:
643
 *
644
 * - called when logging in or whenever a password needs to be validated for a user
645
 * - used to generate a new hash for the db, used during registration or any password changes
646
 * - if a non SHA256 password is sent, will generate one with SHA256(user + password) and return it in password
647
 *
648
 * @param string $password user password if not already 64 characters long will be SHA256 with the user name
649
 * @param string $hash hash as generated from a SHA256 password
650
 * @param string $user user name only required if creating a SHA-256 password
651
 * @param bool $returnhash flag to determine if we are returning a hash suitable for the database
652
 *
653
 * @return bool|string
654
 * @package Authorization
655
 *
656
 */
657
function validateLoginPassword(&$password, $hash, $user = '', $returnhash = false)
658
{
659
	// If the password is not 64 characters, lets make it a (SHA-256)
660
	if (strlen($password) !== 64)
661
	{
662
		$password = hash('sha256', Util::strtolower($user) . un_htmlspecialchars($password));
663
	}
664 10
665
	// They need a password hash, something to save in the db?
666 10
	if ($returnhash)
667
	{
668
		return password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]);
669
	}
670 10
671
	// Doing a password check?
672 6
	return password_verify($password, $hash);
673
}
674
675
/**
676 4
 * Quickly find out what moderation authority this user has
677
 *
678
 * What it does:
679
 *
680
 * - builds the moderator, group and board level querys for the user
681
 * - stores the information on the current users moderation powers in User::$info->mod_cache and $_SESSION['mc']
682
 *
683
 * @package Authorization
684
 */
685
function rebuildModCache()
686
{
687
	$db = database();
688
689
	// What groups can they moderate?
690
	$group_query = allowedTo('manage_membergroups') ? '1=1' : '0=1';
691 1
692
	if ($group_query === '0=1')
693
	{
694 1
		$groups = $db->fetchQuery('
695
			SELECT 
696 1
				id_group
697
			FROM {db_prefix}group_moderators
698 1
			WHERE id_member = {int:current_member}',
699
			array(
700
				'current_member' => User::$info->id,
0 ignored issues
show
Bug Best Practice introduced by
The property id does not exist on ElkArte\Helper\ValuesContainer. Since you implemented __get, consider adding a @property annotation.
Loading history...
701
			)
702
		)->fetch_callback(
703
			function ($row) {
704 1
				return $row['id_group'];
705
			}
706 1
		);
707
708
		$group_query = empty($groups) ? '0=1' : 'id_group IN (' . implode(',', $groups) . ')';
709 1
	}
710
711
	// Then, same again, just the boards this time!
712 1
	$board_query = allowedTo('moderate_forum') ? '1=1' : '0=1';
713
714
	if ($board_query === '0=1')
715
	{
716 1
		$boards = boardsAllowedTo('moderate_board');
717
718 1
		$board_query = empty($boards) ? '0=1' : 'id_board IN (' . implode(',', $boards) . ')';
719
	}
720 1
721
	// What boards are they the moderator of?
722 1
	$boards_mod = array();
723
	if (User::$info->is_guest === false)
0 ignored issues
show
Bug Best Practice introduced by
The property is_guest does not exist on ElkArte\Helper\ValuesContainer. Since you implemented __get, consider adding a @property annotation.
Loading history...
724
	{
725
		require_once(SUBSDIR . '/Boards.subs.php');
726 1
		$boards_mod = boardsModerated(User::$info->id);
727 1
	}
728
729
	$mod_query = empty($boards_mod) ? '0=1' : 'b.id_board IN (' . implode(',', $boards_mod) . ')';
730
731
	$_SESSION['mc'] = array(
732
		'time' => time(),
733 1
		// This looks a bit funny but protects against the login redirect.
734
		'id' => User::$info->id && User::$info->name ? User::$info->id : 0,
0 ignored issues
show
Bug Best Practice introduced by
The property name does not exist on ElkArte\Helper\ValuesContainer. Since you implemented __get, consider adding a @property annotation.
Loading history...
735 1
		// If you change the format of 'gq' and/or 'bq' make sure to adjust 'can_mod' in Load.php.
736 1
		'gq' => $group_query,
737
		'bq' => $board_query,
738 1
		'ap' => boardsAllowedTo('approve_posts'),
739
		'mb' => $boards_mod,
740 1
		'mq' => $mod_query,
741 1
	);
742 1
	call_integration_hook('integrate_mod_cache');
743 1
744 1
	User::$info->mod_cache = $_SESSION['mc'];
0 ignored issues
show
Bug Best Practice introduced by
The property mod_cache does not exist on ElkArte\Helper\ValuesContainer. Since you implemented __set, consider adding a @property annotation.
Loading history...
745
746 1
	// Might as well clean up some tokens while we are at it.
747
	cleanTokens();
748 1
}
749
750
/**
751 1
 * The same thing as setcookie but allows for integration hook
752 1
 *
753
 * @param string $name
754
 * @param string $value = ''
755
 * @param int $expire = 0
756
 * @param string $path = ''
757
 * @param string $domain = ''
758
 * @param bool|null $secure = false
759
 * @param bool|null $httponly = null
760
 * @param string|null $samesite = null
761
 *
762
 * @return bool
763
 * @package Authorization
764
 *
765
 */
766
function elk_setcookie($name, $value = '', $expire = 0, $path = '', $domain = '', $secure = null, $httponly = null, $samesite = null)
767
{
768
	global $modSettings;
769
770
	// In case a customization wants to override the default settings
771
	if ($httponly === null)
772
	{
773
		$httponly = !empty($modSettings['httponlyCookies']);
774
	}
775
776
	if ($secure === null)
777
	{
778
		$secure = !empty($modSettings['secureCookies']);
779
	}
780
781
	// Default value in modern browsers is Lax
782
	// @todo admin panel setting?
783
	$samesite = empty($samesite) ? 'Lax' : $samesite;
784
785
	// Using SameSite=None requires Secure attribute in latest browser versions.
786
	$samesite = (!$secure && $samesite === 'None') ? 'Lax' : $samesite;
787
788
	// Intercept cookie?
789
	call_integration_hook('integrate_cookie', array($name, $value, $expire, $path, $domain, $secure, $httponly, $samesite));
790
791
	if (PHP_VERSION_ID < 70300)
792
	{
793
		return setcookie($name, $value, $expire, $path, $domain, $secure, $httponly);
794
	}
795
796
	return setcookie($name, $value, array(
797
		'expires' => $expire,
798
		'path' => $path,
799
		'domain' => $domain,
800
		'secure' => $secure,
801
		'httponly' => $httponly,
802
		'samesite' => $samesite));
803
}
804
805
/**
806
 * This functions determines whether this is the first login of the given user.
807
 *
808
 * @param int $id_member the id of the member to check for
809
 * @return bool
810
 * @deprecated replaced by \ElkArte\User::$info->isFirstLogin()
811
 *
812
 * @package Authorization
813
 *
814
 */
815
function isFirstLogin($id_member)
816
{
817
	// First login?
818
	require_once(SUBSDIR . '/Members.subs.php');
819
	$member = getBasicMemberData($id_member, array('moderation' => true));
820
821
	return !empty($member) && $member['last_login'] == 0;
822
}
823
824
/**
825
 * Search for a member by given criteria
826
 *
827
 * @param string $where
828
 * @param array $where_params array of values to used in the where statement
829
 * @param bool $fatal
830
 *
831
 * @return array|bool array of members data or false on failure
832
 * @throws \ElkArte\Exceptions\Exception no_user_with_email
833
 * @package Authorization
834
 *
835
 */
836
function findUser($where, $where_params, $fatal = true)
837
{
838
	$db = database();
839
840
	// Find the user!
841
	$request = $db->fetchQuery('
842
		SELECT 
843
			id_member, real_name, member_name, email_address, is_activated, validation_code, 
844
			lngfile, secret_question, passwd
845
		FROM {db_prefix}members
846
		WHERE ' . $where . '
847
		LIMIT 1',
848
		array_merge($where_params, array())
849
	);
850
851
	// Maybe email?
852
	if ($request->num_rows() === 0 && empty($_REQUEST['uid']) && isset($where_params['email_address']))
853
	{
854
		$request->free_result();
855
856
		$request = $db->fetchQuery('
857
			SELECT 
858
				id_member, real_name, member_name, email_address, is_activated, validation_code, 
859
				lngfile, secret_question
860
			FROM {db_prefix}members
861
			WHERE email_address = {string:email_address}
862
			LIMIT 1',
863
			array_merge($where_params, array())
864
		);
865
		if ($request->num_rows() === 0)
866
		{
867
			if ($fatal)
868
			{
869
				throw new \ElkArte\Exceptions\Exception('no_user_with_email', false);
870
			}
871
872
			return false;
873
		}
874
	}
875
876 10
	$member = $request->fetch_assoc();
877
	$member['id_member'] = (int) $member['id_member'];
878 10
	$member['is_activated'] = (int) $member['is_activated'];
879
880
	$request->free_result();
881
882 10
	return $member;
883 10
}
884
885
/**
886 10
 * Find users by their email address.
887 10
 *
888
 * @param string $email
889
 * @param string|null $username
890
 * @return false|int on failure, int of member on success
891 10
 * @package Authorization
892 10
 */
893
function userByEmail($email, $username = null)
894 10
{
895
	$db = database();
896
897
	$return = false;
898
	$db->fetchQuery('
899
		SELECT 
900
			id_member
901
		FROM {db_prefix}members
902
		WHERE email_address = {string:email_address}' . ($username === null ? '' : '
903
			OR email_address = {string:username}') . '
904
		LIMIT 1',
905
		array(
906
			'email_address' => $email,
907
			'username' => $username,
908 1
		)
909
	)->fetch_callback(
910 1
		function ($row) use (&$return) {
911
			$return = (int) $row['id_member'];
912
		}
913
	);
914
915
	return $return;
916
}
917
918
/**
919
 * Generate a random validation code.
920
 *
921
 * @param int $length the number of characters to return
922
 *
923
 * @return string
924 6
 * @package Authorization
925
 *
926 6
 */
927
function generateValidationCode($length = 10)
928 2
{
929
	$tokenizer = new TokenHash();
930
931
	return $tokenizer->generate_hash((int) $length);
932
}
933
934
/**
935
 * This function loads many settings of a user given by name or email.
936 2
 *
937
 * @param string $name
938
 * @param bool $is_id if true it treats $name as a member ID and try to load the data for that ID
939
 * @return array|false false if nothing is found
940
 * @package Authorization
941
 */
942
function loadExistingMember($name, $is_id = false)
943 4
{
944
	$db = database();
945
946
	if ($is_id)
947
	{
948
		$request = $db->fetchQuery('
949
			SELECT 
950
				passwd, id_member, id_group, lngfile, is_activated, email_address, additional_groups, member_name, password_salt,
951 4
				passwd_flood, otp_secret, enable_otp, otp_used
952
			FROM {db_prefix}members
953
			WHERE id_member = {int:id_member}
954
			LIMIT 1',
955 4
			array(
956
				'id_member' => (int) $name,
957
			)
958
		);
959
	}
960
	else
961
	{
962
		// Try to find the user, assuming a member_name was passed...
963
		$request = $db->fetchQuery('
964
			SELECT 
965
				passwd, id_member, id_group, lngfile, is_activated, email_address, additional_groups, member_name, password_salt,
966
				passwd_flood, otp_secret, enable_otp, otp_used
967
			FROM {db_prefix}members
968
			WHERE {column_case_insensitive:member_name} = {string_case_insensitive:user_name}
969
			LIMIT 1',
970
			array(
971
				'user_name' => $name,
972
			)
973
		);
974 6
		// Didn't work. Try it as an email address.
975
		if ($request->num_rows() === 0 && strpos($name, '@') !== false)
976 2
		{
977
			$request->free_result();
978
979
			$request = $db->fetchQuery('
980 4
				SELECT 
981 4
					passwd, id_member, id_group, lngfile, is_activated, email_address, additional_groups, member_name, password_salt,
982
					passwd_flood, otp_secret, enable_otp, otp_used
983
				FROM {db_prefix}members
984 6
				WHERE email_address = {string:user_name}
985
				LIMIT 1',
986 6
				array(
987
					'user_name' => $name,
988
				)
989
			);
990
		}
991
	}
992
993
	// Nothing? Ah the horror...
994
	if ($request->num_rows() === 0)
995
	{
996
		$user_auth_data = false;
997
	}
998
	else
999
	{
1000
		$user_auth_data = $request->fetch_assoc();
1001
		$user_auth_data['id_member'] = (int) $user_auth_data['id_member'];
1002
	}
1003
1004
	$request->free_result();
1005
1006
	return $user_auth_data;
1007
}
1008