User::isSuper()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
ccs 0
cts 2
cp 0
crap 2
1
<?php
2
3
namespace neon\user\models;
4
5
use Carbon\Carbon;
6
use Firebase\JWT\JWT;
7
use neon\core\helpers\Hash;
8
use neon\core\helpers\Html;
9
use neon\core\db\ActiveRecord;
10
use neon\core\Env;
11
use neon\user\services\apiTokenManager\JwtTokenAuth;
12
use yii\web\IdentityInterface;
13
use neon\user\interfaces\iUser;
14
use neon\user\services\apiTokenManager\models\UserApiToken;
15
use neon\user\models\UserInvite;
16
17
/**
18
 * User model
19
 *
20
 * @property string $uuid
21
 * @property integer $id
22
 * @property string $username
23
 * @property string $password_hash
24
 * @property string $password_reset_token
25
 * @property string $email
26
 * @property string $auth_key
27
 * @property string $status - A user is only valid to login if their status is User::STATUS_ACTIVE
28
 * @property integer $created_at
29
 * @property integer $updated_at
30
 * @property string $password write-only password
31
 * @property int $failed_logins the number of failed login attempts made by this user
32
 * @property int $logins the number of times the user has logged in
33
 * @property string $last_login the string mysql date time format (Y-m-d H:i:s) of the last successful login
34
 */
35
class User extends ActiveRecord implements
36
	IdentityInterface,
37
	iUser
