|
1
|
|
|
<?php |
|
2
|
|
|
|
|
3
|
|
|
namespace Charcoal\User; |
|
4
|
|
|
|
|
5
|
|
|
use \DateTime; |
|
6
|
|
|
use \DateTimeInterface; |
|
7
|
|
|
use \InvalidArgumentException; |
|
8
|
|
|
|
|
9
|
|
|
use \Charcoal\Model\AbstractModel; |
|
10
|
|
|
|
|
11
|
|
|
use \Charcoal\User\AuthTokenMetadata; |
|
12
|
|
|
|
|
13
|
|
|
/** |
|
14
|
|
|
* Authorization token; to keep a user logged in |
|
15
|
|
|
*/ |
|
16
|
|
|
class AuthToken extends AbstractModel |
|
17
|
|
|
{ |
|
18
|
|
|
|
|
19
|
|
|
/** |
|
20
|
|
|
* @var string $ident |
|
21
|
|
|
*/ |
|
22
|
|
|
private $ident; |
|
23
|
|
|
|
|
24
|
|
|
/** |
|
25
|
|
|
* @var string $token |
|
26
|
|
|
*/ |
|
27
|
|
|
private $token; |
|
28
|
|
|
|
|
29
|
|
|
/** |
|
30
|
|
|
* The username should be unique and mandatory. |
|
31
|
|
|
* @var string $username |
|
32
|
|
|
*/ |
|
33
|
|
|
private $username; |
|
34
|
|
|
|
|
35
|
|
|
/** |
|
36
|
|
|
* @var Datetime $expiry |
|
37
|
|
|
*/ |
|
38
|
|
|
private $expiry; |
|
39
|
|
|
|
|
40
|
|
|
/** |
|
41
|
|
|
* Token creation date (set automatically on save) |
|
42
|
|
|
* @var DateTime $Created |
|
43
|
|
|
*/ |
|
44
|
|
|
private $created; |
|
45
|
|
|
|
|
46
|
|
|
/** |
|
47
|
|
|
* Token last modified date (set automatically on save and update) |
|
48
|
|
|
* @var DateTime $LastModified |
|
49
|
|
|
*/ |
|
50
|
|
|
private $lastModified; |
|
51
|
|
|
|
|
52
|
|
|
/** |
|
53
|
|
|
* @return string |
|
54
|
|
|
*/ |
|
55
|
|
|
public function key() |
|
56
|
|
|
{ |
|
57
|
|
|
return 'ident'; |
|
58
|
|
|
} |
|
59
|
|
|
|
|
60
|
|
|
/** |
|
61
|
|
|
* @param string $ident The token ident. |
|
62
|
|
|
* @return AuthToken Chainable |
|
63
|
|
|
*/ |
|
64
|
|
|
public function setIdent($ident) |
|
65
|
|
|
{ |
|
66
|
|
|
$this->ident = $ident; |
|
67
|
|
|
return $this; |
|
68
|
|
|
} |
|
69
|
|
|
|
|
70
|
|
|
/** |
|
71
|
|
|
* @return string |
|
72
|
|
|
*/ |
|
73
|
|
|
public function ident() |
|
74
|
|
|
{ |
|
75
|
|
|
return $this->ident; |
|
76
|
|
|
} |
|
77
|
|
|
|
|
78
|
|
|
/** |
|
79
|
|
|
* @param string $token The token. |
|
80
|
|
|
* @return AuthToken Chainable |
|
81
|
|
|
*/ |
|
82
|
|
|
public function setToken($token) |
|
83
|
|
|
{ |
|
84
|
|
|
$this->token = $token; |
|
85
|
|
|
return $this; |
|
86
|
|
|
} |
|
87
|
|
|
|
|
88
|
|
|
/** |
|
89
|
|
|
* @return string |
|
90
|
|
|
*/ |
|
91
|
|
|
public function token() |
|
92
|
|
|
{ |
|
93
|
|
|
return $this->token; |
|
94
|
|
|
} |
|
95
|
|
|
|
|
96
|
|
|
|
|
97
|
|
|
/** |
|
98
|
|
|
* Force a lowercase username |
|
99
|
|
|
* |
|
100
|
|
|
* @param string $username The username (also the login name). |
|
101
|
|
|
* @throws InvalidArgumentException If the username is not a string. |
|
102
|
|
|
* @return User Chainable |
|
103
|
|
|
*/ |
|
104
|
|
View Code Duplication |
public function setUsername($username) |
|
|
|
|
|
|
105
|
|
|
{ |
|
106
|
|
|
if (!is_string($username)) { |
|
107
|
|
|
throw new InvalidArgumentException( |
|
108
|
|
|
'Set user username: Username must be a string' |
|
109
|
|
|
); |
|
110
|
|
|
} |
|
111
|
|
|
$this->username = mb_strtolower($username); |
|
112
|
|
|
return $this; |
|
113
|
|
|
} |
|
114
|
|
|
|
|
115
|
|
|
/** |
|
116
|
|
|
* @return string |
|
117
|
|
|
*/ |
|
118
|
|
|
public function username() |
|
119
|
|
|
{ |
|
120
|
|
|
return $this->username; |
|
121
|
|
|
} |
|
122
|
|
|
|
|
123
|
|
|
/** |
|
124
|
|
|
* @param DateTime|string|null $expiry The date/time at object's creation. |
|
125
|
|
|
* @throws InvalidArgumentException If the date/time is invalid. |
|
126
|
|
|
* @return Content Chainable |
|
127
|
|
|
*/ |
|
128
|
|
|
public function setExpiry($expiry) |
|
129
|
|
|
{ |
|
130
|
|
|
if ($expiry === null) { |
|
131
|
|
|
$this->expiry = null; |
|
132
|
|
|
return $this; |
|
133
|
|
|
} |
|
134
|
|
|
if (is_string($expiry)) { |
|
135
|
|
|
$expiry = new DateTime($expiry); |
|
136
|
|
|
} |
|
137
|
|
|
if (!($expiry instanceof DateTimeInterface)) { |
|
138
|
|
|
throw new InvalidArgumentException( |
|
139
|
|
|
'Invalid "Expiry" value. Must be a date/time string or a DateTime object.' |
|
140
|
|
|
); |
|
141
|
|
|
} |
|
142
|
|
|
$this->expiry = $expiry; |
|
|
|
|
|
|
143
|
|
|
return $this; |
|
144
|
|
|
} |
|
145
|
|
|
|
|
146
|
|
|
/** |
|
147
|
|
|
* @return DateTimeInterface|null |
|
148
|
|
|
*/ |
|
149
|
|
|
public function expiry() |
|
150
|
|
|
{ |
|
151
|
|
|
return $this->expiry; |
|
152
|
|
|
} |
|
153
|
|
|
|
|
154
|
|
|
/** |
|
155
|
|
|
* @param DateTime|string|null $created The date/time at object's creation. |
|
156
|
|
|
* @throws InvalidArgumentException If the date/time is invalid. |
|
157
|
|
|
* @return Content Chainable |
|
158
|
|
|
*/ |
|
159
|
|
|
public function setCreated($created) |
|
160
|
|
|
{ |
|
161
|
|
|
if ($created === null) { |
|
162
|
|
|
$this->created = null; |
|
163
|
|
|
return $this; |
|
164
|
|
|
} |
|
165
|
|
|
if (is_string($created)) { |
|
166
|
|
|
$created = new DateTime($created); |
|
167
|
|
|
} |
|
168
|
|
|
if (!($created instanceof DateTimeInterface)) { |
|
169
|
|
|
throw new InvalidArgumentException( |
|
170
|
|
|
'Invalid "Created" value. Must be a date/time string or a DateTime object.' |
|
171
|
|
|
); |
|
172
|
|
|
} |
|
173
|
|
|
$this->created = $created; |
|
174
|
|
|
return $this; |
|
175
|
|
|
} |
|
176
|
|
|
|
|
177
|
|
|
/** |
|
178
|
|
|
* @return DateTime|null |
|
179
|
|
|
*/ |
|
180
|
|
|
public function created() |
|
181
|
|
|
{ |
|
182
|
|
|
return $this->created; |
|
183
|
|
|
} |
|
184
|
|
|
|
|
185
|
|
|
/** |
|
186
|
|
|
* @param DateTime|string|null $lastModified The last modified date/time. |
|
187
|
|
|
* @throws InvalidArgumentException If the date/time is invalid. |
|
188
|
|
|
* @return Content Chainable |
|
189
|
|
|
*/ |
|
190
|
|
|
public function setLastModified($lastModified) |
|
191
|
|
|
{ |
|
192
|
|
|
if ($lastModified === null) { |
|
193
|
|
|
$this->lastModified = null; |
|
194
|
|
|
return $this; |
|
195
|
|
|
} |
|
196
|
|
|
if (is_string($lastModified)) { |
|
197
|
|
|
$lastModified = new DateTime($lastModified); |
|
198
|
|
|
} |
|
199
|
|
|
if (!($lastModified instanceof DateTimeInterface)) { |
|
200
|
|
|
throw new InvalidArgumentException( |
|
201
|
|
|
'Invalid "Last Modified" value. Must be a date/time string or a DateTime object.' |
|
202
|
|
|
); |
|
203
|
|
|
} |
|
204
|
|
|
$this->lastModified = $lastModified; |
|
205
|
|
|
return $this; |
|
206
|
|
|
} |
|
207
|
|
|
|
|
208
|
|
|
/** |
|
209
|
|
|
* @return DateTime |
|
210
|
|
|
*/ |
|
211
|
|
|
public function lastModified() |
|
212
|
|
|
{ |
|
213
|
|
|
return $this->lastModified; |
|
214
|
|
|
} |
|
215
|
|
|
|
|
216
|
|
|
/** |
|
217
|
|
|
* Note: the `random_bytes()` function is new to PHP-7. Available in PHP 5 with `compat-random`. |
|
218
|
|
|
* |
|
219
|
|
|
* @param string $username The username to generate the auth token from. |
|
220
|
|
|
* @return AuthToken Chainable |
|
221
|
|
|
*/ |
|
222
|
|
|
public function generate($username) |
|
223
|
|
|
{ |
|
224
|
|
|
$this->setIdent(bin2hex(random_bytes(16))); |
|
225
|
|
|
$this->setToken(bin2hex(random_bytes(32))); |
|
226
|
|
|
$this->setUsername($username); |
|
227
|
|
|
$this->setExpiry('now + '.$this->metadata()->cookieDuration()); |
|
|
|
|
|
|
228
|
|
|
|
|
229
|
|
|
return $this; |
|
230
|
|
|
} |
|
231
|
|
|
|
|
232
|
|
|
/** |
|
233
|
|
|
* @return AuthToken Chainable |
|
234
|
|
|
*/ |
|
235
|
|
|
public function sendCookie() |
|
236
|
|
|
{ |
|
237
|
|
|
$cookieName = $this->metadata()->cookieName(); |
|
|
|
|
|
|
238
|
|
|
$value = $this->ident().';'.$this->token(); |
|
239
|
|
|
$expiry = $this->expiry()->getTimestamp(); |
|
240
|
|
|
$secure = $this->metadata()->httpsOnly(); |
|
|
|
|
|
|
241
|
|
|
|
|
242
|
|
|
setcookie($cookieName, $value, $expiry, '', '', $secure); |
|
243
|
|
|
|
|
244
|
|
|
return $this; |
|
245
|
|
|
} |
|
246
|
|
|
|
|
247
|
|
|
/** |
|
248
|
|
|
* StorableTrait > preSave(): Called automatically before saving the object to source. |
|
249
|
|
|
* @return boolean |
|
250
|
|
|
*/ |
|
251
|
|
|
public function preSave() |
|
252
|
|
|
{ |
|
253
|
|
|
parent::preSave(); |
|
254
|
|
|
|
|
255
|
|
|
if (password_needs_rehash($this->token, PASSWORD_DEFAULT)) { |
|
256
|
|
|
$this->token = password_hash($this->token, PASSWORD_DEFAULT); |
|
257
|
|
|
} |
|
258
|
|
|
$this->setCreated('now'); |
|
259
|
|
|
$this->setLastModified('now'); |
|
260
|
|
|
|
|
261
|
|
|
return true; |
|
262
|
|
|
} |
|
263
|
|
|
|
|
264
|
|
|
/** |
|
265
|
|
|
* StorableTrait > preUpdate(): Called automatically before updating the object to source. |
|
266
|
|
|
* @param array $properties The properties (ident) set for update. |
|
267
|
|
|
* @return boolean |
|
268
|
|
|
*/ |
|
269
|
|
|
public function preUpdate(array $properties = null) |
|
270
|
|
|
{ |
|
271
|
|
|
parent::preUpdate($properties); |
|
|
|
|
|
|
272
|
|
|
|
|
273
|
|
|
$this->setLastModified('now'); |
|
274
|
|
|
|
|
275
|
|
|
return true; |
|
276
|
|
|
} |
|
277
|
|
|
|
|
278
|
|
|
/** |
|
279
|
|
|
* @return array `['ident'=>'', 'token'=>''] |
|
280
|
|
|
*/ |
|
281
|
|
|
public function getTokenDataFromCookie() |
|
|
|
|
|
|
282
|
|
|
{ |
|
283
|
|
|
$cookieName = $this->metadata()->cookieName(); |
|
|
|
|
|
|
284
|
|
|
|
|
285
|
|
|
if (!isset($_COOKIE[$cookieName])) { |
|
286
|
|
|
return null; |
|
287
|
|
|
} |
|
288
|
|
|
|
|
289
|
|
|
$authCookie = $_COOKIE[$cookieName]; |
|
290
|
|
|
$vals = explode(';', $authCookie); |
|
291
|
|
|
if (!isset($vals[0]) || !isset($vals[1])) { |
|
292
|
|
|
return null; |
|
293
|
|
|
} |
|
294
|
|
|
|
|
295
|
|
|
return [ |
|
296
|
|
|
'ident' => $vals[0], |
|
297
|
|
|
'token' => $vals[1] |
|
298
|
|
|
]; |
|
299
|
|
|
} |
|
300
|
|
|
|
|
301
|
|
|
/** |
|
302
|
|
|
* @param mixed $ident The auth-token identifier. |
|
303
|
|
|
* @param string $token The token key to validate against. |
|
304
|
|
|
* @return mixed The user id. |
|
305
|
|
|
*/ |
|
306
|
|
|
public function getUserId($ident, $token) |
|
307
|
|
|
{ |
|
308
|
|
|
return $this->getUsernameFromToken($ident, $token); |
|
309
|
|
|
} |
|
310
|
|
|
|
|
311
|
|
|
/** |
|
312
|
|
|
* @param mixed $ident The auth-token identifier (username). |
|
313
|
|
|
* @param string $token The token to validate against. |
|
314
|
|
|
* @return mixed The user id. An empty string if no token match. |
|
315
|
|
|
*/ |
|
316
|
|
|
public function getUsernameFromToken($ident, $token) |
|
317
|
|
|
{ |
|
318
|
|
|
$this->load($ident); |
|
319
|
|
|
if (!$this->ident()) { |
|
320
|
|
|
$this->logger->warning(sprintf('Auth token not found: "%s"', $ident)); |
|
321
|
|
|
return ''; |
|
322
|
|
|
} |
|
323
|
|
|
|
|
324
|
|
|
// Expired cookie |
|
325
|
|
|
$now = new DateTime('now'); |
|
326
|
|
|
if (!$this->expiry() || $now > $this->expiry()) { |
|
327
|
|
|
$this->logger->warning('Expired auth token'); |
|
328
|
|
|
$this->delete(); |
|
329
|
|
|
return ''; |
|
330
|
|
|
} |
|
331
|
|
|
|
|
332
|
|
|
// Validate encrypted token |
|
333
|
|
|
if (password_verify($token, $this->token()) !== true) { |
|
334
|
|
|
$this->panic(); |
|
335
|
|
|
$this->delete(); |
|
336
|
|
|
return ''; |
|
337
|
|
|
} |
|
338
|
|
|
|
|
339
|
|
|
// Success! |
|
340
|
|
|
return $this->username(); |
|
341
|
|
|
} |
|
342
|
|
|
|
|
343
|
|
|
/** |
|
344
|
|
|
* Something is seriously wrong: a cookie ident was in the database but with a tampered token. |
|
345
|
|
|
* |
|
346
|
|
|
* @return void |
|
347
|
|
|
*/ |
|
348
|
|
|
protected function panic() |
|
349
|
|
|
{ |
|
350
|
|
|
// Todo: delete all user's token. |
|
351
|
|
|
// Gve a strongly-worded error message. |
|
352
|
|
|
|
|
353
|
|
|
$this->logger->error( |
|
354
|
|
|
'Possible security breach: an authentication token was found in the database but its token does not match.' |
|
355
|
|
|
); |
|
356
|
|
|
|
|
357
|
|
|
if ($this->username) { |
|
358
|
|
|
$table = $this->source()->table(); |
|
|
|
|
|
|
359
|
|
|
$q = ' |
|
360
|
|
|
delete from |
|
361
|
|
|
'.$table.' |
|
362
|
|
|
where |
|
363
|
|
|
username = :username'; |
|
364
|
|
|
$this->source()->dbQuery($q, [ |
|
|
|
|
|
|
365
|
|
|
'username'=>$this->username() |
|
366
|
|
|
]); |
|
367
|
|
|
} |
|
368
|
|
|
} |
|
369
|
|
|
|
|
370
|
|
|
/** |
|
371
|
|
|
* DescribableTrait > create_metadata(). |
|
372
|
|
|
* |
|
373
|
|
|
* @param array $data Optional data to intialize the Metadata object with. |
|
374
|
|
|
* @return MetadataInterface |
|
375
|
|
|
*/ |
|
376
|
|
|
protected function createMetadata(array $data = null) |
|
377
|
|
|
{ |
|
378
|
|
|
$metadata = new AuthTokenMetadata(); |
|
379
|
|
|
if ($data !== null) { |
|
380
|
|
|
$metadata->setData($data); |
|
|
|
|
|
|
381
|
|
|
} |
|
382
|
|
|
return $metadata; |
|
383
|
|
|
} |
|
384
|
|
|
} |
|
385
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.