Passed
Branch master (d8d0b7)
by Arnold
01:52
created

HashidsConfirmation::extractHex()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 24
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 4.0072

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 13
c 1
b 0
f 0
dl 0
loc 24
ccs 12
cts 13
cp 0.9231
rs 9.8333
cc 4
nc 5
nop 1
crap 4.0072
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Jasny\Auth\Confirmation;
6
7
use Carbon\CarbonImmutable;
8
use Hashids\Hashids;
9
use Jasny\Auth\UserInterface as User;
10
use Jasny\Auth\StorageInterface as Storage;
11
use Jasny\Immutable;
12
use Psr\Log\LoggerInterface as Logger;
13
use Psr\Log\NullLogger;
14
15
/**
16
 * Generate and verify confirmation tokens using the Hashids library.
17
 *
18
 * @link http://hashids.org/php/
19
 */
20
class HashidsConfirmation implements ConfirmationInterface
21
{
22
    use Immutable\With;
23
24
    protected string $subject;
25
    protected string $secret;
26
27
    protected \Closure $createHashids;
28
    protected Storage $storage;
29
30
    /** @phpstan-var \Closure&callable(string $uid):(string|false) */
31
    protected \Closure $encodeUid;
32
    /** @phpstan-var \Closure&callable(string $uid):(string|false) */
33
    protected \Closure $decodeUid;
34
35
    protected Logger $logger;
36
37
    /**
38
     * HashidsConfirmation constructor.
39
     *
40
     * @phpstan-param string                        $secret
41
     * @phpstan-param null|callable(string):Hashids $createHashids
42
     */
43 14
    public function __construct(string $secret, ?callable $createHashids = null)
44
    {
45 14
        $this->secret = $secret;
46
47 14
        $this->createHashids = $createHashids !== null
48 13
            ? \Closure::fromCallable($createHashids)
49 1
            : fn(string $salt) => new Hashids($salt);
50
51 14
        $this->encodeUid = fn(string $uid) => unpack('H*', $uid)[1];
52 14
        $this->decodeUid = fn(string $hex) => pack('H*', $hex);
53
54 14
        $this->logger = new NullLogger();
55 14
    }
56
57
    /**
58
     * Get copy with storage service.
59
     *
60
     * @param Storage $storage
61
     * @return static
62
     */
63 14
    public function withStorage(Storage $storage): self
64
    {
65 14
        return $this->withProperty('storage', $storage);
66
    }
67
68
    /**
69
     * Get a copy with custom methods to encode/decode the uid.
70
     *
71
     * @param callable $encode
72
     * @param callable $decode
73
     * @return static
74
     */
75 4
    public function withUidEncoded(callable $encode, callable $decode): self
76
    {
77
        return $this
78 4
            ->withProperty('encodeUid', \Closure::fromCallable($encode))
79 4
            ->withProperty('decodeUid', \Closure::fromCallable($decode));
80
    }
81
82
    /**
83
     * Get copy with logger.
84
     *
85
     * @param Logger $logger
86
     * @return static
87
     */
88 9
    public function withLogger(Logger $logger): self
89
    {
90 9
        return $this->withProperty('logger', $logger);
91
    }
92
93
    /**
94
     * Create a copy of this service with a specific subject.
95
     *
96
     * @param string $subject
97
     * @return static
98
     */
99 14
    public function withSubject(string $subject): self
100
    {
101 14
        return $this->withProperty('subject', $subject);
102
    }
103
104
105
    /**
106
     * Generate a confirmation token.
107
     */
108 3
    public function getToken(User $user, \DateTimeInterface $expire): string
109
    {
110 3
        $uidHex = $this->encodeUid($user->getAuthId());
111 2
        $expireHex = CarbonImmutable::instance($expire)->utc()->format('YmdHis');
112 2
        $checksum = $this->calcChecksum($user, $expire);
113
114 2
        return $this->createHashids()->encodeHex($checksum . $expireHex . $uidHex);
115
    }
116
117
118
    /**
119
     * Get user by confirmation token.
120
     *
121
     * @param string $token Confirmation token
122
     * @return User
123
     * @throws InvalidTokenException
124
     */
125 9
    public function from(string $token): User
126
    {
127 9
        $hex = $this->createHashids()->decodeHex($token);
128 9
        $info = $this->extractHex($hex);
129
130 9
        $context = ['subject' => $this->subject, 'token' => self::partialToken($token)];
131
132 9
        if ($info === null) {
133 3
            $this->logger->debug('Invalid confirmation token', $context);
134 3
            throw new InvalidTokenException("Invalid confirmation token");
135
        }
136
137
        /** @var CarbonImmutable $expire */
138 6
        ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid] = $info;
139 6
        $context += ['user' => $uid, 'expire' => $expire->format('c')];
140
141 6
        $user = $this->fetchUserFromStorage($uid, $context);
142 5
        $this->verifyChecksum($checksum, $user, $expire, $context);
143 4
        $this->verifyNotExpired($expire, $context);
144
145 3
        $this->logger->info('Verified confirmation token', $context);
146
147 3
        return $user;
148
    }
149
150
151
    /**
152
     * Extract uid, expire date and checksum from hex.
153
     *
154
     * @param string $hex
155
     * @return null|array{checksum:string,expire:CarbonImmutable,uid:string}
156
     */
157 9
    protected function extractHex(string $hex): ?array
