Passed
Push — master ( f5b54c...6b0f95 )
by Arnold
06:03
created

HashidsConfirmation   A

Complexity

Total Complexity 27

Size/Duplication

Total Lines 292
Duplicated Lines 0 %

Test Coverage

Coverage 98.88%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
wmc 27
eloc 84
c 3
b 0
f 0
dl 0
loc 292
ccs 88
cts 89
cp 0.9888
rs 10

16 Methods

Rating   Name   Duplication   Size   Complexity  
A getToken() 0 7 1
A decodeUid() 0 9 2
A verifyChecksum() 0 10 2
A extractHex() 0 24 4
A partialToken() 0 3 1
A verifyNotExpired() 0 8 2
A fetchUserFromStorage() 0 10 2
A from() 0 23 2
A createHashids() 0 5 1
A withUidEncoded() 0 5 1
A calcChecksum() 0 9 1
A withLogger() 0 3 1
A __construct() 0 15 3
A withSubject() 0 3 1
A withStorage() 0 3 1
A encodeUid() 0 9 2
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
     * @param string                        $secret
41
     * @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 = function (string $uid) {
52 1
            $unpacked = unpack('H*', $uid);
53 1
            return $unpacked !== false ? $unpacked[1] : '';
54
        };
55 13
        $this->decodeUid = fn(string $hex) => pack('H*', $hex);
56
57 13
        $this->logger = new NullLogger();
58 13
    }
59
60
    /**
61
     * Get copy with storage service.
62
     *
63
     * @param Storage $storage
64
     * @return static
65
     */
66 13
    public function withStorage(Storage $storage): self
67
    {
68 13
        return $this->withProperty('storage', $storage);
69
    }
70
71
    /**
72
     * Get a copy with custom methods to encode/decode the uid.
73
     *
74
     * @param callable $encode
75
     * @param callable $decode
76
     * @return static
77
     */
78 4
    public function withUidEncoded(callable $encode, callable $decode): self
79
    {
80
        return $this
81 4
            ->withProperty('encodeUid', \Closure::fromCallable($encode))
82 4
            ->withProperty('decodeUid', \Closure::fromCallable($decode));
83
    }
84
85
    /**
86
     * Get copy with logger.
87
     *
88
     * @param Logger $logger
89
     * @return static
90
     */
91 8
    public function withLogger(Logger $logger): self
92
    {
93 8
        return $this->withProperty('logger', $logger);
94
    }
95
96
    /**
97
     * Create a copy of this service with a specific subject.
98
     *
99
     * @param string $subject
100
     * @return static
101
     */
102 13
    public function withSubject(string $subject): self
103
    {
104 13
        return $this->withProperty('subject', $subject);
105
    }
106
107
108
    /**
109
     * Generate a confirmation token.
110
     */
111 3
    public function getToken(User $user, \DateTimeInterface $expire): string
112
    {
113 3
        $uidHex = $this->encodeUid($user->getAuthId());
114 2
        $expireHex = CarbonImmutable::instance($expire)->utc()->format('YmdHis');
115 2
        $checksum = $this->calcChecksum($user, $expire);
116
117 2
        return $this->createHashids()->encodeHex($checksum . $expireHex . $uidHex);
118
    }
119
120
121
    /**
122
     * Get user by confirmation token.
123
     *
124
     * @param string $token Confirmation token
125
     * @return User
126
     * @throws InvalidTokenException
127
     */
128 8
    public function from(string $token): User
129
    {
130 8
        $hex = $this->createHashids()->decodeHex($token);
131 8
        $info = $this->extractHex($hex);
132
133 8
        $context = ['subject' => $this->subject, 'token' => self::partialToken($token)];
134
135 8
        if ($info === null) {
136 3
            $this->logger->debug('Invalid confirmation token', $context);
137 3
            throw new InvalidTokenException("Invalid confirmation token");
138
        }
139
140
        /** @var CarbonImmutable $expire */
141 5
        ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid] = $info;
142 5
        $context += ['user' => $uid, 'expire' => $expire->format('c')];
143
144 5
        $user = $this->fetchUserFromStorage($uid, $context);
145 4
        $this->verifyChecksum($checksum, $user, $expire, $context);
146 3
        $this->verifyNotExpired($expire, $context);
147
148 2
        $this->logger->info('Verified confirmation token', $context);
149
150 2
        return $user;
151
    }
152
153
154
    /**
155
     * Extract uid, expire date and checksum from hex.
156
     *
157
     * @param string $hex
158
     * @return null|array{checksum:string,expire:CarbonImmutable,uid:string}
159
     */
