3 * @file src/Model/User.php
4 * @brief This file includes the User class with user related database functions
6 namespace Friendica\Model;
8 use DivineOmega\PasswordExposed;
10 use Friendica\Core\Addon;
11 use Friendica\Core\Config;
12 use Friendica\Core\Hook;
13 use Friendica\Core\L10n;
14 use Friendica\Core\Logger;
15 use Friendica\Core\PConfig;
16 use Friendica\Core\Protocol;
17 use Friendica\Core\System;
18 use Friendica\Core\Worker;
19 use Friendica\Database\DBA;
20 use Friendica\Object\Image;
21 use Friendica\Util\Crypto;
22 use Friendica\Util\DateTimeFormat;
23 use Friendica\Util\Network;
24 use Friendica\Util\Strings;
27 require_once 'boot.php';
28 require_once 'include/dba.php';
29 require_once 'include/enotify.php';
30 require_once 'include/text.php';
32 * @brief This class handles User related functions
37 * Returns true if a user record exists with the provided id
42 public static function exists($uid)
44 return DBA::exists('user', ['uid' => $uid]);
49 * @return array|boolean User record if it exists, false otherwise
51 public static function getById($uid)
53 return DBA::selectFirst('user', [], ['uid' => $uid]);
57 * @brief Returns the user id of a given profile URL
61 * @return integer user id
63 public static function getIdForURL($url)
65 $self = DBA::selectFirst('contact', ['uid'], ['nurl' => Strings::normaliseLink($url), 'self' => true]);
66 if (!DBA::isResult($self)) {
74 * @brief Get owner data by user id
77 * @return boolean|array
79 public static function getOwnerDataById($uid) {
80 $r = DBA::fetchFirst("SELECT
82 `user`.`prvkey` AS `uprvkey`,
88 `user`.`account-type`,
92 ON `user`.`uid` = `contact`.`uid`
93 WHERE `contact`.`uid` = ?
98 if (!DBA::isResult($r)) {
105 * @brief Get owner data by nick name
108 * @return boolean|array
110 public static function getOwnerDataByNick($nick)
112 $user = DBA::selectFirst('user', ['uid'], ['nickname' => $nick]);
114 if (!DBA::isResult($user)) {
118 return self::getOwnerDataById($user['uid']);
122 * @brief Returns the default group for a given user and network
124 * @param int $uid User id
125 * @param string $network network name
127 * @return int group id
129 public static function getDefaultGroup($uid, $network = '')
133 if ($network == Protocol::OSTATUS) {
134 $default_group = PConfig::get($uid, "ostatus", "default_group");
137 if ($default_group != 0) {
138 return $default_group;
141 $user = DBA::selectFirst('user', ['def_gid'], ['uid' => $uid]);
143 if (DBA::isResult($user)) {
144 $default_group = $user["def_gid"];
147 return $default_group;
152 * Authenticate a user with a clear text password
154 * @brief Authenticate a user with a clear text password
155 * @param mixed $user_info
156 * @param string $password
157 * @return int|boolean
158 * @deprecated since version 3.6
159 * @see User::getIdFromPasswordAuthentication()
161 public static function authenticate($user_info, $password)
164 return self::getIdFromPasswordAuthentication($user_info, $password);
165 } catch (Exception $ex) {
171 * Returns the user id associated with a successful password authentication
173 * @brief Authenticate a user with a clear text password
174 * @param mixed $user_info
175 * @param string $password
176 * @return int User Id if authentication is successful
179 public static function getIdFromPasswordAuthentication($user_info, $password)
181 $user = self::getAuthenticationInfo($user_info);
183 if (strpos($user['password'], '$') === false) {
184 //Legacy hash that has not been replaced by a new hash yet
185 if (self::hashPasswordLegacy($password) === $user['password']) {
186 self::updatePassword($user['uid'], $password);
190 } elseif (!empty($user['legacy_password'])) {
191 //Legacy hash that has been double-hashed and not replaced by a new hash yet
192 //Warning: `legacy_password` is not necessary in sync with the content of `password`
193 if (password_verify(self::hashPasswordLegacy($password), $user['password'])) {
194 self::updatePassword($user['uid'], $password);
198 } elseif (password_verify($password, $user['password'])) {
200 if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
201 self::updatePassword($user['uid'], $password);
207 throw new Exception(L10n::t('Login failed'));
211 * Returns authentication info from various parameters types
213 * User info can be any of the following:
216 * - User email or username or nickname
217 * - User array with at least the uid and the hashed password
219 * @param mixed $user_info
223 private static function getAuthenticationInfo($user_info)
227 if (is_object($user_info) || is_array($user_info)) {
228 if (is_object($user_info)) {
229 $user = (array) $user_info;
234 if (!isset($user['uid'])
235 || !isset($user['password'])
236 || !isset($user['legacy_password'])
238 throw new Exception(L10n::t('Not enough information to authenticate'));
240 } elseif (is_int($user_info) || is_string($user_info)) {
241 if (is_int($user_info)) {
242 $user = DBA::selectFirst('user', ['uid', 'password', 'legacy_password'],
246 'account_expired' => 0,
247 'account_removed' => 0,
252 $fields = ['uid', 'password', 'legacy_password'];
253 $condition = ["(`email` = ? OR `username` = ? OR `nickname` = ?)
254 AND NOT `blocked` AND NOT `account_expired` AND NOT `account_removed` AND `verified`",
255 $user_info, $user_info, $user_info];
256 $user = DBA::selectFirst('user', $fields, $condition);
259 if (!DBA::isResult($user)) {
260 throw new Exception(L10n::t('User not found'));
268 * Generates a human-readable random password
272 public static function generateNewPassword()
274 return Strings::getRandomName(6) . mt_rand(100, 9999);
278 * Checks if the provided plaintext password has been exposed or not
280 * @param string $password
283 public static function isPasswordExposed($password)
285 $cache = new \DivineOmega\DOFileCachePSR6\CacheItemPool();
286 $cache->changeConfig([
287 'cacheDirectory' => get_temppath() . '/password-exposed-cache/',
290 $PasswordExposedCHecker = new PasswordExposed\PasswordExposedChecker(null, $cache);
292 return $PasswordExposedCHecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED;
296 * Legacy hashing function, kept for password migration purposes
298 * @param string $password
301 private static function hashPasswordLegacy($password)
303 return hash('whirlpool', $password);
307 * Global user password hashing function
309 * @param string $password
312 public static function hashPassword($password)
314 if (!trim($password)) {
315 throw new Exception(L10n::t('Password can\'t be empty'));
318 return password_hash($password, PASSWORD_DEFAULT);
322 * Updates a user row with a new plaintext password
325 * @param string $password
328 public static function updatePassword($uid, $password)
330 return self::updatePasswordHashed($uid, self::hashPassword($password));
334 * Updates a user row with a new hashed password.
335 * Empties the password reset token field just in case.
338 * @param string $pasword_hashed
341 private static function updatePasswordHashed($uid, $pasword_hashed)
344 'password' => $pasword_hashed,
346 'pwdreset_time' => null,
347 'legacy_password' => false
349 return DBA::update('user', $fields, ['uid' => $uid]);
353 * @brief Checks if a nickname is in the list of the forbidden nicknames
355 * Check if a nickname is forbidden from registration on the node by the
356 * admin. Forbidden nicknames (e.g. role namess) can be configured in the
359 * @param string $nickname The nickname that should be checked
360 * @return boolean True is the nickname is blocked on the node
362 public static function isNicknameBlocked($nickname)
364 $forbidden_nicknames = Config::get('system', 'forbidden_nicknames', '');
366 // if the config variable is empty return false
367 if (empty($forbidden_nicknames)) {
371 // check if the nickname is in the list of blocked nicknames
372 $forbidden = explode(',', $forbidden_nicknames);
373 $forbidden = array_map('trim', $forbidden);
374 if (in_array(strtolower($nickname), $forbidden)) {
383 * @brief Catch-all user creation function
385 * Creates a user from the provided data array, either form fields or OpenID.
386 * Required: { username, nickname, email } or { openid_url }
388 * Performs the following:
389 * - Sends to the OpenId auth URL (if relevant)
390 * - Creates new key pairs for crypto
391 * - Create self-contact
392 * - Create profile image
398 public static function create(array $data)
401 $return = ['user' => null, 'password' => ''];
403 $using_invites = Config::get('system', 'invitation_only');
404 $num_invites = Config::get('system', 'number_invites');
406 $invite_id = !empty($data['invite_id']) ? Strings::escapeTags(trim($data['invite_id'])) : '';
407 $username = !empty($data['username']) ? Strings::escapeTags(trim($data['username'])) : '';
408 $nickname = !empty($data['nickname']) ? Strings::escapeTags(trim($data['nickname'])) : '';
409 $email = !empty($data['email']) ? Strings::escapeTags(trim($data['email'])) : '';
410 $openid_url = !empty($data['openid_url']) ? Strings::escapeTags(trim($data['openid_url'])) : '';
411 $photo = !empty($data['photo']) ? Strings::escapeTags(trim($data['photo'])) : '';
412 $password = !empty($data['password']) ? trim($data['password']) : '';
413 $password1 = !empty($data['password1']) ? trim($data['password1']) : '';
414 $confirm = !empty($data['confirm']) ? trim($data['confirm']) : '';
415 $blocked = !empty($data['blocked']);
416 $verified = !empty($data['verified']);
417 $language = !empty($data['language']) ? Strings::escapeTags(trim($data['language'])) : 'en';
419 $publish = !empty($data['profile_publish_reg']);
420 $netpublish = $publish && Config::get('system', 'directory');
422 if ($password1 != $confirm) {
423 throw new Exception(L10n::t('Passwords do not match. Password unchanged.'));
424 } elseif ($password1 != '') {
425 $password = $password1;
428 if ($using_invites) {
430 throw new Exception(L10n::t('An invitation is required.'));
433 if (!Register::existsByHash($invite_id)) {
434 throw new Exception(L10n::t('Invitation could not be verified.'));
438 if (empty($username) || empty($email) || empty($nickname)) {
440 if (!Network::isUrlValid($openid_url)) {
441 throw new Exception(L10n::t('Invalid OpenID url'));
443 $_SESSION['register'] = 1;
444 $_SESSION['openid'] = $openid_url;
446 $openid = new LightOpenID($a->getHostName());
447 $openid->identity = $openid_url;
448 $openid->returnUrl = System::baseUrl() . '/openid';
449 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
450 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
452 $authurl = $openid->authUrl();
453 } catch (Exception $e) {
454 throw new Exception(L10n::t('We encountered a problem while logging in with the OpenID you provided. Please check the correct spelling of the ID.') . EOL . EOL . L10n::t('The error message was:') . $e->getMessage(), 0, $e);
456 System::externalRedirect($authurl);
460 throw new Exception(L10n::t('Please enter the required information.'));
463 if (!Network::isUrlValid($openid_url)) {
469 // collapse multiple spaces in name
470 $username = preg_replace('/ +/', ' ', $username);
472 $username_min_length = max(1, min(64, intval(Config::get('system', 'username_min_length', 3))));
473 $username_max_length = max(1, min(64, intval(Config::get('system', 'username_max_length', 48))));
475 if ($username_min_length > $username_max_length) {
476 Logger::log(L10n::t('system.username_min_length (%s) and system.username_max_length (%s) are excluding each other, swapping values.', $username_min_length, $username_max_length), Logger::WARNING);
477 $tmp = $username_min_length;
478 $username_min_length = $username_max_length;
479 $username_max_length = $tmp;
482 if (mb_strlen($username) < $username_min_length) {
483 throw new Exception(L10n::tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
486 if (mb_strlen($username) > $username_max_length) {
487 throw new Exception(L10n::tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
490 // So now we are just looking for a space in the full name.
491 $loose_reg = Config::get('system', 'no_regfullname');
493 $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
494 if (strpos($username, ' ') === false) {
495 throw new Exception(L10n::t("That doesn't appear to be your full (First Last) name."));
499 if (!Network::isEmailDomainAllowed($email)) {
500 throw new Exception(L10n::t('Your email domain is not among those allowed on this site.'));
503 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
504 throw new Exception(L10n::t('Not a valid email address.'));
506 if (self::isNicknameBlocked($nickname)) {
507 throw new Exception(L10n::t('The nickname was blocked from registration by the nodes admin.'));
510 if (Config::get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
511 throw new Exception(L10n::t('Cannot use that email.'));
514 // Disallow somebody creating an account using openid that uses the admin email address,
515 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
516 if (Config::get('config', 'admin_email') && strlen($openid_url)) {
517 $adminlist = explode(',', str_replace(' ', '', strtolower(Config::get('config', 'admin_email'))));
518 if (in_array(strtolower($email), $adminlist)) {
519 throw new Exception(L10n::t('Cannot use that email.'));
523 $nickname = $data['nickname'] = strtolower($nickname);
525 if (!preg_match('/^[a-z0-9][a-z0-9\_]*$/', $nickname)) {
526 throw new Exception(L10n::t('Your nickname can only contain a-z, 0-9 and _.'));
529 // Check existing and deleted accounts for this nickname.
530 if (DBA::exists('user', ['nickname' => $nickname])
531 || DBA::exists('userd', ['username' => $nickname])
533 throw new Exception(L10n::t('Nickname is already registered. Please choose another.'));
536 $new_password = strlen($password) ? $password : User::generateNewPassword();
537 $new_password_encoded = self::hashPassword($new_password);
539 $return['password'] = $new_password;
541 $keys = Crypto::newKeypair(4096);
542 if ($keys === false) {
543 throw new Exception(L10n::t('SERIOUS ERROR: Generation of security keys failed.'));
546 $prvkey = $keys['prvkey'];
547 $pubkey = $keys['pubkey'];
549 // Create another keypair for signing/verifying salmon protocol messages.
550 $sres = Crypto::newKeypair(512);
551 $sprvkey = $sres['prvkey'];
552 $spubkey = $sres['pubkey'];
554 $insert_result = DBA::insert('user', [
555 'guid' => System::createUUID(),
556 'username' => $username,
557 'password' => $new_password_encoded,
559 'openid' => $openid_url,
560 'nickname' => $nickname,
563 'spubkey' => $spubkey,
564 'sprvkey' => $sprvkey,
565 'verified' => $verified,
566 'blocked' => $blocked,
567 'language' => $language,
569 'register_date' => DateTimeFormat::utcNow(),
570 'default-location' => ''
573 if ($insert_result) {
574 $uid = DBA::lastInsertId();
575 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
577 throw new Exception(L10n::t('An error occurred during registration. Please try again.'));
581 throw new Exception(L10n::t('An error occurred during registration. Please try again.'));
584 // if somebody clicked submit twice very quickly, they could end up with two accounts
585 // due to race condition. Remove this one.
586 $user_count = DBA::count('user', ['nickname' => $nickname]);
587 if ($user_count > 1) {
588 DBA::delete('user', ['uid' => $uid]);
590 throw new Exception(L10n::t('Nickname is already registered. Please choose another.'));
593 $insert_result = DBA::insert('profile', [
596 'photo' => System::baseUrl() . "/photo/profile/{$uid}.jpg",
597 'thumb' => System::baseUrl() . "/photo/avatar/{$uid}.jpg",
598 'publish' => $publish,
600 'net-publish' => $netpublish,
601 'profile-name' => L10n::t('default')
603 if (!$insert_result) {
604 DBA::delete('user', ['uid' => $uid]);
606 throw new Exception(L10n::t('An error occurred creating your default profile. Please try again.'));
609 // Create the self contact
610 if (!Contact::createSelfFromUserId($uid)) {
611 DBA::delete('user', ['uid' => $uid]);
613 throw new Exception(L10n::t('An error occurred creating your self contact. Please try again.'));
616 // Create a group with no members. This allows somebody to use it
617 // right away as a default group for new contacts.
618 $def_gid = Group::create($uid, L10n::t('Friends'));
620 DBA::delete('user', ['uid' => $uid]);
622 throw new Exception(L10n::t('An error occurred creating your default contact group. Please try again.'));
625 $fields = ['def_gid' => $def_gid];
626 if (Config::get('system', 'newuser_private') && $def_gid) {
627 $fields['allow_gid'] = '<' . $def_gid . '>';
630 DBA::update('user', $fields, ['uid' => $uid]);
632 // if we have no OpenID photo try to look up an avatar
633 if (!strlen($photo)) {
634 $photo = Network::lookupAvatarByEmail($email);
637 // unless there is no avatar-addon loaded
638 if (strlen($photo)) {
639 $photo_failure = false;
641 $filename = basename($photo);
642 $img_str = Network::fetchUrl($photo, true);
643 // guess mimetype from headers or filename
644 $type = Image::guessType($photo, true);
646 $Image = new Image($img_str, $type);
647 if ($Image->isValid()) {
648 $Image->scaleToSquare(300);
650 $hash = Photo::newResource();
652 $r = Photo::store($Image, $uid, 0, $hash, $filename, L10n::t('Profile Photos'), 4);
655 $photo_failure = true;
658 $Image->scaleDown(80);
660 $r = Photo::store($Image, $uid, 0, $hash, $filename, L10n::t('Profile Photos'), 5);
663 $photo_failure = true;
666 $Image->scaleDown(48);
668 $r = Photo::store($Image, $uid, 0, $hash, $filename, L10n::t('Profile Photos'), 6);
671 $photo_failure = true;
674 if (!$photo_failure) {
675 DBA::update('photo', ['profile' => 1], ['resource-id' => $hash]);
680 Addon::callHooks('register_account', $uid);
682 $return['user'] = $user;
687 * @brief Sends pending registration confirmation email
689 * @param array $user User record array
690 * @param string $sitename
691 * @param string $siteurl
692 * @param string $password Plaintext password
693 * @return NULL|boolean from notification() and email() inherited
695 public static function sendRegisterPendingEmail($user, $sitename, $siteurl, $password)
697 $body = Strings::deindent(L10n::t('
699 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
701 Your login details are as follows:
707 $user['username'], $sitename, $siteurl, $user['nickname'], $password
710 return notification([
711 'type' => SYSTEM_EMAIL,
712 'uid' => $user['uid'],
713 'to_email' => $user['email'],
714 'subject' => L10n::t('Registration at %s', $sitename),
720 * @brief Sends registration confirmation
722 * It's here as a function because the mail is sent from different parts
724 * @param array $user User record array
725 * @param string $sitename
726 * @param string $siteurl
727 * @param string $password Plaintext password
728 * @return NULL|boolean from notification() and email() inherited
730 public static function sendRegisterOpenEmail($user, $sitename, $siteurl, $password)
732 $preamble = Strings::deindent(L10n::t('
734 Thank you for registering at %2$s. Your account has been created.
736 $user['username'], $sitename
738 $body = Strings::deindent(L10n::t('
739 The login details are as follows:
745 You may change your password from your account "Settings" page after logging
748 Please take a few moments to review the other account settings on that page.
750 You may also wish to add some basic information to your default profile
751 ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
753 We recommend setting your full name, adding a profile photo,
754 adding some profile "keywords" ' . "\x28" . 'very useful in making new friends' . "\x29" . ' - and
755 perhaps what country you live in; if you do not wish to be more specific
758 We fully respect your right to privacy, and none of these items are necessary.
759 If you are new and do not know anybody here, they may help
760 you to make some new and interesting friends.
762 If you ever want to delete your account, you can do so at %3$s/removeme
764 Thank you and welcome to %2$s.',
765 $user['email'], $sitename, $siteurl, $user['username'], $password
768 return notification([
769 'uid' => $user['uid'],
770 'language' => $user['language'],
771 'type' => SYSTEM_EMAIL,
772 'to_email' => $user['email'],
773 'subject' => L10n::t('Registration details for %s', $sitename),
774 'preamble' => $preamble,
780 * @param object $uid user to remove
783 public static function remove($uid)
791 Logger::log('Removing user: ' . $uid);
793 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
795 Hook::callAll('remove_user', $user);
797 // save username (actually the nickname as it is guaranteed
798 // unique), so it cannot be re-registered in the future.
799 DBA::insert('userd', ['username' => $user['nickname']]);
801 // The user and related data will be deleted in "cron_expire_and_remove_users" (cronjobs.php)
802 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
803 Worker::add(PRIORITY_HIGH, 'Notifier', 'removeme', $uid);
805 // Send an update to the directory
806 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
807 Worker::add(PRIORITY_LOW, 'Directory', $self['url']);
809 // Remove the user relevant data
810 Worker::add(PRIORITY_LOW, 'RemoveUser', $uid);
816 * Return all identities to a user
818 * @param int $uid The user id
819 * @return array All identities for this user
821 * Example for a return:
825 * 'username' => 'maxmuster',
826 * 'nickname' => 'Max Mustermann'
830 * 'username' => 'johndoe',
831 * 'nickname' => 'John Doe'
835 public static function identities($uid)
839 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid]);
840 if (!DBA::isResult($user)) {
844 if ($user['parent-uid'] == 0) {
845 // First add our own entry
846 $identities = [['uid' => $user['uid'],
847 'username' => $user['username'],
848 'nickname' => $user['nickname']]];
850 // Then add all the children
851 $r = DBA::select('user', ['uid', 'username', 'nickname'],
852 ['parent-uid' => $user['uid'], 'account_removed' => false]);
853 if (DBA::isResult($r)) {
854 $identities = array_merge($identities, DBA::toArray($r));
857 // First entry is our parent
858 $r = DBA::select('user', ['uid', 'username', 'nickname'],
859 ['uid' => $user['parent-uid'], 'account_removed' => false]);
860 if (DBA::isResult($r)) {
861 $identities = DBA::toArray($r);
864 // Then add all siblings
865 $r = DBA::select('user', ['uid', 'username', 'nickname'],
866 ['parent-uid' => $user['parent-uid'], 'account_removed' => false]);
867 if (DBA::isResult($r)) {
868 $identities = array_merge($identities, DBA::toArray($r));
872 $r = DBA::p("SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
874 INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
875 WHERE `user`.`account_removed` = 0 AND `manage`.`uid` = ?",
878 if (DBA::isResult($r)) {
879 $identities = array_merge($identities, DBA::toArray($r));