luyadev/luya-module-admin

View on GitHub
src/models/User.php

Summary

Maintainability
C
1 day
Test Coverage
B
82%
<?php

namespace luya\admin\models;

use luya\admin\aws\ApiRequestInsightActiveWindow;
use luya\admin\aws\ChangePasswordActiveWindow;
use luya\admin\aws\ChangePasswordInterface;
use luya\admin\aws\UserHistorySummaryActiveWindow;
use luya\admin\base\RestActiveController;
use luya\admin\events\UserAccessTokenLoginEvent;
use luya\admin\Module;
use luya\admin\ngrest\base\NgRestModel;
use luya\admin\traits\SoftDeleteTrait;
use luya\helpers\Html;
use luya\helpers\Url;
use luya\validators\StrengthValidator;
use Yii;
use yii\base\InvalidArgumentException;
use yii\helpers\Json;
use yii\web\IdentityInterface;

/**
 * User Model represents all Administration Users.
 *
 * @property integer $id
 * @property string $firstname
 * @property string $lastname
 * @property integer $title
 * @property string $email
 * @property string $password
 * @property string $password_salt
 * @property string $auth_token
 * @property integer $is_deleted
 * @property string $secure_token
 * @property integer $secure_token_timestamp
 * @property integer $force_reload
 * @property string $settings
 * @property UserSetting $setting Setting object to store data.
 * @property integer $is_api_user
 * @property integer $api_rate_limit
 * @property string $api_allowed_ips
 * @property integer $api_last_activity
 * @property string $email_verification_token
 * @property integer $email_verification_token_timestamp
 * @property integer $login_attempt
 * @property integer $login_attempt_lock_expiration
 * @property boolean $is_request_logger_enabled
 * @property int|null $login_2fa_enabled {@since 3.0.0}
 * @property string|null $login_2fa_secret {@since 3.0.0}
 * @property string|null $login_2fa_backup_key {@since 3.0.0}
 * @property string|null $password_verification_token {@since 3.0.0}
 * @property int|null $password_verification_token_timestamp {@since 3.0.0}
 * @property Group[] $groups Expand groups for this user
 *
 * @author Basil Suter <basil@nadar.io>
 * @since 1.0.0
 */
class User extends NgRestModel implements IdentityInterface, ChangePasswordInterface
{
    use SoftDeleteTrait;
    public const USER_SETTING_ISDEVELOPER = 'isDeveloper';

    public const USER_SETTING_UILANGUAGE = 'luyadminlanguage';

    public const USER_SETTING_NEWUSEREMAIL = 'newUserEmail';

    /**
     * @inheritdoc
     */
    public function init()
    {
        parent::init();

        $this->on(self::EVENT_BEFORE_INSERT, function () {
            if ($this->scenario == RestActiveController::SCENARIO_RESTCREATE) {
                $this->encodePassword();

                if ($this->isNewRecord) {
                    $this->is_deleted = false;
                    $this->auth_token = Yii::$app->security->hashData(Yii::$app->security->generateRandomString(), $this->password_salt);
                }
            }
        });
    }

    private $_setting;

    /**
     * Get user settings objects.
     *
     * @return \luya\admin\models\UserSetting
     */
    public function getSetting()
    {
        if ($this->_setting === null) {
            $settingsArray = (empty($this->settings)) ? [] : Json::decode($this->settings);
            $this->_setting = Yii::createObject(['class' => UserSetting::class, 'sender' => $this, 'data' => $settingsArray]);
        }

        return $this->_setting;
    }

    /**
     * Setter method for user settings which encodes the json.
     *
     * @param array $data
     */
    public function updateSettings(array $data)
    {
        return $this->updateAttributes(['settings' => Json::encode($data)]);
    }

    /**
     * Get the last login Timestamp
     */
    public function getLastloginTimestamp()
    {
        return $this->getUserLogins()->select(['timestamp_create'])->scalar();
    }


    /**
     * @inheritdoc
     */
    public static function ngRestApiEndpoint()
    {
        return 'api-admin-user';
    }

    /**
     * @inheritdoc
     */
    public static function ngRestFind()
    {
        return self::find()->andWhere(['is_api_user' => false]);
    }

    /**
     * @inheritdoc
     */
    public function ngRestListOrder()
    {
        return ['firstname' => SORT_ASC];
    }