160 8
    protected function extractHex(string $hex): ?array
161
    {
162 8
        if (strlen($hex) <= 78) {
163 1
            return null;
164
        }
165
166 7
        $checksum = substr($hex, 0, 64);
167 7
        $expireHex = substr($hex, 64, 14);
168 7
        $uidHex = substr($hex, 78);
169
170
        try {
171 7
            $uid = $this->decodeUid($uidHex);
172
173
            /** @var CarbonImmutable $expire */
174 6
            $expire = CarbonImmutable::createFromFormat('YmdHis', $expireHex, '+00:00');
175 2
        } catch (\Exception $exception) {
176 2
            return null;
177
        }
178
179 5
        if ($expire->format('YmdHis') !== $expireHex) {
180
            return null;
181
        }
182
183 5
        return ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid];
184
    }
185
186
    /**
187
     * Encode the uid to a hex value.
188
     *
189
     * @param string $uid
190
     * @return string
191
     */
192 3
    protected function encodeUid(string $uid): string
193
    {
194 3
        $hex = ($this->encodeUid)($uid);
195
196 3
        if ($hex === false) {
197 1
            throw new \RuntimeException("Failed to encode uid");
198
        }
199
200 2
        return $hex;
201
    }
202
203
    /**
204
     * Decode the uid to a hex value.
205
     *
206
     * @param string $hex
207
     * @return string
208
     */
209 7
    protected function decodeUid(string $hex): string
210
    {
211 7
        $uid = ($this->decodeUid)($hex);
212
213 7
        if ($uid === false) {
214 1
            throw new \RuntimeException("Failed to decode uid");
215
        }
216
217 6
        return $uid;
218
    }
219
220
    /**
221
     * Fetch user from storage by uid.
222
     *
223
     * @param string   $uid
224
     * @param string[] $context
225
     * @return User
226
     * @throws InvalidTokenException
227
     */
228 5
    protected function fetchUserFromStorage(string $uid, array $context): User
229
    {
230 5
        $user = $this->storage->fetchUserById($uid);
231
232 5
        if ($user === null) {
233 1
            $this->logger->debug('Invalid confirmation token: user not available', $context);
234 1
            throw new InvalidTokenException("Token has been revoked");
235
        }
236
237 4
        return $user;
238
    }
239
240
    /**
241
     * Check that the checksum from the token matches the expected checksum.
242
     *
243
     * @param string          $checksum
244
     * @param User            $user
245
     * @param CarbonImmutable $expire
246
     * @param string[]        $context
247
     * @throws InvalidTokenException
248
     */
249 4
    protected function verifyChecksum(string $checksum, User $user, CarbonImmutable $expire, array $context): void
250
    {
251 4
        $expected = $this->calcChecksum($user, $expire);
252
253 4
        if ($checksum === $expected) {
254 3
            return;
255
        }
256
257 1
        $this->logger->debug('Invalid confirmation token: bad checksum', $context);
258 1
        throw new InvalidTokenException("Token has been revoked");
259
    }
260
261
    /**
262
     * Check that the token isn't expired.
263
     *
264
     * @param CarbonImmutable   $expire
265
     * @param string[]          $context
266
     * @throws InvalidTokenException
267
     */
268 3
    protected function verifyNotExpired(CarbonImmutable $expire, array $context): void
269
    {
270 3
        if (!$expire->isPast()) {
271 2
            return;
272
        }
273
274 1
        $this->logger->debug('Expired confirmation token', $context);
275 1
        throw new InvalidTokenException("Token is expired");
276
    }
277
278
279
    /**
280
     * Calculate confirmation checksum.
281
     */
282 6
    protected function calcChecksum(User $user, \DateTimeInterface $expire): string
283
    {
284
        $parts = [
285 6
            CarbonImmutable::instance($expire)->utc()->format('YmdHis'),
286 6
            $user->getAuthId(),
287 6
            $user->getAuthChecksum(),
288
        ];
289
290 6
        return hash_hmac('sha256', join("\0", $parts), $this->secret);
291
    }
292
293
    /**
294
     * Create a hashids service.
295
     */
296 12
    public function createHashids(): Hashids
297
    {
298 12
        $salt = base_convert(hash_hmac('sha256', $this->subject, $this->secret), 16, 36);
299
300 12
        return ($this->createHashids)($salt);
301
    }
302
303
    /**
304
     * Create a partial token for logging.
305
     *
306
     * @param string $token
307
     * @return string
308
     */
309 8
    protected static function partialToken(string $token): string
310
    {
311 8
        return substr($token, 0, 8) . '...';
312
    }
313
}
314