38
{
39
	public static $passwordHashCost = 4;
40
41
	/**
42
	 * @inheritdoc
43
	 */
44
	public static $idIsUuid64 = true;
45
	/**
46
	 * Id is already taken so create a specific uuid column
47
	 */
48
	const UUID = 'uuid';
49
	/**
50
	 * @inheritdoc
51
	 */
52
	public static $timestamps = true;
53
54
	/**
55
	 * When a use is suspended can be automatically set if a user has too many password attempts
56
	 */
57
	const STATUS_SUSPENDED = 'SUSPENDED';
58
	/**
59
	 * The only valid user status for successful login
60
	 */
61
	const STATUS_ACTIVE = 'ACTIVE';
62
	/**
63
	 * A user can be banned from the system and not be allowed to log in again
64
	 */
65
	const STATUS_BANNED = 'BANNED';
66
	/**
67
	 * A user can be pending if they have not yet registered
68
	 */
69
	const STATUS_PENDING = 'PENDING';
70
71
	/**
72
	 * @inheritdoc
73
	 */
74 42
	public static function tableName()
75
	{
76 42
		return '{{%user_user}}';
77
	}
78
79
	/**
80
	 * @inheritdoc
81
	 */
82 6
	public function rules()
83
	{
84
		$rules = [
85 6
			['username', 'string', 'min' => 2, 'max' => 255],
86
			['email', 'email'],
87 6
			['status', 'default', 'value' => self::STATUS_ACTIVE],
88 6
			['status', 'in', 'range' => [self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_BANNED, self::STATUS_PENDING]],
89
			['password_hash', 'string', 'min' => 6],
90
			[['password_hash', 'email'], 'required'],
91
		];
92 6
		if ($this->isNewRecord) {
93 6
			$rules[] = ['username', 'match', 'pattern' => '/^[a-zA-Z0-9_-]+$/', 'message' => 'Your username can only contain alphanumeric characters, underscores and dashes.'];
94 6
			$rules[] = ['username', 'unique', 'targetClass' => '\neon\user\models\User', 'message' => 'This username has already been taken.'];
95 6
			$rules[] = ['email', 'unique', 'targetClass' => '\neon\user\models\User', 'message' => 'This email address has already been taken.'];
96
		} else {
97
			// for importing from other places allow a more generous pattern
98 2
			$rules[] = ['username', 'match', 'pattern' => '/^[a-zA-Z0-9_\- \.@]+$/', 'message' => 'Your username can only contain alphanumeric characters, underscores and dashes.'];
99
		}
100 6
		return $rules;
101
	}
102
103
	/**
104
	 * @inheritdoc
105
	 */
106
	public function fields()
107
	{
108
		$fields = parent::fields();
109
		// remove fields that contain sensitive information
110
		unset($fields['auth_key'], $fields['password_hash'], $fields['password_reset_token']);
111
		return $fields;
112
	}
113
114
	/**
115
	 * @inheritdoc
116
	 */
117
	public function getUuid()
118
	{
119
		return $this->uuid;
120
	}
121
122
	/**
123
	 * Caching of user roles so repeated checks don't result
124
	 * in lots of database queries
125
	 * @var array
126
	 */
127
	protected static $_userRoles = [];
128
129
	/**
130
	 * @inheritdoc
131
	 */
132 2
	public function getRoles()
133
	{
134 2
		if (!isset(static::$_userRoles[$this->id]))
135 2
			static::$_userRoles[$this->id] = array_keys(neon()->authManager->getRolesByUser($this->id));
136 2
		return is_array(static::$_userRoles[$this->id]) ? static::$_userRoles[$this->id] : [];
137
	}
138
139
	/**
140
	 * @inheritdoc
141
	 */
142
	public function setRoles(array $roles)
143
	{
144
		$authManager = neon()->authManager;
145
		$authManager->revokeAll($this->id);
146
		foreach ($roles as $role) {
147
			$r = $authManager->getRole($role);
148
			if ($r)
149
				$authManager->assign($r, $this->id);
150
		}
151
		unset(static::$_userRoles[$this->id]);
152
	}
153
154
	/**
155
	 * @inheritdoc
156
	 */
157
	public function hasRole($role)
158
	{
159
		return $this->hasRoles([$role]);
160
	}
161
162
	/**
163
	 * @inheritdoc
164
	 */
165
	public function hasRoles(array $roles)
166
	{
167
		$userRoles = $this->getRoles();
168
		return (count(array_intersect($userRoles, $roles)) > 0);
169
	}
170
171
	/**
172
	 * @inheritdoc
173
	 */
174
	public function getHomeUrl($role=null)
175
	{
176
		$userRoles = $this->getRoles();
177
		$availableRoles = neon('user')->getRoles(true);
178
179
		// searching in a specific role?
180
		if (!empty($role)) {
181
			if (isset($availableRoles[$role]['homeUrl']))
182
				return $availableRoles[$role]['homeUrl'];
183
		} else { // or finding any one?
184
			foreach ($userRoles as $role) {
185
				if (isset($availableRoles[$role]['homeUrl']))
186
					return $availableRoles[$role]['homeUrl'];
187
188
			}
189
		}
190
		return null;
191
	}
192
193
	/**
194
	 * Get the list of possible status states for this record
195
	 * @return array
196
	 */
197
	public static function getStatusChoices()
198
	{
199
		return [
200
			self::STATUS_ACTIVE => 'Active',
201
			self::STATUS_SUSPENDED => 'Suspended',
202
			self::STATUS_BANNED => 'Banned',
203
			self::STATUS_PENDING => 'Pending'
204
		];
205
	}
206
207
	/**
208
	 * @inheritdoc
209
	 */
210
	public static function findIdentity($id)
211
	{
212
		// The id must be a uuid64
213
		if (!Hash::isUuid64($id))
214
			return null;
215
		// The string conversion here is important to ensure the id is correctly quoted as a string
216
		// otherwise if the $id is a number it will match uuid's that start with that number!
217
		return self::findOne(['uuid' => (string) $id, 'status' => self::STATUS_ACTIVE]);
218
	}
219
220
	/**
221
	 * @inheritdoc
222
	 */
223
	public static function findIdentityByAccessToken($token, $type = null)
224
	{
225
		if ($type === 'neon\user\services\apiTokenManager\JwtTokenAuth') {
226
			return JwtTokenAuth::findAndValidateUserIdentity($token);
227
		} else {
228
			return UserApiToken::getValidUserByToken($token);
229
		}
230
	}
231
232
	/**
233
	 * Get hold of the user model associated with the token
234
	 * @param string $token invite token
235
	 * @return null|User
236
	 */
237
	public static function findIdentityByInviteToken($token)
238
	{
239
		$invite = UserInvite::findInviteByInviteToken($token);
240
		if (!$invite)
0 ignored issues
show
introduced by
$invite is of type neon\user\models\UserInvite, thus it always evaluated to true.
Loading history...
241
			return null;
242
		return User::findOne(['uuid' => $invite->user_id, 'status' => [User::STATUS_PENDING]]);
243
	}
244
245
	/**
246
	 * Finds user by username
247
	 *
248
	 * @param string $username
249
	 * @param boolean $activeOnly if true only return active users
250
	 * @return static|null
251
	 */
252
	public static function findByUsername($username, $activeOnly = true)
253
	{
254
		$field = (strpos($username, "@")) ? 'email' : 'username';
255
		$filter = [$field => $username];
256
		if ($activeOnly) {
257
			$filter['status'] = self::STATUS_ACTIVE;
258
		}
259
		return static::findOne($filter);
260
	}
261
262
	/**
263
	 * Finds user by password reset token
264
	 *
265
	 * @param string $token password reset token
266
	 * @return static|null
267
	 */
268
	public static function findByPasswordResetToken($token)
269
	{
270
		if (!static::isPasswordResetTokenValid($token)) {
271
			return null;
272
		}
273
		return static::findOne([
274
				'password_reset_token' => $token,
275
				'status' => self::STATUS_ACTIVE,
276
		]);
277
	}
278
279
	/**
280
	 * Finds out if password reset token is valid
281
	 *
282
	 * @param string $token password reset token
283
	 * @return boolean
284
	 */
285
	public static function isPasswordResetTokenValid($token)
286
	{
287
		if (empty($token)) { return false; }
288
		$timestamp = self::getPasswordResetTokenExpireTime($token);
289
		return $timestamp >= time();
290
	}
291
292
	/**
293
	 * Check if the token is expired
294
	 *
295
	 * @param  string  $token
296
	 * @return int timestamp
297
	 */
298
	public static function hasPasswordResetTokenExpired($token)
299
	{
300
		return !static::isPasswordResetTokenValid($token);
301
	}
302
303
	/**
304
	 * Get the expire time of
305
	 *
306
	 * @param  string  $token
307
	 * @return int
308
	 */
309
	public static function getPasswordResetTokenExpireTime($token)
310
	{
311
		$parts = explode('_', $token);
312
		return (int) end($parts) + neon()->user->passwordResetTokenExpire;
313
	}
314
315
	/**
316
	 * Get a human readable string of time until the password token expires
317
	 * for e.g. '5 minutes'
318
	 * @param  string  $token  The password reset token
319
	 * @return string
320
	 */
321
	public static function passwordResetTokenExpiresIn($token)
322
	{
323
		$expires = self::getPasswordResetTokenExpireTime($token);
324
		return Carbon::now()->diffForHumans(Carbon::createFromTimestamp($expires), true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type array|integer expected by parameter $syntax of Carbon\Carbon::diffForHumans(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

324
		return Carbon::now()->diffForHumans(Carbon::createFromTimestamp($expires), /** @scrutinizer ignore-type */ true);
Loading history...
325
	}
326
327
	/**
328
	 * The key for the userlist cache
329
	 * @see \neon\user\models\User::getUserList()
330
	 */
331
	const CACHE_KEY_USER_LIST = 'USER_LIST';
332
333
	/**
334
	 * Get a list of users in a format suitable for a drop down
335
	 * @param array|string $filters
336
	 * @param array $fields
337
	 * @param int $start - the row offset to start at
338
	 * @param int $length - the total number of rows to return
339
	 * @see getUserListUncached for details
340
	 * @return array
341
	 */
342 4
	public static function getUserList($query='', $filters=[], $fields=[], $start=0, $length=100)
343
	{
344
		// because we need to cache according to the args we cannot store
345
		// results in cache rather cacheArray. This is so we can clear cache
346
		// appropriately in other calls such as edit and delete. Also user
347
		// information should not be available in cached files
348 4
		return neon()->cacheArray->getOrSet(
349 4
			[static::CACHE_KEY_USER_LIST,func_get_args()],
350 4
			function() use ($query, $filters, $fields, $start, $length) {
351 4
				return self::getUserListUncached($query, $filters, $fields, $start, $length);
352 4
			}
353
		);
354
	}
355
356
	/**
357
	 * Get a list of users in a format suitable for a drop down
358
	 * This method is uncached. Unless you need an updated list use
359
	 * @see getUserList
360
	 * @see \neon\user\form\fields\UserSelector
361
	 * @see \neon\user\App::getDataMapTypes()
362
	 * @param array $filters
363
	 * @param array $fields
364
	 * @param int $start - the row offset to start at
365
	 * @param int $length - the total number of rows to return
366
	 * @return array
367
	 */
368 4
	public static function getUserListUncached($query='',$filters=[], $fields=[], $start=0, $length=100)
369
	{
370 4
		$q = self::find()->select(array_merge(['uuid', 'username', 'email'], $fields));
371 4
		if ($q !== '')
372 4
			$q->where(['or', ['like', 'username', $query], ['like', 'email', $query]]);
373 4
		if (!empty($filters))
374 4
			$q->andWhere($filters);
375 4
		$q->offset($start)
376 4
			->limit($length)
377 4
			->orderBy(['username' => SORT_ASC, 'email' => SORT_ASC])
378 4
			->asArray();
379 4
		return self::formatDataMapItems($q->all(), $fields);
380
	}
381
382
	/**
383
	 * Formats a list of user rows into appropriate format for use in a map
384
	 * for e.g. a drop down list
385
	 * @param array $userRows - user db record rows as assoc arrays
386
	 * @param array $fields - list of additional fields to get
387
	 * @return array
388
	 */
389
	public static function formatDataMapItems($userRows, $fields)
390
	{
391 4
		return collect($userRows)->mapWithKeys(function($row, $key) use($fields) {
0 ignored issues
show
Unused Code introduced by
The parameter $key is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

391
		return collect($userRows)->mapWithKeys(function($row, /** @scrutinizer ignore-unused */ $key) use($fields) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
392 4
			if (empty($fields))
393 4
				return [$row['uuid'] => $row['username'] ? $row['username'] . ' - ' . $row['email'] : $row['email']];
394 2
			return [$row['uuid'] => $row];
395 4
		})->all();
396
	}
397
398
	/**********************************************************************************
399
	 * Instance methods
400
	 */
401
402
	/**
403
	 * @inheritdoc
404
	 */
405
	public function getId()
406
	{
407
		return $this->uuid;
408
	}
409
410
	/**
411
	 * @inheritdoc
412
	 * For added security we hash the auth_key
413
	 * Yii provides security on cookies too... When accessed through the request and response
414
	 * objects (a typical controller based request lifecycle). But just in case we
415
	 * hash the auth_key so the cookie only ever has an encrypted version
416
	 * with the auth_key field being the key
417
	 */
418
	public function getAuthKey()
419
	{
420
		profile_begin('getAuthKey');
421
		$thing = JWT::urlsafeB64Encode(neon()->security->encryptByKey($this->getAttribute('auth_key'), Env::getNeonEncryptionKey()));
422
		profile_end('getAuthKey');
423
		return $thing;
424
	}
425
426
	/**
427
	 * @inheritdoc
428
	 */
429
	public function validateAuthKey($authKey)
430
	{
431
		profile_begin('validateAuthKey');
432
		$res = ($this->getAttribute('auth_key') === neon()->security->decryptByKey(JWT::urlsafeB64Decode($authKey), Env::getNeonEncryptionKey()));
433
		profile_end('validateAuthKey');
434
		return $res;
435
	}
436
437
	/**
438
	 * Sets a hashed password against the user
439
	 *
440
	 * @param string $password
441
	 */
442 6
	public function setPassword($password)
443
	{
444 6
		$this->password_hash = neon()->user->generatePasswordHash($password);
445 6
	}
446
447
	/**
448
	 * Validates password
449
	 *
450
	 * @param string $password password to validate
451
	 * @return boolean if password provided is valid for current user
452
	 */
453
	public function validatePassword($password)
454
	{
455
		$passwordValid = neon()->security->validatePassword($password, $this->password_hash);
456
		// keep a record of password incorrect login attempts
457
		if ($passwordValid) {
458
			$this->failed_logins = 0;
459
			$this->save();
460
		} else {
461
			$this->failed_logins = $this->failed_logins + 1;
462
			// suspend the account if we have had too many attempts
463
			if ($this->hasTooManyFailedLoginAttempts()) {
464
				$this->status = self::STATUS_SUSPENDED;
465
			}
466
			$this->save();
467
		}
468
		return $passwordValid;
469
	}
470
471
	/**
472
	 * Returns true if the user has made too many failed log in attempts
473
	 *
474
	 * @return boolean
475
	 */
476
	public function hasTooManyFailedLoginAttempts()
477
	{
478
		return ($this->failed_logins >= neon()->user->failedLoginAttempts);
0 ignored issues
show
Bug Best Practice introduced by
The property failedLoginAttempts does not exist on neon\user\services\User. Since you implemented __get, consider adding a @property annotation.
Loading history...
479
	}
480
481
	/**
482
	 * Whether this user record is suspended
483
	 *
484
	 * @return bool
485
	 */
486
	public function isSuspended()
487
	{
488
		return ($this->status == static::STATUS_SUSPENDED);
489
	}
490
491
	/**
492
	 * Password is readonly
493
	 * @return null
494
	 */
495
	public function getPassword()
496
	{
497
		return null;
498
	}
499
500
	/**
501
	 * Generates "remember me" authentication key
502
	 */
503 6
	public function generateAuthKey()
504
	{
505 6
		$this->auth_key = neon()->security->generateRandomString(100);
506 6
	}
507
508
	/**
509
	 * Generates new password reset token
510
	 */
511
	public function generatePasswordResetToken()
512
	{
513
		$this->password_reset_token = neon()->security->generateRandomString() . '_' . time();
514
	}
515
516
	/**
517
	 * Removes password reset token
518
	 */
519
	public function removePasswordResetToken()
520
	{
521
		$this->password_reset_token = null;
522
	}
523
524
	/**
525
	 * Generate an auth key if this is creating a new user
526
	 * @inheritdoc
527
	 */
528 6
	public function beforeSave($insert)
529
	{
530 6
		neon()->cacheArray->delete(static::CACHE_KEY_USER_LIST);
531 6
		if (parent::beforeSave($insert)) {
532 6
			if ($this->isNewRecord) {
533 6
				$this->generateAuthKey();
534
			}
535 6
			return true;
536
		}
537
		return false;
538
	}
539
540
	/**
541
	 * @inheritdoc
542
	 */
543 2
	public function beforeDelete()
544
	{
545
		// remove any caches and authorisations against the user
546 2
		neon()->cacheArray->delete(static::CACHE_KEY_USER_LIST);
547 2
		if ($this->id)
548 2
			neon()->authManager->revokeAll($this->id);
549 2
		return parent::beforeDelete();
550
	}
551
552
	/**
553
	 * Called by the web User class after a successful login
554
	 */
555
	public function afterLogin()
556
	{
557
		$this->logins = $this->logins + 1;
558
		$this->last_login = date('Y-m-d H:i:s');
559
		// could log the users IP here too $ip = Yii::$app->getRequest()->getUserIP();
560
		$this->save();
561
	}
562
563
	/**
564
	 * @inheritdoc
565
	 */
566
	public function getUsername()
567
	{
568
		return $this->getAttribute('username');
569
	}
570
571
	/**
572
	 * @inheritdoc
573
	 */
574
	public function getName()
575
	{
576
		$username = $this->getAttribute('username');
577
		return $username ? $username : $this->getEmail();
578
	}
579
580
	/**
581
	 * @inheritdoc
582
	 */
583
	public function getEmail()
584
	{
585
		return $this->getAttribute('email');
586
	}
587
588
	/**
589
	 * @inheritdoc
590
	 */
591
	public function getPasswordHash()
592
	{
593
		return $this->getAttribute('password_hash');
594
	}
595
596
	/**
597
	 * Set the super property
598
	 * @param $value
599
	 */
600
	public function __set_super($value)
601
	{
602
		// only allow other super uses to make super users!
603
		if (neon()->user->isSuper()) {
604
			$this->setAttribute('super', $value);
605
		}
606
	}
607
608
	/**
609
	 * @inheritdoc
610
	 */
611
	public function isSuper()
612
	{
613
		return $this->getAttribute('super');
614
	}
615
616
	/**
617
	 * @inheritdoc
618
	 */
619
	public function attributeHints()
620
	{
621
		return [
622
			'username' => 'A unique username',
623
			'super' => 'Make this user a super user, a super user automatically has permission to do everything'
624
		];
625
	}
626
627
	/**
628
	 * @inheritDoc
629
	 */
630
	public function getImageUrl($size=40)
631
	{
632
		return Html::gravatar($this->email, $size, 'mm', 'g');
633
	}
634
}
635