    /**
     * @inheritdoc
     */
    public function ngRestAttributeTypes()
    {
        return [
            'title' => ['selectArray', 'data' => static::getTitles()],
            'firstname' => ['text', 'inline' => true],
            'lastname' => ['text', 'inline' => true],
            'email' => 'text',
            'password' => 'password',
            'login_attempt_lock_expiration' => 'datetime',
            'is_request_logger_enabled' => 'toggleStatus',
        ];
    }

    /**
     * @inheritdoc
     */
    public function ngRestExport()
    {
        return [
            'id' => 'text',
            'lastname' => 'text',
            'firstname' => 'text',
            'email' => 'text',
            'login_attempt_lock_expiration' => 'datetime',
        ];
    }

    /**
     * @inheritdoc
     */
    public function ngRestFilters()
    {
        return [
            'Removed' => self::find()->where(['is_api_user' => false, 'is_deleted' => true]),
        ];
    }

    /**
     * @inheritdoc
     */
    public function ngRestExtraAttributeTypes()
    {
        return [
            'lastloginTimestamp' => ['datetime', 'sortField' => false],
        ];
    }

    /**
     * @inheritdoc
     */
    public function ngRestScopes()
    {
        return [
            ['list', ['title', 'firstname', 'lastname', 'email', 'lastloginTimestamp']],
            ['create', ['title', 'firstname', 'lastname', 'email', 'password']],
            ['update', ['title', 'firstname', 'lastname', 'email', 'login_attempt_lock_expiration']],
            ['delete', true],
        ];
    }

    /**
     * @inheritdoc
     */
    public function ngRestActiveWindows()
    {
        return [
            ['class' => ChangePasswordActiveWindow::class, 'label' => false],
            ['class' => UserHistorySummaryActiveWindow::class, 'label' => false],
            ['class' => ApiRequestInsightActiveWindow::class, 'label' => false],
        ];
    }

    /**
     * @inheritdoc
     */
    public static function tableName()
    {
        return '{{%admin_user}}';
    }

    /**
     * @inheritdoc
     */
    public function genericSearchFields()
    {
        return ['firstname', 'lastname', 'email'];
    }

    /**
     * @inheritdoc
     */
    public function rules()
    {
        return [
            [['title', 'firstname', 'lastname', 'email', 'password'], 'required', 'on' => 'restcreate'],
            [['title', 'firstname', 'lastname', 'email'], 'required', 'on' => 'restupdate'],
            [['email', 'password'], 'required', 'on' => 'login'],
            [['secure_token'], 'required', 'on' => 'securelayer'],
            [['title', 'firstname', 'lastname', 'email', 'password'], 'required', 'on' => 'default'],
            [['firstname', 'lastname', 'password', 'password_salt', 'cookie_token', 'api_allowed_ips', 'login_2fa_secret', 'login_2fa_backup_key'], 'string', 'max' => 255],
            [['email'], 'email'],
            [['email'], 'unique', 'except' => ['login']],
            [['email'], function ($attribute) {
                if (self::find()->where(['email' => $this->$attribute, 'is_deleted' => true])->exists()) {
                    $this->addError($attribute, Module::t('user_model_email_deleted_account_exists'));
                }
            }, 'except' => ['login']],
            [['auth_token'], 'unique'],
            [['settings'], 'string'],
            [['is_request_logger_enabled'], 'boolean'],
            [['email_verification_token_timestamp', 'login_attempt', 'login_attempt_lock_expiration', 'is_deleted', 'is_api_user', 'password_verification_token', 'password_verification_token_timestamp'], 'integer'],
            [['email_verification_token', 'secure_token', 'password_verification_token'], 'string', 'length' => 40],
            [['password'], StrengthValidator::class, 'when' => fn () => Module::getInstance()->strongPasswordPolicy, 'on' => ['restcreate', 'restupdate', 'default']],
            [['login_2fa_enabled'], 'integer'],
        ];
    }

    /**
     * @inheritdoc
     */
    public function attributeLabels()
    {
        return [
            'title' => Module::t('mode_user_title'),
            'firstname' => Module::t('mode_user_firstname'),
            'lastname' => Module::t('mode_user_lastname'),
            'email' => Module::t('mode_user_email'),
            'password' => Module::t('mode_user_password'),
            'lastloginTimestamp' => Module::t('model_user_lastlogintimestamp'),
            'api_last_activity' => Module::t('model_user_api_last_activity'),
            'login_attempt_lock_expiration' => Module::t('model_user_login_attempt_lock_expiration'),
            'email_verification_token' => Module::t('model_user_email_verification_token'),
        ];
    }

