1: <?php
2:
3: namespace Charcoal\User;
4:
5: // Dependencies from `PHP`
6: use DateTime;
7: use DateTimeInterface;
8: use Exception;
9: use InvalidArgumentException;
10:
11: // Module `charcoal-factory` dependencies
12: use Charcoal\Factory\FactoryInterface;
13:
14: // Module `charcoal-config` dependencies
15: use Charcoal\Config\ConfigurableInterface;
16: use Charcoal\Config\ConfigurableTrait;
17:
18: // Module `charcoal-base` dependencies
19: use Charcoal\Object\Content;
20:
21: // Local namespace (charcoal-base) dependencies
22: use Charcoal\User\UserInterface;
23:
24: /**
25: * Full implementation, as abstract class, of the `UserInterface`.
26: */
27: abstract class AbstractUser extends Content implements
28: UserInterface,
29: ConfigurableInterface
30: {
31: use ConfigurableTrait;
32:
33: /**
34: * @var UserInterface $authenticatedUser
35: */
36: protected static $authenticatedUser;
37:
38: /**
39: * The username should be unique and mandatory.
40: * It is also used as login name and main identifier (key).
41: *
42: * @var string
43: */
44: private $username = '';
45:
46: /**
47: * The password is stored encrypted in the (database) storage.
48: * @var string $password
49: */
50: private $password;
51:
52: /**
53: * @var string
54: */
55: private $email;
56:
57: /**
58: * @var boolean
59: */
60: private $active = true;
61:
62: /**
63: * @var string[]
64: */
65: private $roles = [];
66:
67: /**
68: * The date of the latest (successful) login
69: * @var DateTime|null
70: */
71: private $lastLoginDate;
72:
73: /**
74: * @var string
75: */
76: private $lastLoginIp;
77:
78: /**
79: * The date of the latest password change
80: * @var DateTime|null
81: */
82: private $lastPasswordDate;
83:
84: /**
85: * @var string $lastPasswordIp
86: */
87: private $lastPasswordIp;
88:
89: /**
90: * If the login token is set (not empty), then the user should be prompted to
91: * reset his password after login / enter the token to continue
92: * @var string $loginToken
93: */
94: private $loginToken = '';
95:
96: /**
97: * IndexableTrait > key()
98: *
99: * @return string
100: */
101: public function key()
102: {
103: return 'username';
104: }
105:
106: /**
107: * Force a lowercase username
108: *
109: * @param string $username The username (also the login name).
110: * @throws InvalidArgumentException If the username is not a string.
111: * @return User Chainable
112: */
113: public function setUsername($username)
114: {
115: if (!is_string($username)) {
116: throw new InvalidArgumentException(
117: 'Set user username: Username must be a string'
118: );
119: }
120: $this->username = mb_strtolower($username);
121: return $this;
122: }
123:
124: /**
125: * @return string
126: */
127: public function username()
128: {
129: return $this->username;
130: }
131:
132: /**
133: * @param string $email The user email.
134: * @throws InvalidArgumentException If the email is not a string.
135: * @return User Chainable
136: */
137: public function setEmail($email)
138: {
139: if (!is_string($email)) {
140: throw new InvalidArgumentException(
141: 'Set user email: Email must be a string'
142: );
143: }
144: $this->email = $email;
145: return $this;
146: }
147:
148: /**
149: * @return string
150: */
151: public function email()
152: {
153: return $this->email;
154: }
155:
156: /**
157: * @param string|null $password The user password. Encrypted in storage.
158: * @throws InvalidArgumentException If the password is not a string (or null, to reset).
159: * @return UserInterface Chainable
160: */
161: public function setPassword($password)
162: {
163: if ($password === null) {
164: $this->password = $password;
165: } elseif (is_string($password)) {
166: $this->password = $password;
167: } else {
168: throw new InvalidArgumentException(
169: 'Set user password: Password must be a string'
170: );
171: }
172:
173: return $this;
174: }
175:
176: /**
177: * @return string
178: */
179: public function password()
180: {
181: return $this->password;
182: }
183:
184: /**
185: * @param boolean $active The active flag.
186: * @return UserInterface Chainable
187: */
188: public function setActive($active)
189: {
190: $this->active = !!$active;
191: return $this;
192: }
193: /**
194: * @return boolean
195: */
196: public function active()
197: {
198: return $this->active;
199: }
200:
201: /**
202: * @param string|string[]|null $roles The ACL roles this user belongs to.
203: * @throws InvalidArgumentException If the roles argument is invalid.
204: * @return AbstractUser Chainable
205: */
206: public function setRoles($roles)
207: {
208: if ($roles === null) {
209: $this->roles = [];
210: return $this;
211: }
212: if (is_string($roles)) {
213: $roles = explode(',', $roles);
214: }
215: if (!is_array($roles)) {
216: throw new InvalidArgumentException(
217: 'Roles must be a comma-separated string or an array'
218: );
219: }
220: $this->roles = $roles;
221: return $this;
222: }
223:
224: /**
225: * @return string[]
226: */
227: public function roles()
228: {
229: return $this->roles;
230: }
231:
232: /**
233: * @param string|DateTimeInterface|null $lastLoginDate The last login date.
234: * @throws InvalidArgumentException If the ts is not a valid date/time.
235: * @return AbstractUser Chainable
236: */
237: public function setLastLoginDate($lastLoginDate)
238: {
239: if ($lastLoginDate === null) {
240: $this->lastLoginDate = null;
241: return $this;
242: }
243: if (is_string($lastLoginDate)) {
244: try {
245: $lastLoginDate = new DateTime($lastLoginDate);
246: } catch (Exception $e) {
247: throw new InvalidArgumentException(
248: sprintf('Invalid login date (%s)', $e->getMessage())
249: );
250: }
251: }
252: if (!($lastLoginDate instanceof DateTimeInterface)) {
253: throw new InvalidArgumentException(
254: 'Invalid "Last Login Date" value. Must be a date/time string or a DateTime object.'
255: );
256: }
257: $this->lastLoginDate = $lastLoginDate;
258: return $this;
259: }
260:
261: /**
262: * @return DateTimeInterface|null
263: */
264: public function lastLoginDate()
265: {
266: return $this->lastLoginDate;
267: }
268:
269: /**
270: * @param string|integer|null $ip The last login IP address.
271: * @throws InvalidArgumentException If the IP is not an IP string, an integer, or null.
272: * @return UserInterface Chainable
273: */
274: public function setLastLoginIp($ip)
275: {
276: if ($ip === null) {
277: $this->lastLoginIp = null;
278: return $this;
279: }
280: if (is_int($ip)) {
281: $ip = long2ip($ip);
282: }
283: if (!is_string($ip)) {
284: throw new InvalidArgumentException(
285: 'Invalid IP address'
286: );
287: }
288: $this->lastLoginIp = $ip;
289: return $this;
290: }
291: /**
292: * Get the last login IP in x.x.x.x format
293: * @return string
294: */
295: public function lastLoginIp()
296: {
297: return $this->lastLoginIp;
298: }
299:
300: /**
301: * @param string|DateTimeInterface|null $lastPasswordDate The last password date.
302: * @throws InvalidArgumentException If the passsword date is not a valid DateTime.
303: * @return UserInterface Chainable
304: */
305: public function setLastPasswordDate($lastPasswordDate)
306: {
307: if ($lastPasswordDate === null) {
308: $this->lastPasswordDate = null;
309: return $this;
310: }
311: if (is_string($lastPasswordDate)) {
312: try {
313: $lastPasswordDate = new DateTime($lastPasswordDate);
314: } catch (Exception $e) {
315: throw new InvalidArgumentException(
316: sprintf('Invalid last password date (%s)', $e->getMessage())
317: );
318: }
319: }
320: if (!($lastPasswordDate instanceof DateTimeInterface)) {
321: throw new InvalidArgumentException(
322: 'Invalid "Last Password Date" value. Must be a date/time string or a DateTime object.'
323: );
324: }
325: $this->lastPasswordDate = $lastPasswordDate;
326: return $this;
327: }
328:
329: /**
330: * @return DateTimeInterface|null
331: */
332: public function lastPasswordDate()
333: {
334: return $this->lastPasswordDate;
335: }
336:
337: /**
338: * @param integer|string|null $ip The last password IP.
339: * @throws InvalidArgumentException If the IP is not null, an integer or an IP string.
340: * @return UserInterface Chainable
341: */
342: public function setLastPasswordIp($ip)
343: {
344: if ($ip === null) {
345: $this->lastPasswordIp = null;
346: return $this;
347: }
348: if (is_int($ip)) {
349: $ip = long2ip($ip);
350: }
351: if (!is_string($ip)) {
352: throw new InvalidArgumentException(
353: 'Invalid IP address'
354: );
355: }
356: $this->lastPasswordIp = $ip;
357: return $this;
358: }
359: /**
360: * Get the last password change IP in x.x.x.x format
361: *
362: * @return string
363: */
364: public function lastPasswordIp()
365: {
366: return $this->lastPasswordIp;
367: }
368:
369: /**
370: * @param string $token The login token.
371: * @throws InvalidArgumentException If the token is not a string.
372: * @return UserInterface Chainable
373: */
374: public function setLoginToken($token)
375: {
376: if ($token === null) {
377: $this->loginToken = null;
378: return $this;
379: }
380: if (!is_string($token)) {
381: throw new InvalidArgumentException(
382: 'Login Token must be a string'
383: );
384: }
385: $this->loginToken = $token;
386: return $this;
387: }
388:
389: /**
390: * @return string
391: */
392: public function loginToken()
393: {
394: return $this->loginToken;
395: }
396:
397: /**
398: * @throws Exception If trying to save a user to session without a ID.
399: * @return UserInterface Chainable
400: */
401: public function saveToSession()
402: {
403: if (!$this->id()) {
404: throw new Exception(
405: 'Can not set auth user; no user ID'
406: );
407: }
408: $_SESSION[static::sessionKey()] = $this->id();
409: return $this;
410: }
411:
412: /**
413: * Log in the user (in session)
414: *
415: * Called when the authentication is successful.
416: *
417: * @return boolean Success / Failure
418: */
419: public function login()
420: {
421: if (!$this->id()) {
422: return false;
423: }
424:
425: $this->setLastLoginDate('now');
426: $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
427: if ($ip) {
428: $this->setLastLoginIp($ip);
429: }
430: $this->update(['last_login_ip', 'last_login_date']);
431:
432: $this->saveToSession();
433:
434: return true;
435: }
436:
437: /**
438: * @return boolean
439: */
440: public function logLogin()
441: {
442: // @todo
443: return true;
444: }
445:
446: /**
447: * Failed authentication callback
448: *
449: * @param string $username The failed username.
450: * @return void
451: */
452: public function loginFailed($username)
453: {
454: $this->setUsername('');
455:
456: $this->logLoginFailed($username);
457: }
458:
459: /**
460: * @param string $username The username to log failure.
461: * @return boolean
462: */
463: public function logLoginFailed($username)
464: {
465: // @todo
466: return true;
467: }
468:
469: /**
470: * Empties the session var associated to the session key.
471: *
472: * @return boolean Logged out or not.
473: */
474: public function logout()
475: {
476: // Irrelevant call...
477: if (!$this->id()) {
478: return false;
479: }
480:
481: $_SESSION[static::sessionKey()] = null;
482: unset($_SESSION[static::sessionKey()]);
483:
484: return true;
485: }
486:
487: /**
488: * Reset the password.
489: *
490: * Encrypt the password and re-save the object in the database.
491: * Also updates the last password date & ip.
492: *
493: * @param string $plainPassword The plain (non-encrypted) password to reset to.
494: * @throws InvalidArgumentException If the plain password is not a string.
495: * @return UserInterface Chainable
496: */
497: public function resetPassword($plainPassword)
498: {
499: if (!is_string($plainPassword)) {
500: throw new InvalidArgumentException(
501: 'Can not change password: password is not a string.'
502: );
503: }
504:
505: $hash = password_hash($plainPassword, PASSWORD_DEFAULT);
506: $this->setPassword($hash);
507:
508: $this->setLastPasswordDate('now');
509: $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
510: if ($ip) {
511: $this->setLastPasswordIp($ip);
512: }
513:
514: if ($this->id()) {
515: $this->update(['password', 'last_password_date', 'last_password_ip']);
516: }
517:
518: return $this;
519: }
520:
521: /**
522: * Get the currently authenticated user (from session)
523: *
524: * Return null if there is no current user in logged into
525: *
526: * @param FactoryInterface $factory The factory to create the user object with.
527: * @throws Exception If the user from session is invalid.
528: * @return UserInterface|null
529: */
530: public static function getAuthenticated(FactoryInterface $factory)
531: {
532: if (isset(static::$authenticatedUser[static::sessionKey()])) {
533: return static::$authenticatedUser[static::sessionKey()];
534: }
535:
536: if (!isset($_SESSION[static::sessionKey()])) {
537: return null;
538: }
539:
540: $userId = $_SESSION[static::sessionKey()];
541: if (!$userId) {
542: return null;
543: }
544:
545: $userClass = get_called_class();
546: $user = $factory->create($userClass);
547: $user->load($userId);
548:
549: // Inactive users can not authenticate
550: if (!$user->id() || !$user->username() || !$user->active()) {
551: // @todo log error
552: return null;
553: }
554:
555: static::$authenticatedUser[static::sessionKey()] = $user;
556: return $user;
557: }
558: }
559: