Completed
Push — master ( 3b8213...c0d33c )
by Arnold
12s
created

HashidsConfirmation::calcOldChecksum()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 6
c 1
b 0
f 0
dl 0
loc 10
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 2
crap 1
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 13
    public function __construct(string $secret, ?callable $createHashids = null)
44
    {
45 13
        $this->secret = $secret;
46
47 13
        $this->createHashids = $createHashids !== null
48 12
            ? \Closure::fromCallable($createHashids)
49 1
            : fn(string $salt) => new Hashids($salt);
50
51 13
        $this->encodeUid = fn(string $uid) => unpack('H*', $uid)[1];
52 13
        $this->decodeUid = fn(string $hex) => pack('H*', $hex);
53
54 13
        $this->logger = new NullLogger();
55 13
    }
56
57
    /**
58
     * Get copy with storage service.
59
     *
60
     * @param Storage $storage
61
     * @return static
62
     */
63 13
    public function withStorage(Storage $storage): self
64
    {
65 13
        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 8
    public function withLogger(Logger $logger): self
89
    {
90 8
        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 13
    public function withSubject(string $subject): self
100
    {
101 13
        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 8
    public function from(string $token): User
126
    {
127 8
        $hex = $this->createHashids()->decodeHex($token);
128 8
        $info = $this->extractHex($hex);
129
130 8
        $context = ['subject' => $this->subject, 'token' => self::partialToken($token)];
131
132 8
        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 5
        ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid] = $info;
139 5
        $context += ['user' => $uid, 'expire' => $expire->format('c')];
140
141 5
        $user = $this->fetchUserFromStorage($uid, $context);
142 4
        $this->verifyChecksum($checksum, $user, $expire, $context);
143 3
        $this->verifyNotExpired($expire, $context);
144
145 2
        $this->logger->info('Verified confirmation token', $context);
146
147 2
        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 8
    protected function extractHex(string $hex): ?array
158
    {
159 8
        if (strlen($hex) <= 78) {
160 1
            return null;
161
        }
162
163 7
        $checksum = substr($hex, 0, 64);
164 7
        $expireHex = substr($hex, 64, 14);
165 7
        $uidHex = substr($hex, 78);
166
167
        try {
168 7
            $uid = $this->decodeUid($uidHex);
169
170
            /** @var CarbonImmutable $expire */
171 6
            $expire = CarbonImmutable::createFromFormat('YmdHis', $expireHex, '+00:00');
172 2
        } catch (\Exception $exception) {
173 2
            return null;
174
        }
175
176 5
        return ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid];
177
    }
178
179
    /**
180
     * Encode the uid to a hex value.
181
     *
182
     * @param string $uid
183
     * @return string
184
     */
185 3
    protected function encodeUid(string $uid): string
186
    {
187 3
        $hex = ($this->encodeUid)($uid);
188
189 3
        if ($hex === false) {
190 1
            throw new \RuntimeException("Failed to encode uid");
191
        }
192
193 2
        return $hex;
194
    }
195
196
    /**
197
     * Decode the uid to a hex value.
198
     *
199
     * @param string $hex
200
     * @return string
201
     */
202 7
    protected function decodeUid(string $hex): string
203
    {
204 7
        $uid = ($this->decodeUid)($hex);
205
206 7
        if ($uid === false) {
207 1
            throw new \RuntimeException("Failed to decode uid");
208
        }
209
210 6
        return $uid;
211
    }
212
213
    /**
214
     * Fetch user from storage by uid.
215
     *
216
     * @param string   $uid
217
     * @param string[] $context
218
     * @return User
219
     * @throws InvalidTokenException
220
     */
221 5
    protected function fetchUserFromStorage(string $uid, array $context): User
222
    {
223 5
        $user = $this->storage->fetchUserById($uid);
224
225 5
        if ($user === null) {
226 1
            $this->logger->debug('Invalid confirmation token: user not available', $context);
227 1
            throw new InvalidTokenException("Token has been revoked");
228
        }
229
230 4
        return $user;
231
    }
232
233
    /**
234
     * Check that the checksum from the token matches the expected checksum.
235
     *
236
     * @param string          $checksum
237
     * @param User            $user
238
     * @param CarbonImmutable $expire
239
     * @param string[]        $context
240
     * @throws InvalidTokenException
241
     */
242 4
    protected function verifyChecksum(string $checksum, User $user, CarbonImmutable $expire, array $context): void
243
    {
244 4
        $expected = $this->calcChecksum($user, $expire);
245
        $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

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