    /**
     * @inheritdoc
     */
    public function scenarios()
    {
        return [
            'restcreate' => ['title', 'firstname', 'lastname', 'email', 'password', 'is_request_logger_enabled'],
            'restupdate' => ['title', 'firstname', 'lastname', 'email', 'login_attempt_lock_expiration', 'is_request_logger_enabled'],
            'changepassword' => ['password', 'password_salt'],
            'login' => ['email', 'password', 'force_reload'],
            'securelayer' => ['secure_token'],
            'default' => ['title', 'firstname', 'lastname', 'email', 'password', 'force_reload', 'settings'],
        ];
    }

    /**
     * Generate an easy readable random token.
     *
     * @param number $length
     * @return mixed
     * @since 1.2.0
     */
    private function generateToken($length = 6)
    {
        $token = Yii::$app->security->generateRandomString($length);
        $replace = array_rand(range(2, 9));
        return str_replace(['-', '_', 'l', 1], $replace, strtolower($token));
    }

    /**
     * Generate, store and return the secure Login token.
     *
     * @return string
     */
    public function getAndStoreToken()
    {
        $token = $this->generateToken(6);

        $this->setAttribute('secure_token', sha1($token));
        $this->setAttribute('secure_token_timestamp', time());
        $this->update(false);

        return $token;
    }

    /**
     * @inheritdoc
     */
    public function changePassword($newpass)
    {
        $this->password = $newpass;

        if ($this->encodePassword()) {
            if ($this->save(true, ['password', 'password_salt'])) {
                return true;
            }
        }

        $this->addError('newpass', Module::t('user_change_password_error'));
        return false;
    }

    /**
     * Encodes the current active record password field.
     * @return boolean
     */
    public function encodePassword()
    {
        if (!$this->validate(['password'])) {
            return false;
        }

        // create random string for password salting
        $this->password_salt = Yii::$app->getSecurity()->generateRandomString();
        // store the password
        $this->password = Yii::$app->getSecurity()->generatePasswordHash($this->password.$this->password_salt);

        return true;
    }

    /**
     * Get the title Mr, Mrs. as string for the current user.
     *
     * @return string
     */
    public function getTitleNamed()
    {
        return !isset(self::getTitles()[$this->title]) ?: self::getTitles()[$this->title];
    }

    /**
     * Returns the available titles (mr, mrs index by numberic identifier
     *
     * @return array
     */
    public static function getTitles()
    {
        return [
            1 => Module::t('model_user_title_mr'),
            2 => Module::t('model_user_title_mrs'),
        ];
    }

    /**
     * Return sensitive fields from api exposure.
     *
     * {@inheritDoc}
     * @see \yii\db\BaseActiveRecord::fields()
     */
    public function fields()
    {
        $fields = parent::fields();
        unset($fields['password'], $fields['password_salt'], $fields['auth_token'], $fields['is_deleted'], $fields['email_verification_token'], $fields['cookie_token'], $fields['secure_token'], $fields['settings']);
        return $fields;
    }

    /**
     * Return the current related groups.
     * @return \yii\db\ActiveQuery
     */
    public function getGroups()
    {
        return $this->hasMany(Group::class, ['id' => 'group_id'])->viaTable('{{%admin_user_group}}', ['user_id' => 'id']);
    }

    /**
     * @inheritdoc
     */
    public function extraFields()
    {
        return array_unique(array_merge(['groups', 'lastloginTimestamp'], parent::extraFields()));
    }

    /**
     * Devices Active Query
     *
     * @return UserDevice[]
     * @since 3.0.0
     */
    public function getDevices()
    {
        return $this->hasMany(UserDevice::class, ['user_id' => 'id']);
    }

    /**
     * Render user token based email:
     *
     * This is currently used for secure token and email validation tokens.
     *
     * @see https://mjml.io/try-it-live/Hk9rJe68B
     * @since 2.2.0
     */
    public static function generateTokenEmail($token, $title, $text)
    {
        return Yii::$app->view->render('@admin/views/mail/_token.php', [
            'url' => Url::domain(Url::base(true)),
            'token' => $token,
            'browser' => "-",
            'title' => $title,
            'text' => $text,
        ]);
    }

    public static function generateResetEmail($url, $title, $text)
    {
        return Yii::$app->view->render('@admin/views/mail/_reset.php', [
            'url' => Url::domain(Url::base(true)),
            'token' => Html::a(Module::t('reset_email_btn_label'), $url),
            'browser' => "-",
            'title' => $title,
            'text' => $text,
        ]);
    }

