Failed Conditions
Push — master ( c0499a...c36aca )
by Arnold
05:41 queued 40s
created

HashidsConfirmation.php$0 ➔ encodeUid()   A

Complexity

Conditions 2

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 9
ccs 2
cts 2
cp 1
rs 9.9666
cc 2
crap 2
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 13
44
    /**
45 13
     * HashidsConfirmation constructor.
46
     *
47 13
     * @param string                        $secret
48 12
     * @param null|callable(string):Hashids $createHashids
49 1
     */
50
    public function __construct(string $secret, ?callable $createHashids = null)
51 13
    {
52 1
        $this->secret = $secret;
53 1
54 13
        $this->createHashids = $createHashids !== null
55 13
            ? $createHashids(...)
56
            : fn(string $salt) => new Hashids($salt);
57 13
58
        $this->encodeUid = function (string $uid) {
59
            $unpacked = unpack('H*', $uid);
60
            return $unpacked !== false ? $unpacked[1] : '';
61
        };
62
        $this->decodeUid = fn(string $hex) => pack('H*', $hex);
63
64
        $this->logger = new NullLogger();
65
66 13
        $this->clock = new class () implements ClockInterface
67
        {
68 13
            public function now(): DateTimeImmutable
69
            {
70
                return new DateTimeImmutable('now', new DateTimeZone('UTC'));
71
            }
72
        };
73
    }
74
75
    /**
76
     * Get copy with storage service.
77
     */
78 4
    public function withStorage(Storage $storage): static
79
    {
80 4
        return $this->withProperty('storage', $storage);
81 4
    }
82 4
83
    /**
84
     * Get copy with clock service. Mainly used for testing.
85
     */
86
    public function withClock(ClockInterface $clock): static
87
    {
88
        return $this->withProperty('clock', $clock);
89
    }
90
91 8
    /**
92
     * Get a copy with custom methods to encode/decode the uid.
93 8
     */
94
    public function withUidEncoded(callable $encode, callable $decode): static
95
    {
96
        return $this
97
            ->withProperty('encodeUid', $encode(...))
98
            ->withProperty('decodeUid', $decode(...));
99
    }
100
101
    /**
102 13
     * Get copy with logger.
103
     */
104 13
    public function withLogger(Logger $logger): static
105
    {
106
        return $this->withProperty('logger', $logger);
107
    }
108
109
    /**
110
     * Create a copy of this service with a specific subject.
111 3
     */
112
    public function withSubject(string $subject): static
113 3
    {
114 2
        return $this->withProperty('subject', $subject);
115 2
    }
116
117 2
118
    /**
119
     * Generate a confirmation token.
120
     */
121
    public function getToken(User $user, DateTimeInterface $expire): string
122
    {
123
        $uidHex = $this->encodeUid($user->getAuthId());
124
        $expireHex = self::utc($expire)->format('YmdHis');
125
        $checksum = $this->calcChecksum($user, $expire);
126
127
        return $this->createHashids()->encodeHex($checksum . $expireHex . $uidHex);
128 8
    }
129
130 8
131 8
    /**
132
     * Get user by confirmation token.
133 8
     *
134
     * @param string $token Confirmation token
135 8
     * @return User
136 3
     * @throws InvalidTokenException
137 3
     */
138
    public function from(string $token): User
139
    {
140
        $hex = $this->createHashids()->decodeHex($token);
141 5
        /** @var null|array{checksum:string,expire:DateTimeImmutable,uid:string} $info */
142 5
        $info = $this->extractHex($hex);
143
144 5
        $context = ['subject' => $this->subject, 'token' => self::partialToken($token)];
145 4
146 3
        if ($info === null) {
147
            $this->logger->debug('Invalid confirmation token', $context);
148 2
            throw new InvalidTokenException("Invalid confirmation token");
149
        }
150 2
151
        ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid] = $info;
152
        $context += ['user' => $uid, 'expire' => $expire->format('c')];
