elabftw/elabftw

View on GitHub
src/models/Users.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

declare(strict_types=1);

namespace Elabftw\Models;

use Elabftw\AuditEvent\PasswordChanged;
use Elabftw\AuditEvent\UserAttributeChanged;
use Elabftw\AuditEvent\UserRegister;
use Elabftw\Auth\Local;
use Elabftw\Elabftw\App;
use Elabftw\Elabftw\Db;
use Elabftw\Elabftw\Tools;
use Elabftw\Elabftw\UserParams;
use Elabftw\Enums\Action;
use Elabftw\Enums\BasePermissions;
use Elabftw\Enums\State;
use Elabftw\Enums\Usergroup;
use Elabftw\Exceptions\IllegalActionException;
use Elabftw\Exceptions\ImproperActionException;
use Elabftw\Exceptions\InvalidCredentialsException;
use Elabftw\Exceptions\ResourceNotFoundException;
use Elabftw\Interfaces\RestInterface;
use Elabftw\Models\Notifications\OnboardingEmail;
use Elabftw\Models\Notifications\SelfIsValidated;
use Elabftw\Models\Notifications\SelfNeedValidation;
use Elabftw\Models\Notifications\UserCreated;
use Elabftw\Models\Notifications\UserNeedValidation;
use Elabftw\Services\EmailValidator;
use Elabftw\Services\Filter;
use Elabftw\Services\MfaHelper;
use Elabftw\Services\TeamsHelper;
use Elabftw\Services\UserArchiver;
use Elabftw\Services\UserCreator;
use Elabftw\Services\UsersHelper;
use PDO;
use Symfony\Component\HttpFoundation\Request;

use function time;
use function trim;

/**
 * Users
 */
class Users implements RestInterface
{
    public bool $needValidation = false;

    public array $userData = array();

    public int $team = 0;

    public self $requester;

    public bool $isAdmin = false;

    protected Db $Db;

    public function __construct(public ?int $userid = null, ?int $team = null, ?self $requester = null)
    {
        $this->Db = Db::getConnection();
        if ($team !== null && $userid !== null) {
            $this->team = $team;
            $TeamsHelper = new TeamsHelper($team);
            $this->isAdmin = $TeamsHelper->isAdmin($userid);
        }
        if ($userid !== null) {
            $this->readOneFull();
        }
        $this->requester = $requester === null ? $this : $requester;
    }

    /**
     * Create a new user
     */
    public function createOne(
        string $email,
        array $teams,
        string $firstname = '',
        string $lastname = '',
        string $passwordHash = '',
        ?Usergroup $usergroup = null,
        bool $automaticValidationEnabled = false,
        bool $alertAdmin = true,
        ?string $validUntil = null,
        ?string $orgid = null,
    ): int {
        $Config = Config::getConfig();
        $Teams = new Teams($this);

        // make sure that all the teams in which the user will be are created/exist
        // this might throw an exception if the team doesn't exist and we can't create it on the fly
        $teams = $Teams->getTeamsFromIdOrNameOrOrgidArray($teams);
        $TeamsHelper = new TeamsHelper($teams[0]['id']);

        $EmailValidator = new EmailValidator($email, $Config->configArr['email_domain']);
        $EmailValidator->validate();

        $firstname = trim($firstname);
        $lastname = trim($lastname);

        // Registration date is stored in epoch
        $registerDate = time();

        // get the user group for the new users
        $usergroup ??= $TeamsHelper->getGroup();

        $isSysadmin = $usergroup === Usergroup::Sysadmin;

        // is user validated automatically (true) or by an admin (false)?
        $isValidated = $automaticValidationEnabled || !$Config->configArr['admin_validate'] || $usergroup !== Usergroup::User;

        $defaultRead = BasePermissions::Team->toJson();
        $defaultWrite = BasePermissions::User->toJson();

        $sql = 'INSERT INTO users (
            `email`,
            `password_hash`,
            `firstname`,
            `lastname`,
            `register_date`,
            `validated`,
            `lang`,
            `valid_until`,
            `orgid`,
            `is_sysadmin`,
            `default_read`,
            `default_write`,
            `last_seen_version`
        ) VALUES (
            :email,
            :password_hash,
            :firstname,
            :lastname,
            :register_date,
            :validated,
            :lang,
            :valid_until,
            :orgid,
            :is_sysadmin,
            :default_read,
            :default_write,
            :last_seen_version);';
        $req = $this->Db->prepare($sql);

