3 * @copyright Copyright (C) 2010-2021, the Friendica project
5 * @license GNU AGPL version 3 or any later version
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as
9 * published by the Free Software Foundation, either version 3 of the
10 * License, or (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22 namespace Friendica\Model;
24 use DivineOmega\DOFileCachePSR6\CacheItemPool;
25 use DivineOmega\PasswordExposed;
28 use Friendica\Content\Pager;
29 use Friendica\Core\Hook;
30 use Friendica\Core\L10n;
31 use Friendica\Core\Logger;
32 use Friendica\Core\Protocol;
33 use Friendica\Core\System;
34 use Friendica\Core\Worker;
35 use Friendica\Database\DBA;
37 use Friendica\Security\TwoFactor\Model\AppSpecificPassword;
38 use Friendica\Network\HTTPException;
39 use Friendica\Object\Image;
40 use Friendica\Util\Crypto;
41 use Friendica\Util\DateTimeFormat;
42 use Friendica\Util\Images;
43 use Friendica\Util\Network;
44 use Friendica\Util\Proxy;
45 use Friendica\Util\Strings;
46 use Friendica\Worker\Delivery;
51 * This class handles User related functions
58 * PAGE_FLAGS_NORMAL is a typical personal profile account
59 * PAGE_FLAGS_SOAPBOX automatically approves all friend requests as Contact::SHARING, (readonly)
60 * PAGE_FLAGS_COMMUNITY automatically approves all friend requests as Contact::SHARING, but with
61 * write access to wall and comments (no email and not included in page owner's ACL lists)
62 * PAGE_FLAGS_FREELOVE automatically approves all friend requests as full friends (Contact::FRIEND).
66 const PAGE_FLAGS_NORMAL = 0;
67 const PAGE_FLAGS_SOAPBOX = 1;
68 const PAGE_FLAGS_COMMUNITY = 2;
69 const PAGE_FLAGS_FREELOVE = 3;
70 const PAGE_FLAGS_BLOG = 4;
71 const PAGE_FLAGS_PRVGROUP = 5;
79 * ACCOUNT_TYPE_PERSON - the account belongs to a person
80 * Associated page types: PAGE_FLAGS_NORMAL, PAGE_FLAGS_SOAPBOX, PAGE_FLAGS_FREELOVE
82 * ACCOUNT_TYPE_ORGANISATION - the account belongs to an organisation
83 * Associated page type: PAGE_FLAGS_SOAPBOX
85 * ACCOUNT_TYPE_NEWS - the account is a news reflector
86 * Associated page type: PAGE_FLAGS_SOAPBOX
88 * ACCOUNT_TYPE_COMMUNITY - the account is community forum
89 * Associated page types: PAGE_COMMUNITY, PAGE_FLAGS_PRVGROUP
91 * ACCOUNT_TYPE_RELAY - the account is a relay
92 * This will only be assigned to contacts, not to user accounts
95 const ACCOUNT_TYPE_PERSON = 0;
96 const ACCOUNT_TYPE_ORGANISATION = 1;
97 const ACCOUNT_TYPE_NEWS = 2;
98 const ACCOUNT_TYPE_COMMUNITY = 3;
99 const ACCOUNT_TYPE_RELAY = 4;
100 const ACCOUNT_TYPE_DELETED = 127;
105 private static $owner;
108 * Returns the numeric account type by their string
110 * @param string $accounttype as string constant
111 * @return int|null Numeric account type - or null when not set
113 public static function getAccountTypeByString(string $accounttype)
115 switch ($accounttype) {
117 return User::ACCOUNT_TYPE_PERSON;
119 return User::ACCOUNT_TYPE_ORGANISATION;
121 return User::ACCOUNT_TYPE_NEWS;
123 return User::ACCOUNT_TYPE_COMMUNITY;
131 * Fetch the system account
133 * @return array system account
135 public static function getSystemAccount()
137 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
138 if (!DBA::isResult($system)) {
139 self::createSystemAccount();
140 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
141 if (!DBA::isResult($system)) {
146 $system['sprvkey'] = $system['uprvkey'] = $system['prvkey'];
147 $system['spubkey'] = $system['upubkey'] = $system['pubkey'];
148 $system['nickname'] = $system['nick'];
149 $system['page-flags'] = User::PAGE_FLAGS_SOAPBOX;
150 $system['account-type'] = $system['contact-type'];
151 $system['guid'] = '';
152 $system['nickname'] = $system['nick'];
153 $system['pubkey'] = $system['pubkey'];
154 $system['locality'] = '';
155 $system['region'] = '';
156 $system['country-name'] = '';
157 $system['net-publish'] = false;
159 // Ensure that the user contains data
160 $user = DBA::selectFirst('user', ['prvkey', 'guid'], ['uid' => 0]);
161 if (empty($user['prvkey']) || empty($user['guid'])) {
163 'username' => $system['name'],
164 'nickname' => $system['nick'],
165 'register_date' => $system['created'],
166 'pubkey' => $system['pubkey'],
167 'prvkey' => $system['prvkey'],
168 'spubkey' => $system['spubkey'],
169 'sprvkey' => $system['sprvkey'],
170 'guid' => System::createUUID(),
172 'page-flags' => User::PAGE_FLAGS_SOAPBOX,
173 'account-type' => User::ACCOUNT_TYPE_RELAY,
176 DBA::update('user', $fields, ['uid' => 0]);
178 $system['guid'] = $fields['guid'];
180 $system['guid'] = $user['guid'];
187 * Create the system account
191 private static function createSystemAccount()
193 $system_actor_name = self::getActorName();
194 if (empty($system_actor_name)) {
198 $keys = Crypto::newKeypair(4096);
199 if ($keys === false) {
200 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
205 $system['created'] = DateTimeFormat::utcNow();
206 $system['self'] = true;
207 $system['network'] = Protocol::ACTIVITYPUB;
208 $system['name'] = 'System Account';
209 $system['addr'] = $system_actor_name . '@' . DI::baseUrl()->getHostname();
210 $system['nick'] = $system_actor_name;
211 $system['url'] = DI::baseUrl() . '/friendica';
213 $system['avatar'] = $system['photo'] = Contact::getDefaultAvatar($system, Proxy::SIZE_SMALL);
214 $system['thumb'] = Contact::getDefaultAvatar($system, Proxy::SIZE_THUMB);
215 $system['micro'] = Contact::getDefaultAvatar($system, Proxy::SIZE_MICRO);
217 $system['nurl'] = Strings::normaliseLink($system['url']);
218 $system['pubkey'] = $keys['pubkey'];
219 $system['prvkey'] = $keys['prvkey'];
220 $system['blocked'] = 0;
221 $system['pending'] = 0;
222 $system['contact-type'] = Contact::TYPE_RELAY; // In AP this is translated to 'Application'
223 $system['name-date'] = DateTimeFormat::utcNow();
224 $system['uri-date'] = DateTimeFormat::utcNow();
225 $system['avatar-date'] = DateTimeFormat::utcNow();
226 $system['closeness'] = 0;
227 $system['baseurl'] = DI::baseUrl();
228 $system['gsid'] = GServer::getID($system['baseurl']);
229 DBA::insert('contact', $system);
233 * Detect a usable actor name
235 * @return string actor account name
237 public static function getActorName()
239 $system_actor_name = DI::config()->get('system', 'actor_name');
240 if (!empty($system_actor_name)) {
241 $self = Contact::selectFirst(['nick'], ['uid' => 0, 'self' => true]);
242 if (!empty($self['nick'])) {
243 if ($self['nick'] != $system_actor_name) {
244 // Reset the actor name to the already used name
245 DI::config()->set('system', 'actor_name', $self['nick']);
246 $system_actor_name = $self['nick'];
249 return $system_actor_name;
252 // List of possible actor names
253 $possible_accounts = ['friendica', 'actor', 'system', 'internal'];
254 foreach ($possible_accounts as $name) {
255 if (!DBA::exists('user', ['nickname' => $name, 'account_removed' => false, 'expire' => false]) &&
256 !DBA::exists('userd', ['username' => $name])) {
257 DI::config()->set('system', 'actor_name', $name);
265 * Returns true if a user record exists with the provided id
267 * @param integer $uid
271 public static function exists($uid)
273 return DBA::exists('user', ['uid' => $uid]);
277 * @param integer $uid
278 * @param array $fields
279 * @return array|boolean User record if it exists, false otherwise
282 public static function getById($uid, array $fields = [])
284 return !empty($uid) ? DBA::selectFirst('user', $fields, ['uid' => $uid]) : [];
288 * Returns a user record based on it's GUID
290 * @param string $guid The guid of the user
291 * @param array $fields The fields to retrieve
292 * @param bool $active True, if only active records are searched
294 * @return array|boolean User record if it exists, false otherwise
297 public static function getByGuid(string $guid, array $fields = [], bool $active = true)
300 $cond = ['guid' => $guid, 'account_expired' => false, 'account_removed' => false];
302 $cond = ['guid' => $guid];
305 return DBA::selectFirst('user', $fields, $cond);
309 * @param string $nickname
310 * @param array $fields
311 * @return array|boolean User record if it exists, false otherwise
314 public static function getByNickname($nickname, array $fields = [])
316 return DBA::selectFirst('user', $fields, ['nickname' => $nickname]);
320 * Returns the user id of a given profile URL
324 * @return integer user id
327 public static function getIdForURL(string $url)
329 // Avoid database queries when the local node hostname isn't even part of the url.
330 if (!Contact::isLocal($url)) {
334 $self = Contact::selectFirst(['uid'], ['self' => true, 'nurl' => Strings::normaliseLink($url)]);
335 if (!empty($self['uid'])) {
339 $self = Contact::selectFirst(['uid'], ['self' => true, 'addr' => $url]);
340 if (!empty($self['uid'])) {
344 $self = Contact::selectFirst(['uid'], ['self' => true, 'alias' => [$url, Strings::normaliseLink($url)]]);
345 if (!empty($self['uid'])) {
353 * Get a user based on its email
355 * @param string $email
356 * @param array $fields
358 * @return array|boolean User record if it exists, false otherwise
362 public static function getByEmail($email, array $fields = [])
364 return DBA::selectFirst('user', $fields, ['email' => $email]);
368 * Fetch the user array of the administrator. The first one if there are several.
370 * @param array $fields
373 public static function getFirstAdmin(array $fields = [])
375 if (!empty(DI::config()->get('config', 'admin_nickname'))) {
376 return self::getByNickname(DI::config()->get('config', 'admin_nickname'), $fields);
377 } elseif (!empty(DI::config()->get('config', 'admin_email'))) {
378 $adminList = explode(',', str_replace(' ', '', DI::config()->get('config', 'admin_email')));
379 return self::getByEmail($adminList[0], $fields);
386 * Get owner data by user id
389 * @param boolean $repairMissing Repair the owner data if it's missing
390 * @return boolean|array
393 public static function getOwnerDataById(int $uid, bool $repairMissing = true)
396 return self::getSystemAccount();
399 if (!empty(self::$owner[$uid])) {
400 return self::$owner[$uid];
403 $owner = DBA::selectFirst('owner-view', [], ['uid' => $uid]);
404 if (!DBA::isResult($owner)) {
405 if (!DBA::exists('user', ['uid' => $uid]) || !$repairMissing) {
408 if (!DBA::exists('profile', ['uid' => $uid])) {
409 DBA::insert('profile', ['uid' => $uid]);
411 if (!DBA::exists('contact', ['uid' => $uid, 'self' => true])) {
412 Contact::createSelfFromUserId($uid);
414 $owner = self::getOwnerDataById($uid, false);
417 if (empty($owner['nickname'])) {
421 if (!$repairMissing || $owner['account_expired']) {
425 // Check if the returned data is valid, otherwise fix it. See issue #6122
427 // Check for correct url and normalised nurl
428 $url = DI::baseUrl() . '/profile/' . $owner['nickname'];
429 $repair = empty($owner['network']) || ($owner['url'] != $url) || ($owner['nurl'] != Strings::normaliseLink($owner['url']));
432 // Check if "addr" is present and correct
433 $addr = $owner['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3);
434 $repair = ($addr != $owner['addr']) || empty($owner['prvkey']) || empty($owner['pubkey']);
438 // Check if the avatar field is filled and the photo directs to the correct path
439 $avatar = Photo::selectFirst(['resource-id'], ['uid' => $uid, 'profile' => true]);
440 if (DBA::isResult($avatar)) {
441 $repair = empty($owner['avatar']) || !strpos($owner['photo'], $avatar['resource-id']);
446 Contact::updateSelfFromUserID($uid);
447 // Return the corrected data and avoid a loop
448 $owner = self::getOwnerDataById($uid, false);
451 self::$owner[$uid] = $owner;
456 * Get owner data by nick name
459 * @return boolean|array
462 public static function getOwnerDataByNick($nick)
464 $user = DBA::selectFirst('user', ['uid'], ['nickname' => $nick]);
466 if (!DBA::isResult($user)) {
470 return self::getOwnerDataById($user['uid']);
474 * Returns the default group for a given user and network
476 * @param int $uid User id
477 * @param string $network network name
479 * @return int group id
482 public static function getDefaultGroup($uid, $network = '')
484 $user = DBA::selectFirst('user', ['def_gid'], ['uid' => $uid]);
485 if (DBA::isResult($user)) {
486 $default_group = $user["def_gid"];
491 return $default_group;
496 * Authenticate a user with a clear text password
498 * @param mixed $user_info
499 * @param string $password
500 * @param bool $third_party
501 * @return int|boolean
502 * @deprecated since version 3.6
503 * @see User::getIdFromPasswordAuthentication()
505 public static function authenticate($user_info, $password, $third_party = false)
508 return self::getIdFromPasswordAuthentication($user_info, $password, $third_party);
509 } catch (Exception $ex) {
515 * Authenticate a user with a clear text password
517 * Returns the user id associated with a successful password authentication
519 * @param mixed $user_info
520 * @param string $password
521 * @param bool $third_party
522 * @return int User Id if authentication is successful
523 * @throws HTTPException\ForbiddenException
524 * @throws HTTPException\NotFoundException
526 public static function getIdFromPasswordAuthentication($user_info, $password, $third_party = false)
528 // Addons registered with the "authenticate" hook may create the user on the
529 // fly. `getAuthenticationInfo` will fail if the user doesn't exist yet. If
530 // the user doesn't exist, we should give the addons a chance to create the
531 // user in our database, if applicable, before re-throwing the exception if
534 $user = self::getAuthenticationInfo($user_info);
535 } catch (Exception $e) {
536 $username = (is_string($user_info) ? $user_info : $user_info['nickname'] ?? '');
538 // Addons can create users, and since this 'catch' branch should only
539 // execute if getAuthenticationInfo can't find an existing user, that's
540 // exactly what will happen here. Creating a numeric username would create
541 // abiguity with user IDs, possibly opening up an attack vector.
542 // So let's be very careful about that.
543 if (empty($username) || is_numeric($username)) {
547 return self::getIdFromAuthenticateHooks($username, $password);
550 if ($third_party && DI::pConfig()->get($user['uid'], '2fa', 'verified')) {
551 // Third-party apps can't verify two-factor authentication, we use app-specific passwords instead
552 if (AppSpecificPassword::authenticateUser($user['uid'], $password)) {
555 } elseif (strpos($user['password'], '$') === false) {
556 //Legacy hash that has not been replaced by a new hash yet
557 if (self::hashPasswordLegacy($password) === $user['password']) {
558 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
562 } elseif (!empty($user['legacy_password'])) {
563 //Legacy hash that has been double-hashed and not replaced by a new hash yet
564 //Warning: `legacy_password` is not necessary in sync with the content of `password`
565 if (password_verify(self::hashPasswordLegacy($password), $user['password'])) {
566 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
570 } elseif (password_verify($password, $user['password'])) {
572 if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
573 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
578 return self::getIdFromAuthenticateHooks($user['nickname'], $password); // throws
581 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
585 * Try to obtain a user ID via "authenticate" hook addons
587 * Returns the user id associated with a successful password authentication
589 * @param string $username
590 * @param string $password
591 * @return int User Id if authentication is successful
592 * @throws HTTPException\ForbiddenException
594 public static function getIdFromAuthenticateHooks($username, $password)
597 'username' => $username,
598 'password' => $password,
599 'authenticated' => 0,
600 'user_record' => null
604 * An addon indicates successful login by setting 'authenticated' to non-zero value and returning a user record
605 * Addons should never set 'authenticated' except to indicate success - as hooks may be chained
606 * and later addons should not interfere with an earlier one that succeeded.
608 Hook::callAll('authenticate', $addon_auth);
610 if ($addon_auth['authenticated'] && $addon_auth['user_record']) {
611 return $addon_auth['user_record']['uid'];
614 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
618 * Returns authentication info from various parameters types
620 * User info can be any of the following:
623 * - User email or username or nickname
624 * - User array with at least the uid and the hashed password
626 * @param mixed $user_info
628 * @throws HTTPException\NotFoundException
630 public static function getAuthenticationInfo($user_info)
634 if (is_object($user_info) || is_array($user_info)) {
635 if (is_object($user_info)) {
636 $user = (array) $user_info;
643 || !isset($user['password'])
644 || !isset($user['legacy_password'])
646 throw new Exception(DI::l10n()->t('Not enough information to authenticate'));
648 } elseif (is_int($user_info) || is_string($user_info)) {
649 if (is_int($user_info)) {
650 $user = DBA::selectFirst(
652 ['uid', 'nickname', 'password', 'legacy_password'],
656 'account_expired' => 0,
657 'account_removed' => 0,
662 $fields = ['uid', 'nickname', 'password', 'legacy_password'];
664 "(`email` = ? OR `username` = ? OR `nickname` = ?)
665 AND NOT `blocked` AND NOT `account_expired` AND NOT `account_removed` AND `verified`",
666 $user_info, $user_info, $user_info
668 $user = DBA::selectFirst('user', $fields, $condition);
671 if (!DBA::isResult($user)) {
672 throw new HTTPException\NotFoundException(DI::l10n()->t('User not found'));
680 * Generates a human-readable random password
685 public static function generateNewPassword()
687 return ucfirst(Strings::getRandomName(8)) . random_int(1000, 9999);
691 * Checks if the provided plaintext password has been exposed or not
693 * @param string $password
697 public static function isPasswordExposed($password)
699 $cache = new CacheItemPool();
700 $cache->changeConfig([
701 'cacheDirectory' => get_temppath() . '/password-exposed-cache/',
705 $passwordExposedChecker = new PasswordExposed\PasswordExposedChecker(null, $cache);
707 return $passwordExposedChecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED;
708 } catch (Exception $e) {
709 Logger::error('Password Exposed Exception: ' . $e->getMessage(), [
710 'code' => $e->getCode(),
711 'file' => $e->getFile(),
712 'line' => $e->getLine(),
713 'trace' => $e->getTraceAsString()
721 * Legacy hashing function, kept for password migration purposes
723 * @param string $password
726 private static function hashPasswordLegacy($password)
728 return hash('whirlpool', $password);
732 * Global user password hashing function
734 * @param string $password
738 public static function hashPassword($password)
740 if (!trim($password)) {
741 throw new Exception(DI::l10n()->t('Password can\'t be empty'));
744 return password_hash($password, PASSWORD_DEFAULT);
748 * Updates a user row with a new plaintext password
751 * @param string $password
755 public static function updatePassword($uid, $password)
757 $password = trim($password);
759 if (empty($password)) {
760 throw new Exception(DI::l10n()->t('Empty passwords are not allowed.'));
763 if (!DI::config()->get('system', 'disable_password_exposed', false) && self::isPasswordExposed($password)) {
764 throw new Exception(DI::l10n()->t('The new password has been exposed in a public data dump, please choose another.'));
767 $allowed_characters = '!"#$%&\'()*+,-./;<=>?@[\]^_`{|}~';
769 if (!preg_match('/^[a-z0-9' . preg_quote($allowed_characters, '/') . ']+$/i', $password)) {
770 throw new Exception(DI::l10n()->t('The password can\'t contain accentuated letters, white spaces or colons (:)'));
773 return self::updatePasswordHashed($uid, self::hashPassword($password));
777 * Updates a user row with a new hashed password.
778 * Empties the password reset token field just in case.
781 * @param string $pasword_hashed
785 private static function updatePasswordHashed($uid, $pasword_hashed)
788 'password' => $pasword_hashed,
790 'pwdreset_time' => null,
791 'legacy_password' => false
793 return DBA::update('user', $fields, ['uid' => $uid]);
797 * Checks if a nickname is in the list of the forbidden nicknames
799 * Check if a nickname is forbidden from registration on the node by the
800 * admin. Forbidden nicknames (e.g. role namess) can be configured in the
803 * @param string $nickname The nickname that should be checked
804 * @return boolean True is the nickname is blocked on the node
806 public static function isNicknameBlocked($nickname)
808 $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
809 if (!empty($forbidden_nicknames)) {
810 $forbidden = explode(',', $forbidden_nicknames);
811 $forbidden = array_map('trim', $forbidden);
816 // Add the name of the internal actor to the "forbidden" list
817 $actor_name = self::getActorName();
818 if (!empty($actor_name)) {
819 $forbidden[] = $actor_name;
822 if (empty($forbidden)) {
826 // check if the nickname is in the list of blocked nicknames
827 if (in_array(strtolower($nickname), $forbidden)) {
836 * Catch-all user creation function
838 * Creates a user from the provided data array, either form fields or OpenID.
839 * Required: { username, nickname, email } or { openid_url }
841 * Performs the following:
842 * - Sends to the OpenId auth URL (if relevant)
843 * - Creates new key pairs for crypto
844 * - Create self-contact
845 * - Create profile image
849 * @throws ErrorException
850 * @throws HTTPException\InternalServerErrorException
851 * @throws ImagickException
854 public static function create(array $data)
856 $return = ['user' => null, 'password' => ''];
858 $using_invites = DI::config()->get('system', 'invitation_only');
860 $invite_id = !empty($data['invite_id']) ? Strings::escapeTags(trim($data['invite_id'])) : '';
861 $username = !empty($data['username']) ? Strings::escapeTags(trim($data['username'])) : '';
862 $nickname = !empty($data['nickname']) ? Strings::escapeTags(trim($data['nickname'])) : '';
863 $email = !empty($data['email']) ? Strings::escapeTags(trim($data['email'])) : '';
864 $openid_url = !empty($data['openid_url']) ? Strings::escapeTags(trim($data['openid_url'])) : '';
865 $photo = !empty($data['photo']) ? Strings::escapeTags(trim($data['photo'])) : '';
866 $password = !empty($data['password']) ? trim($data['password']) : '';
867 $password1 = !empty($data['password1']) ? trim($data['password1']) : '';
868 $confirm = !empty($data['confirm']) ? trim($data['confirm']) : '';
869 $blocked = !empty($data['blocked']);
870 $verified = !empty($data['verified']);
871 $language = !empty($data['language']) ? Strings::escapeTags(trim($data['language'])) : 'en';
873 $netpublish = $publish = !empty($data['profile_publish_reg']);
875 if ($password1 != $confirm) {
876 throw new Exception(DI::l10n()->t('Passwords do not match. Password unchanged.'));
877 } elseif ($password1 != '') {
878 $password = $password1;
881 if ($using_invites) {
883 throw new Exception(DI::l10n()->t('An invitation is required.'));
886 if (!Register::existsByHash($invite_id)) {
887 throw new Exception(DI::l10n()->t('Invitation could not be verified.'));
891 /// @todo Check if this part is really needed. We should have fetched all this data in advance
892 if (empty($username) || empty($email) || empty($nickname)) {
894 if (!Network::isUrlValid($openid_url)) {
895 throw new Exception(DI::l10n()->t('Invalid OpenID url'));
897 $_SESSION['register'] = 1;
898 $_SESSION['openid'] = $openid_url;
900 $openid = new LightOpenID(DI::baseUrl()->getHostname());
901 $openid->identity = $openid_url;
902 $openid->returnUrl = DI::baseUrl() . '/openid';
903 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
904 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
906 $authurl = $openid->authUrl();
907 } catch (Exception $e) {
908 throw new Exception(DI::l10n()->t('We encountered a problem while logging in with the OpenID you provided. Please check the correct spelling of the ID.') . EOL . EOL . DI::l10n()->t('The error message was:') . $e->getMessage(), 0, $e);
910 System::externalRedirect($authurl);
914 throw new Exception(DI::l10n()->t('Please enter the required information.'));
917 if (!Network::isUrlValid($openid_url)) {
921 // collapse multiple spaces in name
922 $username = preg_replace('/ +/', ' ', $username);
924 $username_min_length = max(1, min(64, intval(DI::config()->get('system', 'username_min_length', 3))));
925 $username_max_length = max(1, min(64, intval(DI::config()->get('system', 'username_max_length', 48))));
927 if ($username_min_length > $username_max_length) {
928 Logger::log(DI::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);
929 $tmp = $username_min_length;
930 $username_min_length = $username_max_length;
931 $username_max_length = $tmp;
934 if (mb_strlen($username) < $username_min_length) {
935 throw new Exception(DI::l10n()->tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
938 if (mb_strlen($username) > $username_max_length) {
939 throw new Exception(DI::l10n()->tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
942 // So now we are just looking for a space in the full name.
943 $loose_reg = DI::config()->get('system', 'no_regfullname');
945 $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
946 if (strpos($username, ' ') === false) {
947 throw new Exception(DI::l10n()->t("That doesn't appear to be your full (First Last) name."));
951 if (!Network::isEmailDomainAllowed($email)) {
952 throw new Exception(DI::l10n()->t('Your email domain is not among those allowed on this site.'));
955 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
956 throw new Exception(DI::l10n()->t('Not a valid email address.'));
958 if (self::isNicknameBlocked($nickname)) {
959 throw new Exception(DI::l10n()->t('The nickname was blocked from registration by the nodes admin.'));
962 if (DI::config()->get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
963 throw new Exception(DI::l10n()->t('Cannot use that email.'));
966 // Disallow somebody creating an account using openid that uses the admin email address,
967 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
968 if (DI::config()->get('config', 'admin_email') && strlen($openid_url)) {
969 $adminlist = explode(',', str_replace(' ', '', strtolower(DI::config()->get('config', 'admin_email'))));
970 if (in_array(strtolower($email), $adminlist)) {
971 throw new Exception(DI::l10n()->t('Cannot use that email.'));
975 $nickname = $data['nickname'] = strtolower($nickname);
977 if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) {
978 throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.'));
981 // Check existing and deleted accounts for this nickname.
983 DBA::exists('user', ['nickname' => $nickname])
984 || DBA::exists('userd', ['username' => $nickname])
986 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
989 $new_password = strlen($password) ? $password : User::generateNewPassword();
990 $new_password_encoded = self::hashPassword($new_password);
992 $return['password'] = $new_password;
994 $keys = Crypto::newKeypair(4096);
995 if ($keys === false) {
996 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
999 $prvkey = $keys['prvkey'];
1000 $pubkey = $keys['pubkey'];
1002 // Create another keypair for signing/verifying salmon protocol messages.
1003 $sres = Crypto::newKeypair(512);
1004 $sprvkey = $sres['prvkey'];
1005 $spubkey = $sres['pubkey'];
1007 $insert_result = DBA::insert('user', [
1008 'guid' => System::createUUID(),
1009 'username' => $username,
1010 'password' => $new_password_encoded,
1012 'openid' => $openid_url,
1013 'nickname' => $nickname,
1014 'pubkey' => $pubkey,
1015 'prvkey' => $prvkey,
1016 'spubkey' => $spubkey,
1017 'sprvkey' => $sprvkey,
1018 'verified' => $verified,
1019 'blocked' => $blocked,
1020 'language' => $language,
1021 'timezone' => 'UTC',
1022 'register_date' => DateTimeFormat::utcNow(),
1023 'default-location' => ''
1026 if ($insert_result) {
1027 $uid = DBA::lastInsertId();
1028 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1030 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1034 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1037 // if somebody clicked submit twice very quickly, they could end up with two accounts
1038 // due to race condition. Remove this one.
1039 $user_count = DBA::count('user', ['nickname' => $nickname]);
1040 if ($user_count > 1) {
1041 DBA::delete('user', ['uid' => $uid]);
1043 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1046 $insert_result = DBA::insert('profile', [
1048 'name' => $username,
1049 'photo' => DI::baseUrl() . "/photo/profile/{$uid}.jpg",
1050 'thumb' => DI::baseUrl() . "/photo/avatar/{$uid}.jpg",
1051 'publish' => $publish,
1052 'net-publish' => $netpublish,
1054 if (!$insert_result) {
1055 DBA::delete('user', ['uid' => $uid]);
1057 throw new Exception(DI::l10n()->t('An error occurred creating your default profile. Please try again.'));
1060 // Create the self contact
1061 if (!Contact::createSelfFromUserId($uid)) {
1062 DBA::delete('user', ['uid' => $uid]);
1064 throw new Exception(DI::l10n()->t('An error occurred creating your self contact. Please try again.'));
1067 // Create a group with no members. This allows somebody to use it
1068 // right away as a default group for new contacts.
1069 $def_gid = Group::create($uid, DI::l10n()->t('Friends'));
1071 DBA::delete('user', ['uid' => $uid]);
1073 throw new Exception(DI::l10n()->t('An error occurred creating your default contact group. Please try again.'));
1076 $fields = ['def_gid' => $def_gid];
1077 if (DI::config()->get('system', 'newuser_private') && $def_gid) {
1078 $fields['allow_gid'] = '<' . $def_gid . '>';
1081 DBA::update('user', $fields, ['uid' => $uid]);
1083 // if we have no OpenID photo try to look up an avatar
1084 if (!strlen($photo)) {
1085 $photo = Network::lookupAvatarByEmail($email);
1088 // unless there is no avatar-addon loaded
1089 if (strlen($photo)) {
1090 $photo_failure = false;
1092 $filename = basename($photo);
1093 $curlResult = DI::httpRequest()->get($photo);
1094 if ($curlResult->isSuccess()) {
1095 $img_str = $curlResult->getBody();
1096 $type = $curlResult->getContentType();
1102 $type = Images::getMimeTypeByData($img_str, $photo, $type);
1104 $Image = new Image($img_str, $type);
1105 if ($Image->isValid()) {
1106 $Image->scaleToSquare(300);
1108 $resource_id = Photo::newResource();
1110 $r = Photo::store($Image, $uid, 0, $resource_id, $filename, DI::l10n()->t('Profile Photos'), 4);
1113 $photo_failure = true;
1116 $Image->scaleDown(80);
1118 $r = Photo::store($Image, $uid, 0, $resource_id, $filename, DI::l10n()->t('Profile Photos'), 5);
1121 $photo_failure = true;
1124 $Image->scaleDown(48);
1126 $r = Photo::store($Image, $uid, 0, $resource_id, $filename, DI::l10n()->t('Profile Photos'), 6);
1129 $photo_failure = true;
1132 if (!$photo_failure) {
1133 Photo::update(['profile' => 1], ['resource-id' => $resource_id]);
1137 Contact::updateSelfFromUserID($uid, true);
1140 Hook::callAll('register_account', $uid);
1142 $return['user'] = $user;
1147 * Update a user entry and distribute the changes if needed
1149 * @param array $fields
1150 * @param integer $uid
1153 public static function update(array $fields, int $uid): bool
1155 $old_owner = self::getOwnerDataById($uid);
1156 if (empty($old_owner)) {
1160 if (!DBA::update('user', $fields, ['uid' => $uid])) {
1164 $update = Contact::updateSelfFromUserID($uid);
1166 $owner = self::getOwnerDataById($uid);
1167 if (empty($owner)) {
1171 if ($old_owner['name'] != $owner['name']) {
1172 Profile::update(['name' => $owner['name']], $uid);
1176 Profile::publishUpdate($uid);
1183 * Sets block state for a given user
1185 * @param int $uid The user id
1186 * @param bool $block Block state (default is true)
1188 * @return bool True, if successfully blocked
1192 public static function block(int $uid, bool $block = true)
1194 return DBA::update('user', ['blocked' => $block], ['uid' => $uid]);
1198 * Allows a registration based on a hash
1200 * @param string $hash
1202 * @return bool True, if the allow was successful
1204 * @throws HTTPException\InternalServerErrorException
1207 public static function allow(string $hash)
1209 $register = Register::getByHash($hash);
1210 if (!DBA::isResult($register)) {
1214 $user = User::getById($register['uid']);
1215 if (!DBA::isResult($user)) {
1219 Register::deleteByHash($hash);
1221 DBA::update('user', ['blocked' => false, 'verified' => true], ['uid' => $register['uid']]);
1223 $profile = DBA::selectFirst('profile', ['net-publish'], ['uid' => $register['uid']]);
1225 if (DBA::isResult($profile) && $profile['net-publish'] && DI::config()->get('system', 'directory')) {
1226 $url = DI::baseUrl() . '/profile/' . $user['nickname'];
1227 Worker::add(PRIORITY_LOW, "Directory", $url);
1230 $l10n = DI::l10n()->withLang($register['language']);
1232 return User::sendRegisterOpenEmail(
1235 DI::config()->get('config', 'sitename'),
1236 DI::baseUrl()->get(),
1237 ($register['password'] ?? '') ?: 'Sent in a previous email'
1242 * Denys a pending registration
1244 * @param string $hash The hash of the pending user
1246 * This does not have to go through user_remove() and save the nickname
1247 * permanently against re-registration, as the person was not yet
1248 * allowed to have friends on this system
1250 * @return bool True, if the deny was successfull
1253 public static function deny(string $hash)
1255 $register = Register::getByHash($hash);
1256 if (!DBA::isResult($register)) {
1260 $user = User::getById($register['uid']);
1261 if (!DBA::isResult($user)) {
1265 // Delete the avatar
1266 Photo::delete(['uid' => $register['uid']]);
1268 return DBA::delete('user', ['uid' => $register['uid']]) &&
1269 Register::deleteByHash($register['hash']);
1273 * Creates a new user based on a minimal set and sends an email to this user
1275 * @param string $name The user's name
1276 * @param string $email The user's email address
1277 * @param string $nick The user's nick name
1278 * @param string $lang The user's language (default is english)
1280 * @return bool True, if the user was created successfully
1281 * @throws HTTPException\InternalServerErrorException
1282 * @throws ErrorException
1283 * @throws ImagickException
1285 public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT)
1290 throw new HTTPException\InternalServerErrorException('Invalid arguments.');
1293 $result = self::create([
1294 'username' => $name,
1296 'nickname' => $nick,
1301 $user = $result['user'];
1302 $preamble = Strings::deindent(DI::l10n()->t('
1304 the administrator of %2$s has set up an account for you.'));
1305 $body = Strings::deindent(DI::l10n()->t('
1306 The login details are as follows:
1312 You may change your password from your account "Settings" page after logging
1315 Please take a few moments to review the other account settings on that page.
1317 You may also wish to add some basic information to your default profile
1318 (on the "Profiles" page) so that other people can easily find you.
1320 We recommend setting your full name, adding a profile photo,
1321 adding some profile "keywords" (very useful in making new friends) - and
1322 perhaps what country you live in; if you do not wish to be more specific
1325 We fully respect your right to privacy, and none of these items are necessary.
1326 If you are new and do not know anybody here, they may help
1327 you to make some new and interesting friends.
1329 If you ever want to delete your account, you can do so at %1$s/removeme
1331 Thank you and welcome to %4$s.'));
1333 $preamble = sprintf($preamble, $user['username'], DI::config()->get('config', 'sitename'));
1334 $body = sprintf($body, DI::baseUrl()->get(), $user['nickname'], $result['password'], DI::config()->get('config', 'sitename'));
1336 $email = DI::emailer()
1338 ->withMessage(DI::l10n()->t('Registration details for %s', DI::config()->get('config', 'sitename')), $preamble, $body)
1340 ->withRecipient($user['email'])
1342 return DI::emailer()->send($email);
1346 * Sends pending registration confirmation email
1348 * @param array $user User record array
1349 * @param string $sitename
1350 * @param string $siteurl
1351 * @param string $password Plaintext password
1352 * @return NULL|boolean from notification() and email() inherited
1353 * @throws HTTPException\InternalServerErrorException
1355 public static function sendRegisterPendingEmail($user, $sitename, $siteurl, $password)
1357 $body = Strings::deindent(DI::l10n()->t(
1360 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
1362 Your login details are as follows:
1375 $email = DI::emailer()
1377 ->withMessage(DI::l10n()->t('Registration at %s', $sitename), $body)
1379 ->withRecipient($user['email'])
1381 return DI::emailer()->send($email);
1385 * Sends registration confirmation
1387 * It's here as a function because the mail is sent from different parts
1389 * @param L10n $l10n The used language
1390 * @param array $user User record array
1391 * @param string $sitename
1392 * @param string $siteurl
1393 * @param string $password Plaintext password
1395 * @return NULL|boolean from notification() and email() inherited
1396 * @throws HTTPException\InternalServerErrorException
1398 public static function sendRegisterOpenEmail(L10n $l10n, $user, $sitename, $siteurl, $password)
1400 $preamble = Strings::deindent($l10n->t(
1403 Thank you for registering at %2$s. Your account has been created.
1408 $body = Strings::deindent($l10n->t(
1410 The login details are as follows:
1416 You may change your password from your account "Settings" page after logging
1419 Please take a few moments to review the other account settings on that page.
1421 You may also wish to add some basic information to your default profile
1422 ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
1424 We recommend setting your full name, adding a profile photo,
1425 adding some profile "keywords" ' . "\x28" . 'very useful in making new friends' . "\x29" . ' - and
1426 perhaps what country you live in; if you do not wish to be more specific
1429 We fully respect your right to privacy, and none of these items are necessary.
1430 If you are new and do not know anybody here, they may help
1431 you to make some new and interesting friends.
1433 If you ever want to delete your account, you can do so at %3$s/removeme
1435 Thank you and welcome to %2$s.',
1443 $email = DI::emailer()
1445 ->withMessage(DI::l10n()->t('Registration details for %s', $sitename), $preamble, $body)
1447 ->withRecipient($user['email'])
1449 return DI::emailer()->send($email);
1453 * @param int $uid user to remove
1455 * @throws HTTPException\InternalServerErrorException
1457 public static function remove(int $uid)
1463 Logger::log('Removing user: ' . $uid);
1465 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1467 Hook::callAll('remove_user', $user);
1469 // save username (actually the nickname as it is guaranteed
1470 // unique), so it cannot be re-registered in the future.
1471 DBA::insert('userd', ['username' => $user['nickname']]);
1473 // Remove all personal settings, especially connector settings
1474 DBA::delete('pconfig', ['uid' => $uid]);
1476 // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers
1477 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
1478 Worker::add(PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid);
1480 // Send an update to the directory
1481 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
1482 Worker::add(PRIORITY_LOW, 'Directory', $self['url']);
1484 // Remove the user relevant data
1485 Worker::add(PRIORITY_NEGLIGIBLE, 'RemoveUser', $uid);
1491 * Return all identities to a user
1493 * @param int $uid The user id
1494 * @return array All identities for this user
1496 * Example for a return:
1500 * 'username' => 'maxmuster',
1501 * 'nickname' => 'Max Mustermann'
1505 * 'username' => 'johndoe',
1506 * 'nickname' => 'John Doe'
1511 public static function identities($uid)
1519 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid]);
1520 if (!DBA::isResult($user)) {
1524 if ($user['parent-uid'] == 0) {
1525 // First add our own entry
1527 'uid' => $user['uid'],
1528 'username' => $user['username'],
1529 'nickname' => $user['nickname']
1532 // Then add all the children
1535 ['uid', 'username', 'nickname'],
1536 ['parent-uid' => $user['uid'], 'account_removed' => false]
1538 if (DBA::isResult($r)) {
1539 $identities = array_merge($identities, DBA::toArray($r));
1542 // First entry is our parent
1545 ['uid', 'username', 'nickname'],
1546 ['uid' => $user['parent-uid'], 'account_removed' => false]
1548 if (DBA::isResult($r)) {
1549 $identities = DBA::toArray($r);
1552 // Then add all siblings
1555 ['uid', 'username', 'nickname'],
1556 ['parent-uid' => $user['parent-uid'], 'account_removed' => false]
1558 if (DBA::isResult($r)) {
1559 $identities = array_merge($identities, DBA::toArray($r));
1564 "SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
1566 INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
1567 WHERE `user`.`account_removed` = 0 AND `manage`.`uid` = ?",
1570 if (DBA::isResult($r)) {
1571 $identities = array_merge($identities, DBA::toArray($r));
1578 * Check if the given user id has delegations or is delegated
1583 public static function hasIdentities(int $uid):bool
1589 $user = DBA::selectFirst('user', ['parent-uid'], ['uid' => $uid, 'account_removed' => false]);
1590 if (!DBA::isResult($user)) {
1594 if ($user['parent-uid'] != 0) {
1598 if (DBA::exists('user', ['parent-uid' => $uid, 'account_removed' => false])) {
1602 if (DBA::exists('manage', ['uid' => $uid])) {
1610 * Returns statistical information about the current users of this node
1616 public static function getStatistics()
1620 'active_users_halfyear' => 0,
1621 'active_users_monthly' => 0,
1622 'active_users_weekly' => 0,
1625 $userStmt = DBA::select('owner-view', ['uid', 'login_date', 'last-item'],
1626 ["`verified` AND `login_date` > ? AND NOT `blocked`
1627 AND NOT `account_removed` AND NOT `account_expired`",
1628 DBA::NULL_DATETIME]);
1629 if (!DBA::isResult($userStmt)) {
1633 $halfyear = time() - (180 * 24 * 60 * 60);
1634 $month = time() - (30 * 24 * 60 * 60);
1635 $week = time() - (7 * 24 * 60 * 60);
1637 while ($user = DBA::fetch($userStmt)) {
1638 $statistics['total_users']++;
1640 if ((strtotime($user['login_date']) > $halfyear) || (strtotime($user['last-item']) > $halfyear)
1642 $statistics['active_users_halfyear']++;
1645 if ((strtotime($user['login_date']) > $month) || (strtotime($user['last-item']) > $month)
1647 $statistics['active_users_monthly']++;
1650 if ((strtotime($user['login_date']) > $week) || (strtotime($user['last-item']) > $week)
1652 $statistics['active_users_weekly']++;
1655 DBA::close($userStmt);
1661 * Get all users of the current node
1663 * @param int $start Start count (Default is 0)
1664 * @param int $count Count of the items per page (Default is @see Pager::ITEMS_PER_PAGE)
1665 * @param string $type The type of users, which should get (all, bocked, removed)
1666 * @param string $order Order of the user list (Default is 'contact.name')
1667 * @param bool $descending Order direction (Default is ascending)
1669 * @return array The list of the users
1672 public static function getList($start = 0, $count = Pager::ITEMS_PER_PAGE, $type = 'all', $order = 'name', bool $descending = false)
1674 $param = ['limit' => [$start, $count], 'order' => [$order => $descending]];
1678 $condition['account_removed'] = false;
1679 $condition['blocked'] = false;
1682 $condition['account_removed'] = false;
1683 $condition['blocked'] = true;
1684 $condition['verified'] = true;
1687 $condition['account_removed'] = true;
1691 return DBA::selectToArray('owner-view', [], $condition, $param);