Passed
Push — master ( 927536...4ad0b4 )
by Chauncey
07:28
created

AuthToken::metadataClass()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 0
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-user'
13
use Charcoal\User\AuthTokenMetadata;
14
15
/**
16
 * Authorization token; to keep a user logged in
17
 */
18
class AuthToken extends AbstractModel
0 ignored issues
show
Bug introduced by
There is at least one abstract method in this class. Maybe declare it as abstract, or implement the remaining methods: hasProperty, p, properties, property
Loading history...
19
{
20
    /**
21
     * @var string
22
     */
23
    private $ident;
24
25
    /**
26
     * @var string
27
     */
28
    private $token;
29
30
    /**
31
     * The username should be unique and mandatory.
32
     * @var string
33
     */
34
    private $username;
35
36
    /**
37
     * @var DateTimeInterface|null
38
     */
39
    private $expiry;
40
41
    /**
42
     * Token creation date (set automatically on save)
43
     * @var DateTimeInterface|null
44
     */
45
    private $created;
46
47
    /**
48
     * Token last modified date (set automatically on save and update)
49
     * @var DateTimeInterface|null
50
     */
51
    private $lastModified;
52
53
    /**
54
     * @return string
55
     */
56
    public function key()
57
    {
58
        return 'ident';
59
    }
60
61
    /**
62
     * @param string $ident The token ident.
63
     * @return AuthToken Chainable
64
     */
65
    public function setIdent($ident)
66
    {
67
        $this->ident = $ident;
68
        return $this;
69
    }
70
71
    /**
72
     * @return string
73
     */
74
    public function ident()
75
    {
76
        return $this->ident;
77
    }
78
79
    /**
80
     * @param string $token The token.
81
     * @return AuthToken Chainable
82
     */
83
    public function setToken($token)
84
    {
85
        $this->token = $token;
86
        return $this;
87
    }
88
89
    /**
90
     * @return string
91
     */
92
    public function token()
93
    {
94
        return $this->token;
95
    }
96
97
98
    /**
99
     * Force a lowercase username
100
     *
101
     * @param string $username The username (also the login name).
102
     * @throws InvalidArgumentException If the username is not a string.
103
     * @return AuthToken Chainable
104
     */
105 View Code Duplication
    public function setUsername($username)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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.

Loading history...
106
    {
107
        if (!is_string($username)) {
108
            throw new InvalidArgumentException(
109
                'Set user username: Username must be a string'
110
            );
111
        }
112
        $this->username = mb_strtolower($username);
113
        return $this;
114
    }
115
116
    /**
117
     * @return string
118
     */
119
    public function username()
120
    {
121
        return $this->username;
122
    }
123
124
    /**
125
     * @param DateTimeInterface|string|null $expiry The date/time at object's creation.
126
     * @throws InvalidArgumentException If the date/time is invalid.
127
     * @return AuthToken Chainable
128
     */
129 View Code Duplication
    public function setExpiry($expiry)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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.

Loading history...
130
    {
131
        if ($expiry === null) {
132
            $this->expiry = null;
133
            return $this;
134
        }
135
        if (is_string($expiry)) {
136
            $expiry = new DateTime($expiry);
137
        }
138
        if (!($expiry instanceof DateTimeInterface)) {
139
            throw new InvalidArgumentException(
140
                'Invalid "Expiry" value. Must be a date/time string or a DateTime object.'
141
            );
142
        }
143
        $this->expiry = $expiry;
144
        return $this;
145
    }
146
147
    /**
148
     * @return DateTimeInterface|null
149
     */
150
    public function expiry()
151
    {
152
        return $this->expiry;
153
    }
154
155
    /**
156
     * @param DateTimeInterface|string|null $created The date/time at object's creation.
157
     * @throws InvalidArgumentException If the date/time is invalid.
158
     * @return AuthToken Chainable
159
     */
160 View Code Duplication
    public function setCreated($created)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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.

Loading history...
161
    {
162
        if ($created === null) {
163
            $this->created = null;
164
            return $this;
165
        }
166
        if (is_string($created)) {
167
            $created = new DateTime($created);
168
        }
169
        if (!($created instanceof DateTimeInterface)) {
170
            throw new InvalidArgumentException(
171
                'Invalid "Created" value. Must be a date/time string or a DateTime object.'
172
            );
173
        }
174
        $this->created = $created;
175
        return $this;
176
    }
177
178
    /**
179
     * @return DateTimeInterface|null
180
     */
181
    public function created()
182
    {
183
        return $this->created;
184
    }
185
186
    /**
187
     * @param DateTimeInterface|string|null $lastModified The last modified date/time.
188
     * @throws InvalidArgumentException If the date/time is invalid.
189
     * @return AuthToken Chainable
190
     */
191 View Code Duplication
    public function setLastModified($lastModified)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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.

Loading history...
192
    {
193
        if ($lastModified === null) {
194
            $this->lastModified = null;
195
            return $this;
196
        }
197
        if (is_string($lastModified)) {
198
            $lastModified = new DateTime($lastModified);
199
        }
200
        if (!($lastModified instanceof DateTimeInterface)) {
201
            throw new InvalidArgumentException(
202
                'Invalid "Last Modified" value. Must be a date/time string or a DateTime object.'
203
            );
204
        }
205
        $this->lastModified = $lastModified;
206
        return $this;
207
    }
208
209
    /**
210
     * @return DateTimeInterface|null
211
     */
212
    public function lastModified()
213
    {
214
        return $this->lastModified;
215
    }
216
217
    /**
218
     * Note: the `random_bytes()` function is new to PHP-7. Available in PHP 5 with `compat-random`.
219
     *
220
     * @param string $username The username to generate the auth token from.
221
     * @return AuthToken Chainable
222
     */
223
    public function generate($username)
224
    {
225
        $metadata = $this->metadata();
226
        $this->setIdent(bin2hex(random_bytes(16)));
227
        $this->setToken(bin2hex(random_bytes(32)));
228
        $this->setUsername($username);
229
        $this->setExpiry('now + '.$metadata['cookieDuration']);
230
231
        return $this;
232
    }
233
234
    /**
235
     * @return AuthToken Chainable
236
     */
237
    public function sendCookie()
238
    {
239
        $metadata = $this->metadata();
240
        $cookieName = $metadata['cookieName'];
241
        $value = $this->ident().';'.$this->token();
242
        $expiry = $this->expiry() ? $this->expiry()->getTimestamp() : null;
243
        $secure = $metadata['httpsOnly'];
244
245
        setcookie($cookieName, $value, $expiry, '', '', $secure);
246
247
        return $this;
248
    }
249
250
    /**
251
     * @return array|null `['ident'=>'', 'token'=>'']
252
     */
253
    public function getTokenDataFromCookie()
0 ignored issues
show
Coding Style introduced by
getTokenDataFromCookie uses the super-global variable $_COOKIE which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
254
    {
255
        $metadata = $this->metadata();
256
        $cookieName = $metadata['cookieName'];
257
258
        if (!isset($_COOKIE[$cookieName])) {
259
            return null;
260
        }
261
262
        $authCookie = $_COOKIE[$cookieName];
263
        $vals = explode(';', $authCookie);
264
        if (!isset($vals[0]) || !isset($vals[1])) {
265
            return null;
266
        }
267
268
        return [
269
            'ident' => $vals[0],
270
            'token' => $vals[1]
271
        ];
272
    }
273
274
    /**
275
     * @param mixed  $ident The auth-token identifier.
276
     * @param string $token The token key to validate against.
277
     * @return mixed The user id.
278
     */
279
    public function getUserId($ident, $token)
280
    {
281
        return $this->getUsernameFromToken($ident, $token);
282
    }
283
284
    /**
285
     * @param mixed  $ident The auth-token identifier (username).
286
     * @param string $token The token to validate against.
287
     * @return mixed The user id. An empty string if no token match.
288
     */
289
    public function getUsernameFromToken($ident, $token)
290
    {
291
        if (!$this->source()->tableExists()) {
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Charcoal\Source\SourceInterface as the method tableExists() does only exist in the following implementations of said interface: Charcoal\Source\DatabaseSource.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
292
            return '';
293
        }
294
295
        $this->load($ident);
296
        if (!$this->ident()) {
297
            $this->logger->warning(sprintf(
298
                'Auth token not found: "%s"',
299
                $ident
300
            ));
301
            return '';
302
        }
303
304
        // Expired cookie
305
        $now = new DateTime('now');
306
        if (!$this->expiry() || $now > $this->expiry()) {
307
            $this->logger->warning('Expired auth token');
308
            $this->delete();
309
            return '';
310
        }
311
312
        // Validate encrypted token
313
        if (password_verify($token, $this->token()) !== true) {
314
            $this->panic();
315
            $this->delete();
316
            return '';
317
        }
318
319
        // Success!
320
        return $this->username();
321
    }
322
323
    /**
324
     * StorableTrait > preSave(): Called automatically before saving the object to source.
325
     * @return boolean
326
     */
327
    protected function preSave()
328
    {
329
        parent::preSave();
330
331
        if (password_needs_rehash($this->token, PASSWORD_DEFAULT)) {
332
            $this->token = password_hash($this->token, PASSWORD_DEFAULT);
333
        }
334
        $this->setCreated('now');
335
        $this->setLastModified('now');
336
337
        return true;
338
    }
339
340
    /**
341
     * StorableTrait > preUpdate(): Called automatically before updating the object to source.
342
     * @param array $properties The properties (ident) set for update.
343
     * @return boolean
344
     */
345
    protected function preUpdate(array $properties = null)
346
    {
347
        parent::preUpdate($properties);
348
349
        $this->setLastModified('now');
350
351
        return true;
352
    }
353
354
    /**
355
     * Something is seriously wrong: a cookie ident was in the database but with a tampered token.
356
     *
357
     * @return void
358
     */
359
    protected function panic()
360
    {
361
        $this->logger->error(
362
            'Possible security breach: an authentication token was found in the database but its token does not match.'
363
        );
364
365
        if ($this->username) {
366
            $table = $this->source()->table();
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Charcoal\Source\SourceInterface as the method table() does only exist in the following implementations of said interface: Charcoal\Source\DatabaseSource.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
367
            $q = sprintf('
368
                DELETE FROM
369
                    `%s`
370
                WHERE
371
                    username = :username', $table);
372
            $this->source()->dbQuery($q, [
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Charcoal\Source\SourceInterface as the method dbQuery() does only exist in the following implementations of said interface: Charcoal\Source\DatabaseSource.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
373
                'username' => $this->username()
374
            ]);
375
        }
376
    }
377
378
    /**
379
     * Create a new metadata object.
380
     *
381
     * @param  array $data Optional metadata to merge on the object.
382
     * @return AuthTokenMetadata
383
     */
384
    protected function createMetadata(array $data = null)
385
    {
386
        $class = $this->metadataClass();
387
        return new $class($data);
388
    }
389
390
    /**
391
     * Retrieve the class name of the metadata object.
392
     *
393
     * @return string
394
     */
395
    protected function metadataClass()
396
    {
397
        return AuthTokenMetadata::class;
398
    }
399
}
400