Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
- Base: wintercms/winter branch 1.2 (full framework) - Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS - Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication - Partials: hero (offline-first), features, modes (offline/nube toggle), screenshots, pricing (3 planes), comparison, FAQ, CTA - Plugin VivesPOS.Site with ContactForm - Dockerfile: PHP 8.2 Apache, port 80, healthcheck - Added winter/wn-pages, blog, sitemap, seo plugins - Active theme set to vivespos
457 lines
14 KiB
PHP
457 lines
14 KiB
PHP
<?php namespace Backend\Models;
|
|
|
|
use Backend\Facades\Backend;
|
|
use Backend\Facades\BackendAuth;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Lang;
|
|
use Winter\Storm\Auth\AuthorizationException;
|
|
use Winter\Storm\Auth\Models\User as UserBase;
|
|
use Winter\Storm\Support\Facades\Mail;
|
|
|
|
/**
|
|
* Administrator user model
|
|
*
|
|
* @package winter\wn-backend-module
|
|
* @author Alexey Bobkov, Samuel Georges
|
|
*/
|
|
class User extends UserBase
|
|
{
|
|
use \Winter\Storm\Database\Traits\SoftDelete;
|
|
|
|
/**
|
|
* @var string The database table used by the model.
|
|
*/
|
|
protected $table = 'backend_users';
|
|
|
|
/**
|
|
* Validation rules
|
|
*/
|
|
public $rules = [
|
|
'email' => 'required|between:6,255|email|unique:backend_users',
|
|
'login' => 'required|between:2,255|unique:backend_users',
|
|
'password' => 'required:create|min:4|confirmed',
|
|
'password_confirmation' => 'required_with:password|min:4'
|
|
];
|
|
|
|
/**
|
|
* @var array Attributes that should be cast to dates
|
|
*/
|
|
protected $dates = [
|
|
'activated_at',
|
|
'last_login',
|
|
'created_at',
|
|
'updated_at',
|
|
'deleted_at',
|
|
];
|
|
|
|
/**
|
|
* Relations
|
|
*/
|
|
public $belongsToMany = [
|
|
'groups' => [UserGroup::class, 'table' => 'backend_users_groups', 'softDelete' => true]
|
|
];
|
|
|
|
public $belongsTo = [
|
|
'role' => UserRole::class
|
|
];
|
|
|
|
public $attachOne = [
|
|
'avatar' => \System\Models\File::class
|
|
];
|
|
|
|
public $hasMany = [
|
|
'throttle' => [UserThrottle::class, 'key' => 'user_id']
|
|
];
|
|
|
|
/**
|
|
* Purge attributes from data set.
|
|
*/
|
|
protected $purgeable = ['password_confirmation', 'send_invite'];
|
|
|
|
/**
|
|
* @var array List of attribute names which are json encoded and decoded from the database.
|
|
*/
|
|
protected $jsonable = ['permissions', 'metadata'];
|
|
|
|
/**
|
|
* @var string Login attribute
|
|
*/
|
|
public static $loginAttribute = 'login';
|
|
|
|
/**
|
|
* @var array<string> Relations on this model that require `backend.manage_users`
|
|
* to change on another user's record. Deliberately limited to the relations this
|
|
* model owns: authorization semantics for plugin-added relations belong to the
|
|
* plugin, which can append to this list or bind its own guard to the
|
|
* `model.relation.*` events.
|
|
*/
|
|
public array $permissionGuardedRelations = ['groups', 'avatar', 'throttle'];
|
|
|
|
public function __construct(array $attributes = [])
|
|
{
|
|
parent::__construct($attributes);
|
|
|
|
// Guard relation writes at the point they actually happen: direct
|
|
// attach()/detach(), deferred binding commits and queued relation
|
|
// syncs all funnel through these relation events.
|
|
$guard = function (string $relationName) {
|
|
$this->authorizeRelationChange($relationName);
|
|
};
|
|
|
|
$this->bindEvent('model.relation.beforeAttach', $guard);
|
|
$this->bindEvent('model.relation.beforeDetach', $guard);
|
|
$this->bindEvent('model.relation.beforeAdd', $guard);
|
|
$this->bindEvent('model.relation.beforeRemove', $guard);
|
|
}
|
|
|
|
/**
|
|
* @return string Returns the user's full name.
|
|
*/
|
|
public function getFullNameAttribute()
|
|
{
|
|
return trim($this->first_name . ' ' . $this->last_name);
|
|
}
|
|
|
|
/**
|
|
* Gets a code for when the user is persisted to a cookie or session which identifies the user.
|
|
* @return string
|
|
*/
|
|
public function getPersistCode()
|
|
{
|
|
// Option A: @todo config
|
|
// return parent::getPersistCode();
|
|
|
|
// Option B:
|
|
if (!$this->persist_code) {
|
|
return parent::getPersistCode();
|
|
}
|
|
|
|
return $this->persist_code;
|
|
}
|
|
|
|
/**
|
|
* Returns the public image file path to this user's avatar.
|
|
*/
|
|
public function getAvatarThumb($size = 25, $options = null)
|
|
{
|
|
if (is_string($options)) {
|
|
$options = ['default' => $options];
|
|
}
|
|
elseif (!is_array($options)) {
|
|
$options = [];
|
|
}
|
|
|
|
// Default is "mm" (Mystery man)
|
|
$default = array_get($options, 'default', 'mm');
|
|
|
|
if ($this->avatar) {
|
|
return $this->avatar->getThumb($size, $size, $options);
|
|
}
|
|
|
|
return '//www.gravatar.com/avatar/' .
|
|
md5(strtolower(trim($this->email))) .
|
|
'?s='. $size .
|
|
'&d='. urlencode($default);
|
|
}
|
|
|
|
/**
|
|
* Determine whether the given user (or the currently authenticated user)
|
|
* is authorized to manage this user record.
|
|
*
|
|
* Returns true when no user is provided and no user is authenticated (CLI/queue),
|
|
* or when the user has `backend.manage_users` and (if this record is a superuser)
|
|
* the user is also a superuser.
|
|
*/
|
|
public function canBeManagedByUser(?User $user = null): bool
|
|
{
|
|
$user = $user ?? BackendAuth::getUser();
|
|
|
|
if (!$user) {
|
|
return true;
|
|
}
|
|
|
|
if (!$user->hasAccess('backend.manage_users')) {
|
|
return false;
|
|
}
|
|
|
|
if (!$user->isSuperUser() && ($this->is_superuser || $this->getOriginal('is_superuser'))) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Before create event — enforce authorization rules to prevent privilege escalation.
|
|
*/
|
|
public function beforeCreate()
|
|
{
|
|
$this->authorizeChange();
|
|
}
|
|
|
|
/**
|
|
* Before update event — enforce authorization rules to prevent privilege escalation.
|
|
*
|
|
* Bound to update rather than save so that a save with nothing to write (e.g. a
|
|
* pivot form submission re-saving an untouched related record) requires no
|
|
* permission: the update event only fires when attributes have actually changed,
|
|
* after purgeable attributes have been stripped. Relation writes (group
|
|
* membership, avatar) are guarded separately by authorizeRelationChange().
|
|
*/
|
|
public function beforeUpdate()
|
|
{
|
|
$this->authorizeChange();
|
|
}
|
|
|
|
/**
|
|
* Enforce authorization rules for a change to this record's attributes.
|
|
*
|
|
* @throws AuthorizationException if the current user lacks permission
|
|
*/
|
|
protected function authorizeChange(): void
|
|
{
|
|
$actor = BackendAuth::getUser();
|
|
if (!$actor) {
|
|
return;
|
|
}
|
|
|
|
$isCurrentUser = $this->exists && $actor->getKey() === $this->getKey();
|
|
|
|
if ($isCurrentUser && $this->isDirty(['role_id', 'is_superuser', 'permissions'])) {
|
|
throw new AuthorizationException(Lang::get('backend::lang.user.self_escalation_denied'));
|
|
}
|
|
|
|
if (!$isCurrentUser && !$this->canBeManagedByUser($actor)) {
|
|
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enforce authorization rules for a change to one of this record's relations.
|
|
*
|
|
* Only the relations listed in $permissionGuardedRelations are guarded, so
|
|
* plugin-added relations (e.g. records that reference a user) behave normally.
|
|
*
|
|
* Changes to your own record's guarded relations are allowed: groups do not
|
|
* carry permissions out of the box, so they are not an escalation vector.
|
|
*
|
|
* @throws AuthorizationException if the current user lacks permission
|
|
*/
|
|
protected function authorizeRelationChange(string $relationName): void
|
|
{
|
|
if (!in_array($relationName, $this->permissionGuardedRelations)) {
|
|
return;
|
|
}
|
|
|
|
$actor = BackendAuth::getUser();
|
|
if (!$actor) {
|
|
return;
|
|
}
|
|
|
|
$isCurrentUser = $this->exists && $actor->getKey() === $this->getKey();
|
|
|
|
if (!$isCurrentUser && !$this->canBeManagedByUser($actor)) {
|
|
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Before delete event — enforce authorization rules.
|
|
*/
|
|
public function beforeDelete()
|
|
{
|
|
if (!$this->canBeManagedByUser()) {
|
|
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Before restore event — enforce authorization rules.
|
|
*/
|
|
public function beforeRestore()
|
|
{
|
|
if (!$this->canBeManagedByUser()) {
|
|
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* After create event
|
|
* @return void
|
|
*/
|
|
public function afterCreate()
|
|
{
|
|
$this->restorePurgedValues();
|
|
|
|
if ($this->send_invite) {
|
|
$this->sendInvitation();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* After login event
|
|
* @return void
|
|
*/
|
|
public function afterLogin()
|
|
{
|
|
parent::afterLogin();
|
|
|
|
/**
|
|
* @event backend.user.login
|
|
* Provides an opportunity to interact with the Backend User model after the user has logged in
|
|
*
|
|
* Example usage:
|
|
*
|
|
* Event::listen('backend.user.login', function ((\Backend\Models\User) $user) {
|
|
* Flash::success(sprintf('Welcome %s!', $user->getFullNameAttribute()));
|
|
* });
|
|
*
|
|
*/
|
|
Event::fire('backend.user.login', [$this]);
|
|
}
|
|
|
|
/**
|
|
* Sends an invitation to the user using template "backend::mail.invite".
|
|
* @return void
|
|
*/
|
|
public function sendInvitation()
|
|
{
|
|
$data = [
|
|
'name' => $this->full_name,
|
|
'login' => $this->login,
|
|
'password' => $this->getOriginalHashValue('password'),
|
|
'link' => Backend::url('backend'),
|
|
];
|
|
|
|
Mail::send('backend::mail.invite', $data, function ($message) {
|
|
$message->to($this->email, $this->full_name);
|
|
});
|
|
}
|
|
|
|
public function getGroupsOptions()
|
|
{
|
|
$result = [];
|
|
|
|
foreach (UserGroup::all() as $group) {
|
|
$result[$group->id] = [$group->name, $group->description];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function getRoleOptions()
|
|
{
|
|
$result = [];
|
|
|
|
foreach (UserRole::all() as $role) {
|
|
$result[$role->id] = [$role->name, $role->description];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Check if the user is suspended.
|
|
* @return bool
|
|
*/
|
|
public function isSuspended()
|
|
{
|
|
return BackendAuth::findThrottleByUserId($this->id)->checkSuspended();
|
|
}
|
|
|
|
/**
|
|
* Remove the suspension on this user.
|
|
*
|
|
* @throws AuthorizationException if the current user lacks permission
|
|
*/
|
|
public function unsuspend()
|
|
{
|
|
if (!$this->canBeManagedByUser()) {
|
|
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
|
|
}
|
|
|
|
BackendAuth::findThrottleByUserId($this->id)->unsuspend();
|
|
}
|
|
|
|
/**
|
|
* Get a reset password code for this user.
|
|
*
|
|
* When called by an authenticated user targeting a different account,
|
|
* the actor must have `backend.manage_users` permission.
|
|
* Self-service resets (Auth controller restore flow) are allowed.
|
|
*
|
|
* @throws AuthorizationException if the current user lacks permission
|
|
*/
|
|
public function getResetPasswordCode()
|
|
{
|
|
$actor = BackendAuth::getUser();
|
|
if ($actor && $actor->getKey() !== $this->getKey() && !$this->canBeManagedByUser($actor)) {
|
|
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
|
|
}
|
|
|
|
return parent::getResetPasswordCode();
|
|
}
|
|
|
|
//
|
|
// Impersonation
|
|
//
|
|
|
|
/**
|
|
* Returns an array of merged permissions based on the user's individual permissions
|
|
* and their role permissions filtering out any permissions the impersonator doesn't
|
|
* have access to (if the current user is being impersonated)
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getMergedPermissions()
|
|
{
|
|
if (!$this->mergedPermissions) {
|
|
$permissions = parent::getMergedPermissions();
|
|
|
|
// If the user is being impersonated filter out any permissions the impersonator doesn't have access to already
|
|
if (BackendAuth::isImpersonator()) {
|
|
$impersonator = BackendAuth::getImpersonator();
|
|
if ($impersonator && $impersonator !== $this) {
|
|
foreach ($permissions as $permission => $status) {
|
|
if (!$impersonator->hasAccess($permission)) {
|
|
unset($permissions[$permission]);
|
|
}
|
|
}
|
|
$this->mergedPermissions = $permissions;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->mergedPermissions;
|
|
}
|
|
|
|
/**
|
|
* Check if this user can be impersonated by the provided impersonator
|
|
* Super users cannot be impersonated and all users cannot be impersonated unless there is an impersonator
|
|
* present and the impersonator has access to `backend.impersonate_users`, and the impersonator is not the
|
|
* user being impersonated
|
|
*
|
|
* @param \Winter\Storm\Auth\Models\User|false $impersonator The user attempting to impersonate this user, false when not available
|
|
* @return boolean
|
|
*/
|
|
public function canBeImpersonated($impersonator = false)
|
|
{
|
|
if (
|
|
$this->isSuperUser() ||
|
|
!$impersonator ||
|
|
!($impersonator instanceof static) ||
|
|
!$impersonator->hasAccess('backend.impersonate_users') ||
|
|
$impersonator === $this
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// Clear the merged permissions before the impersonation starts
|
|
// so that they are correct even if they had been loaded prior
|
|
// to the impersonation starting
|
|
$this->mergedPermissions = null;
|
|
|
|
return true;
|
|
}
|
|
}
|