    /**
     * Finds a current user for a given email.
     *
     * This is used for the login form, and can therefore not be used for api users (since 1.1.0)
     *
     * @param string $email The email address to find the user from.
     * @return \yii\db\ActiveRecord|null
     */
    public static function findByEmail($email)
    {
        return self::find()->andWhere(['email' => $email, 'is_api_user' => false])->one();
    }

    /**
     * Validates the password for the current given user.
     *
     * @param string $password The plain user input password.
     * @return boolean
     */
    public function validatePassword($password)
    {
        return Yii::$app->security->validatePassword($password.$this->password_salt, $this->password);
    }

    /**
     * Get the user logins for the given user.
     *
     * @return \yii\db\ActiveQuery
     */
    public function getUserLogins()
    {
        return $this->hasMany(UserLogin::class, ['user_id' => 'id']);
    }

    /**
     * Get all ngrest log entries for this user.
     *
     * @return \yii\db\ActiveQuery
     */
    public function getNgrestLogs()
    {
        return $this->hasMany(NgrestLog::class, ['user_id' => 'id']);
    }

    // Change e-mail

    /**
     * Generate and save a email verification token and return the token.
     *
     * @return mixed
     * @since 1.2.0
     */
    public function getAndStoreEmailVerificationToken()
    {
        $token = $this->generateToken(6);

        $this->updateAttributes([
            'email_verification_token' => sha1($token),
            'email_verification_token_timestamp' => time(),
        ]);

        return $token;
    }

    /**
     * Reset the user model email verification token and timestamp
     *
     * @since 1.2.0
     */
    public function resetEmailVerification()
    {
        $this->updateAttributes([
            'email_verification_token' => null,
            'email_verification_token_timestamp' => null,
        ]);
    }

    // IdentityInterface

    /**
     * @inheritdoc
     */
    public static function findIdentity($id)
    {
        $where = ['{{%admin_user}}.id' => $id, 'is_destroyed' => false, 'is_api_user' => false];
        if (Module::getInstance()->logoutOnUserIpChange) {
            $where['ip'] = Yii::$app->request->userIP;
        }
        return static::find()->joinWith(['userLogins ul'])->andWhere($where)->one();
    }

    /**
     * @inheritdoc
     */
    public static function findIdentityByAccessToken($token, $type = null)
    {
        if (empty($token) || !is_scalar($token)) {
            throw new InvalidArgumentException("The provided access token is invalid.");
        }

        $event = new UserAccessTokenLoginEvent();
        $event->type = $type;
        $event->token = $token;
        Yii::$app->trigger(Module::EVENT_USER_ACCESS_TOKEN_LOGIN, $event);

        if ($event->user) {
            $user = $event->user;
        } else {
            $user = static::findOne(['auth_token' => $token]);
        }

        // if the given user can be found, udpate the api last activity timestamp.
        if ($user && Module::getInstance()->apiUserTrackLastActivity) {
            $user->updateAttributes(['api_last_activity' => time()]);
        }

        // this ensures the user cookie won't be destroyed.
        Yii::$app->adminuser->enableAutoLogin = false;
        return $user;
    }

    /**
     * @inheritdoc
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @inheritdoc
     */
    public function getAuthKey()
    {
        // find user agent, if empty disable auto login
        $userAgent = Yii::$app->request->userAgent;
        if (empty($userAgent)) {
            return false;
        }

        $checksum = UserDevice::generateUserAgentChecksum($userAgent);
        if (empty($checksum)) {
            return false;
        }

        $model = UserDevice::find()->where(['user_id' => $this->id, 'user_agent_checksum' => $checksum])->one();

        if ($model) {
            // update last update timestamp and return existing auth key
            $model->touch('updated_at');
            return $model->auth_key;
        }

        $model = new UserDevice();
        $model->user_id = $this->id;
        $model->user_agent = $userAgent;
        $model->user_agent_checksum = $checksum;
        $model->auth_key = Yii::$app->security->generatePasswordHash(Yii::$app->security->generateRandomString() . $checksum);

        if ($model->save()) {
            return $model->auth_key;
        }

        return false;
    }

    /**
     * @inheritdoc
     */
    public function validateAuthKey($authKey)
    {
        return UserDevice::find()->where(['auth_key' => $authKey, 'user_id' => $this->id])->exists();
    }
}