1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace Charcoal\User; |
4
|
|
|
|
5
|
|
|
use DateTime; |
6
|
|
|
use DateTimeInterface; |
7
|
|
|
use InvalidArgumentException; |
8
|
|
|
|
9
|
|
|
// From 'charcoal-core' |
10
|
|
|
use Charcoal\Model\AbstractModel; |
11
|
|
|
|
12
|
|
|
// From 'charcoal-object' |
13
|
|
|
use Charcoal\Object\TimestampableInterface; |
14
|
|
|
use Charcoal\Object\TimestampableTrait; |
15
|
|
|
|
16
|
|
|
// From 'charcoal-user' |
17
|
|
|
use Charcoal\User\AuthTokenMetadata; |
18
|
|
|
|
19
|
|
|
/** |
20
|
|
|
* Base Authorization Token |
21
|
|
|
*/ |
22
|
|
|
abstract class AbstractAuthToken extends AbstractModel implements |
23
|
|
|
AuthTokenInterface, |
24
|
|
|
TimestampableInterface |
25
|
|
|
{ |
26
|
|
|
use TimestampableTrait; |
27
|
|
|
|
28
|
|
|
/** |
29
|
|
|
* The token key. |
30
|
|
|
* |
31
|
|
|
* @var string |
32
|
|
|
*/ |
33
|
|
|
private $ident; |
34
|
|
|
|
35
|
|
|
/** |
36
|
|
|
* The token value. |
37
|
|
|
* |
38
|
|
|
* @var string |
39
|
|
|
*/ |
40
|
|
|
private $token; |
41
|
|
|
|
42
|
|
|
/** |
43
|
|
|
* The related user ID. |
44
|
|
|
* |
45
|
|
|
* @var string |
46
|
|
|
*/ |
47
|
|
|
private $userId; |
48
|
|
|
|
49
|
|
|
/** |
50
|
|
|
* The token's expiration date. |
51
|
|
|
* |
52
|
|
|
* @var DateTimeInterface|null |
53
|
|
|
*/ |
54
|
|
|
private $expiry; |
55
|
|
|
|
56
|
|
|
/** |
57
|
|
|
* @return string |
58
|
|
|
*/ |
59
|
|
|
public function key() |
60
|
|
|
{ |
61
|
|
|
return 'ident'; |
62
|
|
|
} |
63
|
|
|
|
64
|
|
|
/** |
65
|
|
|
* @param string $ident The token ident. |
66
|
|
|
* @return self |
67
|
|
|
*/ |
68
|
|
|
public function setIdent($ident) |
69
|
|
|
{ |
70
|
|
|
$this->ident = $ident; |
71
|
|
|
return $this; |
72
|
|
|
} |
73
|
|
|
|
74
|
|
|
/** |
75
|
|
|
* @return string |
76
|
|
|
*/ |
77
|
|
|
public function getIdent() |
78
|
|
|
{ |
79
|
|
|
return $this->ident; |
80
|
|
|
} |
81
|
|
|
|
82
|
|
|
/** |
83
|
|
|
* @param string $token The token. |
84
|
|
|
* @return self |
85
|
|
|
*/ |
86
|
|
|
public function setToken($token) |
87
|
|
|
{ |
88
|
|
|
$this->token = $token; |
89
|
|
|
return $this; |
90
|
|
|
} |
91
|
|
|
|
92
|
|
|
/** |
93
|
|
|
* @return string |
94
|
|
|
*/ |
95
|
|
|
public function getToken() |
96
|
|
|
{ |
97
|
|
|
return $this->token; |
98
|
|
|
} |
99
|
|
|
|
100
|
|
|
/** |
101
|
|
|
* @param string $id The user ID. |
102
|
|
|
* @throws InvalidArgumentException If the user ID is not a string. |
103
|
|
|
* @return self |
104
|
|
|
*/ |
105
|
|
|
public function setUserId($id) |
106
|
|
|
{ |
107
|
|
|
if (!is_string($id)) { |
108
|
|
|
throw new InvalidArgumentException( |
109
|
|
|
'Set User ID: identifier must be a string' |
110
|
|
|
); |
111
|
|
|
} |
112
|
|
|
|
113
|
|
|
$this->userId = $id; |
114
|
|
|
return $this; |
115
|
|
|
} |
116
|
|
|
|
117
|
|
|
/** |
118
|
|
|
* @return string |
119
|
|
|
*/ |
120
|
|
|
public function getUserId() |
121
|
|
|
{ |
122
|
|
|
return $this->userId; |
123
|
|
|
} |
124
|
|
|
|
125
|
|
|
/** |
126
|
|
|
* @param DateTimeInterface|string|null $expiry The date/time at object's creation. |
127
|
|
|
* @throws InvalidArgumentException If the date/time is invalid. |
128
|
|
|
* @return self |
129
|
|
|
*/ |
130
|
|
View Code Duplication |
public function setExpiry($expiry) |
|
|
|
|
131
|
|
|
{ |
132
|
|
|
if ($expiry === null) { |
133
|
|
|
$this->expiry = null; |
134
|
|
|
return $this; |
135
|
|
|
} |
136
|
|
|
|
137
|
|
|
if (is_string($expiry)) { |
138
|
|
|
$expiry = new DateTime($expiry); |
139
|
|
|
} |
140
|
|
|
|
141
|
|
|
if (!($expiry instanceof DateTimeInterface)) { |
142
|
|
|
throw new InvalidArgumentException( |
143
|
|
|
'Invalid "Expiry" value. Must be a date/time string or a DateTime object.' |
144
|
|
|
); |
145
|
|
|
} |
146
|
|
|
|
147
|
|
|
$this->expiry = $expiry; |
148
|
|
|
return $this; |
149
|
|
|
} |
150
|
|
|
|
151
|
|
|
/** |
152
|
|
|
* @return DateTimeInterface|null |
153
|
|
|
*/ |
154
|
|
|
public function getExpiry() |
155
|
|
|
{ |
156
|
|
|
return $this->expiry; |
|
|
|
|
157
|
|
|
} |
158
|
|
|
|
159
|
|
|
/** |
160
|
|
|
* Generate auth token data for the given user ID. |
161
|
|
|
* |
162
|
|
|
* Note: the `random_bytes()` function is new to PHP-7. Available in PHP 5 with `compat-random`. |
163
|
|
|
* |
164
|
|
|
* @param string $userId The user ID to generate the auth token from. |
165
|
|
|
* @return self |
166
|
|
|
*/ |
167
|
|
|
public function generate($userId) |
168
|
|
|
{ |
169
|
|
|
if (!$this->isEnabled()) { |
170
|
|
|
return $this; |
171
|
|
|
} |
172
|
|
|
|
173
|
|
|
$metadata = $this->metadata(); |
174
|
|
|
|
175
|
|
|
$this['ident'] = bin2hex(random_bytes(16)); |
176
|
|
|
$this['token'] = bin2hex(random_bytes(32)); |
177
|
|
|
$this['userId'] = $userId; |
178
|
|
|
$this['expiry'] = 'now + '.$metadata['tokenDuration']; |
179
|
|
|
|
180
|
|
|
return $this; |
181
|
|
|
} |
182
|
|
|
|
183
|
|
|
/** |
184
|
|
|
* Determine if authentication by token is supported. |
185
|
|
|
* |
186
|
|
|
* @return boolean |
187
|
|
|
*/ |
188
|
|
|
public function isEnabled() |
189
|
|
|
{ |
190
|
|
|
return $this->metadata()['enabled']; |
191
|
|
|
} |
192
|
|
|
|
193
|
|
|
/** |
194
|
|
|
* Determine if authentication by token should be only over HTTPS. |
195
|
|
|
* |
196
|
|
|
* @return boolean |
197
|
|
|
*/ |
198
|
|
|
public function isSecure() |
199
|
|
|
{ |
200
|
|
|
return $this->metadata()['httpsOnly']; |
201
|
|
|
} |
202
|
|
|
|
203
|
|
|
/** |
204
|
|
|
* @param mixed $ident The auth-token identifier. |
205
|
|
|
* @param string $token The token to validate against. |
206
|
|
|
* @return mixed The user id. An empty string if no token match. |
207
|
|
|
*/ |
208
|
|
|
public function getUserIdFromToken($ident, $token) |
209
|
|
|
{ |
210
|
|
|
if (!$this->isEnabled()) { |
211
|
|
|
return null; |
212
|
|
|
} |
213
|
|
|
|
214
|
|
|
if (!$this->source()->tableExists()) { |
|
|
|
|
215
|
|
|
$this->logger->warning(sprintf( |
216
|
|
|
'[AuthToken] Invalid login attempt from token "%s": The table "%s" does not exist.', |
217
|
|
|
$ident, |
218
|
|
|
$this->source()->table() |
|
|
|
|
219
|
|
|
)); |
220
|
|
|
return null; |
221
|
|
|
} |
222
|
|
|
|
223
|
|
|
$this->load($ident); |
224
|
|
|
if (!$this['ident']) { |
225
|
|
|
$this->logger->warning(sprintf( |
226
|
|
|
'[AuthToken] Token not found: "%s"', |
227
|
|
|
$ident |
228
|
|
|
)); |
229
|
|
|
return null; |
230
|
|
|
} |
231
|
|
|
|
232
|
|
|
// Expired token |
233
|
|
|
$now = new DateTime('now'); |
234
|
|
|
if (!$this['expiry'] || $now > $this['expiry']) { |
235
|
|
|
$this->logger->warning( |
236
|
|
|
'[AuthToken] Token expired', |
237
|
|
|
$ident |
238
|
|
|
); |
239
|
|
|
$this->delete(); |
240
|
|
|
return null; |
241
|
|
|
} |
242
|
|
|
|
243
|
|
|
// Validate encrypted token |
244
|
|
|
if (password_verify($token, $this['token']) !== true) { |
245
|
|
|
$this->panic(); |
246
|
|
|
$this->delete(); |
247
|
|
|
return null; |
248
|
|
|
} |
249
|
|
|
|
250
|
|
|
// Success! |
251
|
|
|
return $this['userId']; |
252
|
|
|
} |
253
|
|
|
|
254
|
|
|
/** |
255
|
|
|
* Delete all auth tokens from storage for the current user. |
256
|
|
|
* |
257
|
|
|
* @return void |
258
|
|
|
*/ |
259
|
|
|
public function deleteUserAuthTokens() |
260
|
|
|
{ |
261
|
|
|
$userId = $this['userId']; |
262
|
|
|
if (isset($userId)) { |
263
|
|
|
$source = $this->source(); |
264
|
|
|
|
265
|
|
|
if (!$source->tableExists()) { |
266
|
|
|
return; |
267
|
|
|
} |
268
|
|
|
|
269
|
|
|
$sql = sprintf( |
270
|
|
|
'DELETE FROM `%s` WHERE user_id = :userId', |
271
|
|
|
$source->table() |
272
|
|
|
); |
273
|
|
|
$source->dbQuery($sql, [ |
274
|
|
|
'userId' => $userId, |
275
|
|
|
]); |
276
|
|
|
} |
277
|
|
|
} |
278
|
|
|
|
279
|
|
|
/** |
280
|
|
|
* Something is seriously wrong: a auth ident was in the database but with a tampered token. |
281
|
|
|
* |
282
|
|
|
* @return void |
283
|
|
|
*/ |
284
|
|
|
protected function panic() |
285
|
|
|
{ |
286
|
|
|
$this->logger->error( |
287
|
|
|
'[AuthToken] Possible security breach: an authentication token was found in the database but its token does not match.' |
288
|
|
|
); |
289
|
|
|
|
290
|
|
|
$this->deleteUserAuthTokens(); |
291
|
|
|
} |
292
|
|
|
|
293
|
|
|
/** |
294
|
|
|
* @see \Charcoal\Source\StorableTrait::preSave() |
295
|
|
|
* |
296
|
|
|
* @return boolean |
297
|
|
|
*/ |
298
|
|
|
protected function preSave() |
299
|
|
|
{ |
300
|
|
|
$result = parent::preSave(); |
301
|
|
|
|
302
|
|
|
$this->touchToken(); |
303
|
|
|
|
304
|
|
|
$this['created'] = 'now'; |
305
|
|
|
$this['lastModified'] = 'now'; |
306
|
|
|
|
307
|
|
|
return $result; |
308
|
|
|
} |
309
|
|
|
|
310
|
|
|
/** |
311
|
|
|
* @see \Charcoal\Source\StorableTrait::preUpdate() |
312
|
|
|
* |
313
|
|
|
* @param array $properties The properties (ident) set for update. |
314
|
|
|
* @return boolean |
315
|
|
|
*/ |
316
|
|
|
protected function preUpdate(array $properties = null) |
317
|
|
|
{ |
318
|
|
|
$result = parent::preUpdate($properties); |
319
|
|
|
|
320
|
|
|
$this['lastModified'] = 'now'; |
321
|
|
|
|
322
|
|
|
return $result; |
323
|
|
|
} |
324
|
|
|
|
325
|
|
|
/** |
326
|
|
|
* @return void |
327
|
|
|
*/ |
328
|
|
|
protected function touchToken() |
329
|
|
|
{ |
330
|
|
|
$token = $this['token']; |
331
|
|
|
if (password_needs_rehash($token, PASSWORD_DEFAULT)) { |
332
|
|
|
$this['token'] = password_hash($token, PASSWORD_DEFAULT); |
333
|
|
|
} |
334
|
|
|
} |
335
|
|
|
|
336
|
|
|
/** |
337
|
|
|
* Create a new metadata object. |
338
|
|
|
* |
339
|
|
|
* @param array $data Optional metadata to merge on the object. |
340
|
|
|
* @return AuthTokenMetadata |
341
|
|
|
*/ |
342
|
|
|
protected function createMetadata(array $data = null) |
343
|
|
|
{ |
344
|
|
|
$class = $this->metadataClass(); |
345
|
|
|
return new $class($data); |
346
|
|
|
} |
347
|
|
|
|
348
|
|
|
/** |
349
|
|
|
* Retrieve the class name of the metadata object. |
350
|
|
|
* |
351
|
|
|
* @return string |
352
|
|
|
*/ |
353
|
|
|
protected function metadataClass() |
354
|
|
|
{ |
355
|
|
|
return AuthTokenMetadata::class; |
356
|
|
|
} |
357
|
|
|
} |
358
|
|
|
|
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.