Passed
Push — master ( f6f8ca...9593ed )
by Arnold
07:33
created

HashidsConfirmation::calcOldChecksum()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
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 6
cts 6
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
     * @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 = fn(string $uid) => unpack('H*', $uid)[1];
0 ignored issues
show
introduced by
Cannot access offset 1 on array|false.
Loading history...
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
        if ($expire->format('YmdHis') !== $expireHex) {
177
            return null;
178
        }
179
180 5
        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 7
    protected function decodeUid(string $hex): string
207
    {
208 7
        $uid = ($this->decodeUid)($hex);
209
210 7
        if ($uid === false) {
211 1
            throw new \RuntimeException("Failed to decode uid");
212
        }
213
214 6
        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 5
    protected function fetchUserFromStorage(string $uid, array $context): User
226
    {
227 5
        $user = $this->storage->fetchUserById($uid);
228
229 5
        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 4
        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 4
    protected function verifyChecksum(string $checksum, User $user, CarbonImmutable $expire, array $context): void
247
    {
248 4
        $expected = $this->calcChecksum($user, $expire);
249
250 4
        if ($checksum === $expected) {
251 3
            return;
252
        }
253
254 1
        $this->logger->debug('Invalid confirmation token: bad checksum', $context);
255 1
        throw new InvalidTokenException("Token has been revoked");
256
    }
257
258
    /**
259
     * Check that the token isn't expired.
260
     *
261
     * @param CarbonImmutable   $expire
262
     * @param string[]          $context
263
     * @throws InvalidTokenException
264
     */
265 3
    protected function verifyNotExpired(CarbonImmutable $expire, array $context): void
266
    {
267 3
        if (!$expire->isPast()) {
268 2
            return;
269
        }
270
271 1
        $this->logger->debug('Expired confirmation token', $context);
272 1
        throw new InvalidTokenException("Token is expired");
273
    }
274
275
276
    /**
277
     * Calculate confirmation checksum.
278
     */
279 6
    protected function calcChecksum(User $user, \DateTimeInterface $expire): string
280
    {
281
        $parts = [
282 6
            CarbonImmutable::instance($expire)->utc()->format('YmdHis'),
283 6
            $user->getAuthId(),
284 6
            $user->getAuthChecksum(),
285
        ];
286
287 6
        return hash_hmac('sha256', join("\0", $parts), $this->secret);
288
    }
289
290
    /**
291
     * Create a hashids service.
292
     */
293 12
    public function createHashids(): Hashids
294
    {
295 12
        $salt = base_convert(hash_hmac('sha256', $this->subject, $this->secret), 16, 36);
296
297 12
        return ($this->createHashids)($salt);
298
    }
299
300
    /**
301
     * Create a partial token for logging.
302
     *
303
     * @param string $token
304
     * @return string
305
     */
306 8
    protected static function partialToken(string $token): string
307
    {
308 8
        return substr($token, 0, 8) . '...';
309
    }
310
}
311