158
    {
159 9
        if (strlen($hex) <= 78) {
160 1
            return null;
161
        }
162
163 8
        $checksum = substr($hex, 0, 64);
164 8
        $expireHex = substr($hex, 64, 14);
165 8
        $uidHex = substr($hex, 78);
166
167
        try {
168 8
            $uid = $this->decodeUid($uidHex);
169
170
            /** @var CarbonImmutable $expire */
171 7
            $expire = CarbonImmutable::createFromFormat('YmdHis', $expireHex, '+00:00');
172 2
        } catch (\Exception $exception) {
173 2
            return null;
174
        }
175
176 6
        if ($expire->format('YmdHis') !== $expireHex) {
177
            return null;
178
        }
179
180 6
        return ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid];
181
    }
182
183
    /**
184
     * Encode the uid to a hex value.
185
     *
186
     * @param string $uid
187
     * @return string
188
     */
189 3
    protected function encodeUid(string $uid): string
190
    {
191 3
        $hex = ($this->encodeUid)($uid);
192
193 3
        if ($hex === false) {
194 1
            throw new \RuntimeException("Failed to encode uid");
195
        }
196
197 2
        return $hex;
198
    }
199
200
    /**
201
     * Decode the uid to a hex value.
202
     *
203
     * @param string $hex
204
     * @return string
205
     */
206 8
    protected function decodeUid(string $hex): string
207
    {
208 8
        $uid = ($this->decodeUid)($hex);
209
210 8
        if ($uid === false) {
211 1
            throw new \RuntimeException("Failed to decode uid");
212
        }
213
214 7
        return $uid;
215
    }
216
217
    /**
218
     * Fetch user from storage by uid.
219
     *
220
     * @param string   $uid
221
     * @param string[] $context
222
     * @return User
223
     * @throws InvalidTokenException
224
     */
225 6
    protected function fetchUserFromStorage(string $uid, array $context): User
226
    {
227 6
        $user = $this->storage->fetchUserById($uid);
228
229 6
        if ($user === null) {
230 1
            $this->logger->debug('Invalid confirmation token: user not available', $context);
231 1
            throw new InvalidTokenException("Token has been revoked");
232
        }
233
234 5
        return $user;
235
    }
236
237
    /**
238
     * Check that the checksum from the token matches the expected checksum.
239
     *
240
     * @param string          $checksum
241
     * @param User            $user
242
     * @param CarbonImmutable $expire
243
     * @param string[]        $context
244
     * @throws InvalidTokenException
245
     */
246 5
    protected function verifyChecksum(string $checksum, User $user, CarbonImmutable $expire, array $context): void
247
    {
248 5
        $expected = $this->calcChecksum($user, $expire);
249 5
        $expectedOld = $this->calcOldChecksum($user, $expire);
0 ignored issues
show
Deprecated Code introduced by
The function Jasny\Auth\Confirmation\...tion::calcOldChecksum() has been deprecated. ( Ignorable by Annotation )

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

249
        $expectedOld = /** @scrutinizer ignore-deprecated */ $this->calcOldChecksum($user, $expire);
Loading history...
250
251 5
        if ($checksum === $expected || $checksum === $expectedOld) {
252 4
            return;
253
        }
254
255 1
        $this->logger->debug('Invalid confirmation token: bad checksum', $context);
256 1
        throw new InvalidTokenException("Token has been revoked");
257
    }
258
259
    /**
260
     * Check that the token isn't expired.
261
     *
262
     * @param CarbonImmutable   $expire
263
     * @param string[]          $context
264
     * @throws InvalidTokenException
265
     */
266 4
    protected function verifyNotExpired(CarbonImmutable $expire, array $context): void
267
    {
268 4
        if (!$expire->isPast()) {
269 3
            return;
270
        }
271
272 1
        $this->logger->debug('Expired confirmation token', $context);
273 1
        throw new InvalidTokenException("Token is expired");
274
    }
275
276
277
    /**
278
     * Calculate confirmation checksum.
279
     */
280 7
    protected function calcChecksum(User $user, \DateTimeInterface $expire): string
281
    {
282
        $parts = [
283 7
            CarbonImmutable::instance($expire)->utc()->format('YmdHis'),
284 7
            $user->getAuthId(),
285 7
            $user->getAuthChecksum(),
286
        ];
287
288 7
        return hash_hmac('sha256', join("\0", $parts), $this->secret);
289
    }
290
291
    /**
292
     * Calculate confirmation checksum, before switching to hmac.
293
     * Temporary so existing confirmation tokens will continue working. Will be removed.
294
     *
295
     * @deprecated
296
     */
297 5
    protected function calcOldChecksum(User $user, \DateTimeInterface $expire): string
298
    {
299
        $parts = [
300 5
            CarbonImmutable::instance($expire)->utc()->format('YmdHis'),
301 5
            $user->getAuthId(),
302 5
            $user->getAuthChecksum(),
303 5
            $this->secret,
304
        ];
305
306 5
        return hash('sha256', join("\0", $parts));
307
    }
308
309
310
    /**
311
     * Create a hashids service.
312
     */
313 13
    public function createHashids(): Hashids
314
    {
315 13
        $salt = hash('sha256', $this->subject . $this->secret, true);
316
317 13
        return ($this->createHashids)($salt);
318
    }
319
320
    /**
321
     * Create a partial token for logging.
322
     *
323
     * @param string $token
324
     * @return string
325
     */
326 9
    protected static function partialToken(string $token): string
327
    {
328 9
        return substr($token, 0, 8) . '...';
329
    }
330
}
331