HashidsConfirmation.php$0 ➔ extractHex()   A
last analyzed

Complexity

Conditions 5

Size

Total Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 5

Importance

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