153
154
        $user = $this->fetchUserFromStorage($uid, $context);
155
        $this->verifyChecksum($checksum, $user, $expire, $context);
156
        $this->verifyNotExpired($expire, $context);
157
158
        $this->logger->info('Verified confirmation token', $context);
159
160 8
        return $user;
161
    }
162 8
163 1
164
    /**
165
     * Extract uid, expire date and checksum from hex.
166 7
     *
167 7
     * @param string $hex
168 7
     * @return null|array{checksum:string,expire:DateTimeImmutable,uid:string}
169
     */
170
    protected function extractHex(string $hex): ?array
171 7
    {
172
        if (strlen($hex) <= 78) {
173
            return null;
174 6
        }
175 2
176 2
        $checksum = substr($hex, 0, 64);
177
        $expireHex = substr($hex, 64, 14);
178
        $uidHex = substr($hex, 78);
179 5
180
        try {
181
            $uid = $this->decodeUid($uidHex);
182
            $expire = DateTimeImmutable::createFromFormat('YmdHis', $expireHex, new DateTimeZone('UTC'));
183 5
        } catch (Exception $exception) {
184
            return null;
185
        }
186
187
        if ($expire === false || $expire->format('YmdHis') !== $expireHex) {
188
            return null;
189
        }
190
191
        return ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid];
192 3
    }
193
194 3
    /**
195
     * Encode the uid to a hex value.
196 3
     */
197 1
    protected function encodeUid(string $uid): string
198
    {
199
        $hex = ($this->encodeUid)($uid);
200 2
201
        if ($hex === false) {
202
            throw new RuntimeException("Failed to encode uid");
203
        }
204
205
        return $hex;
206
    }
207
208
    /**
209 7
     * Decode the uid to a hex value.
210
     */
211 7
    protected function decodeUid(string $hex): string
212
    {
213 7
        $uid = ($this->decodeUid)($hex);
214 1
215
        if ($uid === false) {
216
            throw new RuntimeException("Failed to decode uid");
217 6
        }
218
219
        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 5
     * @throws InvalidTokenException
229
     */
230 5
    protected function fetchUserFromStorage(string $uid, array $context): User
231
    {
232 5
        $user = $this->storage->fetchUserById($uid);
233 1
234 1
        if ($user === null) {
235
            $this->logger->debug('Invalid confirmation token: user not available', $context);
236
            throw new InvalidTokenException("Token has been revoked");
237 4
        }
238
239
        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 4
     * @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 3
255
        if ($checksum !== $expected) {
256
            $this->logger->debug('Invalid confirmation token: bad checksum', $context);
257 1
            throw new InvalidTokenException("Token has been revoked");
258 1
        }
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 2
            $this->logger->debug('Expired confirmation token', $context);
272
            throw new InvalidTokenException("Token is expired");
273
        }
274 1
    }
275 1
276
277
    /**
278
     * Calculate confirmation checksum.
279
     */
280
    protected function calcChecksum(User $user, DateTimeInterface $expire): string
281
    {
282 6
        $parts = [
283
            self::utc($expire)->format('YmdHis'),
284 6
            $user->getAuthId(),
285 6
            $user->getAuthChecksum(),
286 6
        ];
287 6
288 6
        return hash_hmac('sha256', join("\0", $parts), $this->secret);
289
    }
290 6
291
    /**
292
     * Create a hashids service.
293
     */
294
    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 12
301
    /**
302
     * Create a partial token for logging.
303
     */
304
    protected static function partialToken(string $token): string
305
    {
306
        return substr($token, 0, 8) . '...';
307
    }
308
309 8
    /**
310
     * Create a UTC date from a date.
311 8
     */
312
    protected static function utc(DateTimeInterface $date): DateTimeImmutable
313
    {
314
        return (new DateTimeImmutable())
315
            ->setTimestamp($date->getTimestamp())
316
            ->setTimezone(new DateTimeZone('UTC'));
317
    }
318
}
319