        $req->bindParam(':email', $email);
        $req->bindParam(':password_hash', $passwordHash);
        $req->bindParam(':firstname', $firstname);
        $req->bindParam(':lastname', $lastname);
        $req->bindParam(':register_date', $registerDate);
        $req->bindValue(':validated', $isValidated, PDO::PARAM_INT);
        $req->bindValue(':lang', $Config->configArr['lang']);
        $req->bindValue(':valid_until', $validUntil);
        $req->bindValue(':orgid', $orgid);
        $req->bindValue(':is_sysadmin', $isSysadmin, PDO::PARAM_INT);
        $req->bindValue(':default_read', $defaultRead);
        $req->bindValue(':default_write', $defaultWrite);
        $req->bindValue(':last_seen_version', App::INSTALLED_VERSION_INT);
        $this->Db->execute($req);
        $userid = $this->Db->lastInsertId();

        // check if the team is empty before adding the user to the team
        $isFirstUser = $TeamsHelper->isFirstUserInTeam();
        // now add the user to the team
        $Users2Teams = new Users2Teams($this->requester);
        // only send onboarding emails for new teams when user is validated
        if ($isValidated) {
            // do we send an email for the instance
            if ($Config->configArr['onboarding_email_active'] === '1') {
                $isAdmin = $usergroup === Usergroup::Admin || $usergroup === Usergroup::Sysadmin;
                (new OnboardingEmail(-1, $isAdmin))->create($userid);
            }
            // send email for each team
            $Users2Teams->sendOnboardingEmailOfTeams = true;
        }
        $Users2Teams->addUserToTeams(
            $userid,
            array_column($teams, 'id'),
            // transform Sysadmin to Admin because users2teams.groups_id is 2 (Admin) or 4 (User), but never 1 (Sysadmin)
            $usergroup === Usergroup::Sysadmin
                ? Usergroup::Admin
                : $usergroup,
        );
        if ($alertAdmin && !$isFirstUser) {
            $this->notifyAdmins($TeamsHelper->getAllAdminsUserid(), $userid, $isValidated, $teams[0]['name']);
        }
        if (!$isValidated) {
            $Notifications = new SelfNeedValidation();
            $Notifications->create($userid);
            // set a flag to show correct message to user
            $this->needValidation = true;
        }
        AuditLogs::create(new UserRegister($this->requester->userid ?? 0, $userid));
        return $userid;
    }

    /**
     * Search users based on query. It searches in email, firstname, lastname
     *
     * @param string $query the searched term
     * @param int $teamId limit search to a given team or search all teams if 0
     */
    public function readFromQuery(
        string $query,
        int $teamId = 0,
        bool $includeArchived = false,
        bool $onlyAdmins = false,
    ): array {
        $teamFilterSql = '';
        if ($teamId > 0) {
            $teamFilterSql = ' AND users2teams.teams_id = :team';
        }

        // Assures to get every user only once
        $tmpTable = ' (SELECT users_id, MIN(teams_id) AS teams_id, MIN(groups_id) AS groups_id
            FROM users2teams
            GROUP BY users_id) AS';
        // unless we use a specific team
        if ($teamId > 0) {
            $tmpTable = '';
        }

        $archived = '';
        if ($includeArchived) {
            $archived = ' OR users.archived = 1';
        }

        $admins = '';
        if ($onlyAdmins) {
            $admins = sprintf(' AND users2teams.groups_id = %d', Usergroup::Admin->value);
        }

        // NOTE: $tmpTable avoids the use of DISTINCT, so we are able to use ORDER BY with teams_id.
        // Side effect: User is shown in team with lowest id
        $sql = "SELECT users.userid,
            users.firstname, users.lastname, users.orgid, users.email, users.mfa_secret IS NOT NULL AS has_mfa_enabled,
            users.validated, users.archived, users.last_login, users.valid_until, users.is_sysadmin,
            CONCAT(users.firstname, ' ', users.lastname) AS fullname,
            users.orcid, users.auth_service, sig_keys.pubkey AS sig_pubkey
            FROM users
            LEFT JOIN sig_keys ON (sig_keys.userid = users.userid AND state = :state)
            CROSS JOIN" . $tmpTable . ' users2teams ON (users2teams.users_id = users.userid' . $teamFilterSql . $admins . ')
            WHERE (users.email LIKE :query OR users.firstname LIKE :query OR users.lastname LIKE :query)
            AND (users.archived = 0' . $archived . ')
            ORDER BY users2teams.teams_id ASC, users.lastname ASC';
        $req = $this->Db->prepare($sql);
        $req->bindValue(':query', '%' . $query . '%');
        $req->bindValue(':state', State::Normal->value, PDO::PARAM_INT);
        if ($teamId > 0) {
            $req->bindValue(':team', $teamId);
        }
        $this->Db->execute($req);

        return $req->fetchAll();
    }

    /**
     * Read all users from the team
     */
    public function readAllFromTeam(): array
    {
        return $this->readFromQuery('', $this->userData['team'], true);
    }

    public function readAllActiveFromTeam(): array
    {
        return array_filter(
            $this->readAllFromTeam(),
            fn($u): bool => $u['archived'] === 0,
        );
    }

    /**
     * This can be called from api and only contains "safe" values
     */
    public function readAll(): array
    {
        $Request = Request::createFromGlobals();
        return $this->readFromQuery(
            $Request->query->getAlnum('q'),
            0,
            $Request->query->getBoolean('includeArchived'),
            $Request->query->getBoolean('onlyAdmins'),
        );
    }

    /**
     * This can be called from api and only contains "safe" values
     */
    public function readOne(): array
    {
        $this->canReadOrExplode();
        $userData = $this->readOneFull();
        unset($userData['password']);
        unset($userData['password_hash']);
        unset($userData['salt']);
        unset($userData['mfa_secret']);
        unset($userData['token']);
        // keep sig_privkey in response if requester is target
        if ($this->requester->userData['userid'] !== $this->userData['userid']) {
            unset($userData['sig_privkey']);
        }
        return $userData;
    }

    public function readNamesFromIds(array $idArr): array
    {
        if (empty($idArr)) {
            return array();
        }
        $sql = "SELECT CONCAT(users.firstname, ' ', users.lastname) AS fullname, userid, email FROM users WHERE userid IN (" . implode(',', $idArr) . ') ORDER BY fullname ASC';
        $req = $this->Db->prepare($sql);
        $this->Db->execute($req);

        return $req->fetchAll();
    }

    public function isAdminSomewhere(): bool
    {
        $sql = sprintf(
            'SELECT users_id FROM users2teams WHERE users_id = :userid AND groups_id <= %d',
            Usergroup::Admin->value,
        );
        $req = $this->Db->prepare($sql);
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
        $this->Db->execute($req);
        return $req->rowCount() >= 1;
    }

    public function postAction(Action $action, array $reqBody): int
    {
        $Creator = new UserCreator($this->requester, $reqBody);
        return $Creator->create();
    }

    public function patch(Action $action, array $params): array
    {
        $this->canWriteOrExplode($action);
        match ($action) {
            Action::Add => (
                function () use ($params) {
                    // check instance config if admins are allowed to do that (if requester is not sysadmin)
                    $Config = Config::getConfig();
                    if ($this->requester->userData['is_sysadmin'] !== 1 && $Config->configArr['admins_import_users'] !== '1') {
                        throw new IllegalActionException('A non sysadmin user tried to import a user but admins_import_users is disabled in config.');
                    }
                    // need to be admin to "import" a user in a team
                    $team = (int) $params['team'];
                    $TeamsHelper = new TeamsHelper($team);
                    $isAdmin = $TeamsHelper->isAdmin($this->requester->userData['userid']);
                    if ($isAdmin === false && $this->requester->userData['is_sysadmin'] !== 1) {
                        throw new IllegalActionException('Only Admin can add a user to a team (where they are Admin)');
                    }
                    $Users2Teams = new Users2Teams($this->requester);
                    if ($this->userData['validated']) {
                        $Users2Teams->sendOnboardingEmailOfTeams = true;
                    }
                    $Users2Teams->create($this->userData['userid'], $team);
                }
            )(),
            Action::Disable2fa => $this->disable2fa(),
            Action::PatchUser2Team => (new Users2Teams($this->requester))->PatchUser2Team($params),
            Action::Unreference => (new Users2Teams($this->requester))->destroy($this->userData['userid'], (int) $params['team']),
            Action::Lock, Action::Archive => (new UserArchiver($this->requester, $this))->toggleArchive((bool) $params['with_exp']),
            Action::UpdatePassword => $this->updatePassword($params),
            Action::Update => (
                function () use ($params) {
                    foreach ($params as $target => $content) {
                        $this->update(new UserParams($target, (string) $content));
                    }
                }
            )(),
            Action::Validate => $this->validate(),
            default => throw new ImproperActionException('Invalid action parameter.'),
        };
        return $this->readOne();
    }

    public function getPage(): string
    {
        return 'api/v2/users/';
    }

    /**
     * Invalidate token on logout action
     */
    public function invalidateToken(): bool
    {
        $sql = 'UPDATE users SET token = null WHERE userid = :userid';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
        return $this->Db->execute($req);
    }

    public function allowUntrustedLogin(): bool
    {
        $sql = 'SELECT allow_untrusted, auth_lock_time > (NOW() - INTERVAL 1 HOUR) AS currently_locked FROM users WHERE userid = :userid';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
        $req->execute();
        $res = $req->fetch();

        if ($res['allow_untrusted'] === 1) {
            return true;
        }
        // check for the time when it was locked
        return $res['currently_locked'] === 0;
    }

    /**
     * Destroy user. Will completely remove everything from the user.
     */
    public function destroy(): bool
    {
        $this->canWriteOrExplode();

        $UsersHelper = new UsersHelper($this->userData['userid']);
        if ($UsersHelper->cannotBeDeleted()) {
            throw new ImproperActionException('Cannot delete a user that owns experiments, items, comments, templates or uploads!');
        }

        // Due to the InnoDB cascading actions of the foreign key constraints the deletion will also happen in the other tables
        $sql = 'DELETE FROM users WHERE userid = :userid';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
        return $this->Db->execute($req);
    }

    /**
     * Check if this instance's user is admin of the userid in function argument
     */
    public function isAdminOf(int $userid): bool
    {
        // consider that we are admin of ourselves
        if ($this->userid === $userid) {
            return true;
        }
        // check if in the teams we have in common, the potential admin is admin
        $sql = sprintf(
            'SELECT *
                FROM users2teams u1
                INNER JOIN users2teams u2
                    ON (u1.teams_id = u2.teams_id)
                WHERE u1.users_id = :admin_userid
                    AND u2.users_id = :user_userid
                    AND u1.groups_id <= %d',
            Usergroup::Admin->value,
        );
        $req = $this->Db->prepare($sql);
        $req->bindParam(':admin_userid', $this->userid, PDO::PARAM_INT);
        $req->bindParam(':user_userid', $userid, PDO::PARAM_INT);
        $req->execute();
        return $req->rowCount() >= 1;
    }

    /**
     * For when password must be different than older one
     * Here the happy path is in the catch... Not great, not terrible...
     */
    public function requireResetPassword(string $password): bool
    {
        $LocalAuth = new Local($this->userData['email'], $password);
        try {
            $LocalAuth->tryAuth();
        } catch (InvalidCredentialsException) {
            return $this->updatePassword(array('password' => $password), true);
        }
        throw new ImproperActionException(_('New password must not be the same as the current one.'));
    }

    /**
     * This function allows us to set a new password without having to provide the old password
     */
    public function resetPassword(string $password): bool
    {
        return $this->updatePassword(array('password' => $password), true);
    }

    public function checkCurrentPasswordOrExplode(?string $currentPassword): void
    {
        if (empty($currentPassword)) {
            throw new ImproperActionException('Current password must be provided by "current_password" parameter.');
        }
        $LocalAuth = new Local($this->userData['email'], $currentPassword);
        try {
            $LocalAuth->tryAuth();
        } catch (InvalidCredentialsException) {
            throw new ImproperActionException('The current password is not valid!');
        }
    }

    protected static function search(string $column, string $term, bool $validated = false): self
    {
        $Db = Db::getConnection();
        $sql = sprintf(
            'SELECT userid FROM users WHERE %s = :term AND archived = 0 %s LIMIT 1',
            $column === 'orgid'
                ? 'orgid'
                : 'email',
            $validated
                ? 'AND validated = 1'
                : '',
        );
        $req = $Db->prepare($sql);
        $req->bindParam(':term', $term);
        $Db->execute($req);
        $res = (int) $req->fetchColumn();
        if ($res === 0) {
            throw new ResourceNotFoundException();
        }
        return new self($res);
    }

    private function disable2fa(): array
    {
        // only sysadmin or same user can disable 2fa
        if ($this->requester->userData['userid'] === $this->userData['userid'] || $this->requester->userData['is_sysadmin'] === 1) {
            (new MfaHelper($this->userData['userid']))->removeSecret();
            return $this->readOne();
        }
        throw new IllegalActionException('User tried to disable 2fa but is not sysadmin or same user.');
    }

    private function canReadOrExplode(): void
    {
        // it's ourself or we are sysadmin

        // FIXME To investigate: $this->requester->userid is a string here!!!
        if ($this->requester->userData['userid'] === $this->userid || $this->requester->userData['is_sysadmin'] === 1) {
            return;
        }
        if (!$this->requester->isAdmin && $this->userid !== $this->userData['userid']) {
            throw new IllegalActionException('This endpoint requires admin privileges to access other users.');
        }
        // check we view user of our team, unless we are sysadmin and we can access it
        if ($this->userid !== null && !$this->requester->isAdminOf($this->userid)) {
            throw new IllegalActionException('User tried to access user from other team.');
        }
    }

    private function update(UserParams $params): bool
    {
        if ($params->getTarget() === 'password') {
            throw new ImproperActionException('Use action:updatepassword to update the password');
        }
        // email is filtered here because otherwise the check for existing email will throw exception
        if ($params->getTarget() === 'email' && $params->getContent() !== $this->userData['email']) {
            // we can only edit our own email, or be sysadmin
            if (($this->requester->userData['userid'] !== $this->userData['userid']) && ($this->requester->userData['is_sysadmin'] !== 1)) {
                throw new IllegalActionException('User tried to edit email of another user but is not sysadmin.');
            }
            Filter::email($params->getContent());
        }
        // special case for is_sysadmin: only a sysadmin can affect this column
        if ($params->getTarget() === 'is_sysadmin') {
            if ($this->requester->userData['is_sysadmin'] === 0) {
                throw new IllegalActionException('Non sysadmin user tried to edit the is_sysadmin column of a user');
            }
        }

        $sql = 'UPDATE users SET ' . $params->getColumn() . ' = :content WHERE userid = :userid';
        $req = $this->Db->prepare($sql);
        $req->bindValue(':content', $params->getContent());
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
        $res = $this->Db->execute($req);

        $auditLoggableTargets = array(
            'email',
            'orgid',
            'is_sysadmin',
        );

        if ($res && in_array($params->getTarget(), $auditLoggableTargets, true)) {
            AuditLogs::create(new UserAttributeChanged(
                $this->requester->userid ?? 0,
                $this->userid ?? 0,
                $params->getTarget(),
                (string) $this->userData[$params->getTarget()],
                $params->getContent(),
            ));
        }
        return $res;
    }

    private function updatePassword(array $params, bool $isReset = false): bool
    {
        // a sysadmin or reset password page request doesn't need to provide the current password
        if ($this->requester->userData['is_sysadmin'] !== 1 && $isReset === false) {
            $this->checkCurrentPasswordOrExplode($params['current_password']);
        }
        if (empty($params['password'])) {
            throw new ImproperActionException('New password must be provided by "password" parameter.');
        }
        // when updating the password, we need to check for the presence and validity of the current_password
        // special case for password: we invalidate the stored token
        $this->invalidateToken();
        // this will properly hash the password
        $params = new UserParams('password', $params['password']);
        // don't use the update() function so it cannot be bypassed by setting Action::Update instead of Action::UpdatePassword
        $sql = 'UPDATE users SET password_hash = :content, password_modified_at = NOW() WHERE userid = :userid';
        $req = $this->Db->prepare($sql);
        $req->bindValue(':content', $params->getContent());
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
        $res = $this->Db->execute($req);
        AuditLogs::create(new PasswordChanged(
            $this->requester->userid ?? 0,
            $this->userid ?? 0,
            'password',
            'the old password',
            'the new password',
        ));
        return $res;
    }

    /**
     * Check if requester can act on this User
     */
    private function canWriteOrExplode(?Action $action = null): void
    {
        if ($this->requester->userData['is_sysadmin'] === 1) {
            return;
        }
        if (!$this->requester->isAdminOf($this->userData['userid']) && $action !== Action::Add) {
            throw new IllegalActionException(Tools::error(true));
        }
    }

    /**
     * Validate current user instance
     * Note: this could also be PATCHed?
     */
    private function validate(): array
    {
        $sql = 'UPDATE users SET validated = 1 WHERE userid = :userid';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
        $this->Db->execute($req);
        $Notifications = new SelfIsValidated();
        $Notifications->create($this->userData['userid']);
        $this->sendOnboardingEmailsAfterValidation();
        return $this->readOne();
    }

    /**
     * Read all the columns (including sensitive ones) of the current user
     */
    private function readOneFull(): array
    {
        $sql = "SELECT users.*, sig_keys.privkey AS sig_privkey, sig_keys.pubkey AS sig_pubkey,
            CONCAT(users.firstname, ' ', users.lastname) AS fullname
            FROM users
            LEFT JOIN sig_keys ON (sig_keys.userid = users.userid AND state = :state)
            WHERE users.userid = :userid";
        $req = $this->Db->prepare($sql);
        $req->bindValue(':userid', $this->userid, PDO::PARAM_INT);
        $req->bindValue(':state', State::Normal->value, PDO::PARAM_INT);
        $this->Db->execute($req);

        $this->userData = $this->Db->fetch($req);
        $this->userData['team'] = $this->team;
        $UsersHelper = new UsersHelper($this->userData['userid']);
        $this->userData['teams'] = $UsersHelper->getTeamsFromUserid();
        return $this->userData;
    }

    private function notifyAdmins(array $admins, int $userid, bool $isValidated, string $team): void
    {
        $Notifications = new UserCreated($userid, $team);
        if (!$isValidated) {
            $Notifications = new UserNeedValidation($userid, $team);
        }
        foreach ($admins as $admin) {
            $Notifications->create($admin);
        }
    }

    private function sendOnboardingEmailsAfterValidation(): void
    {
        // do we send an eamil for the instance
        if (Config::getConfig()->configArr['onboarding_email_active'] === '1') {
            (new OnboardingEmail(-1, $this->isAdmin))->create($this->userData['userid']);
        }

        // Check setting for each team individually
        foreach (array_column($this->userData['teams'], 'id') as $teamId) {
            if ((new Teams($this))->readOne()['onboarding_email_active'] === 1) {
                (new OnboardingEmail($teamId))->create($this->userData['userid']);
            }
        }
    }
}