Completed
Push — master ( c47c6a...b116a1 )
by Arnold
03:00
created

HashidsConfirmation::fetchUserFromStorage()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 5
c 0
b 0
f 0
dl 0
loc 10
ccs 6
cts 6
cp 1
rs 10
cc 2
nc 2
nop 2
crap 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 unreal4u\Dummy\Logger as DummyLogger;
13
use Psr\Log\LoggerInterface as Logger;
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
    protected Logger $logger;
31
32
    /**
33
     * HashidsConfirmation constructor.
34
     *
35
     * @param string                   $secret
36
     * @param callable(string):Hashids $createHashids
37
     */
38 11
    public function __construct(string $secret, ?callable $createHashids = null)
39
    {
40 11
        $this->secret = $secret;
41
42 11
        $this->createHashids = $createHashids !== null
43 10
            ? \Closure::fromCallable($createHashids)
44 1
            : fn(string $salt) => new Hashids($salt);
45
46 11
        $this->logger = new DummyLogger();
47 11
    }
48
49
    /**
50
     * Get copy with storage service.
51
     *
52
     * @param Storage $storage
53
     * @return static
54
     */
55 11
    public function withStorage(Storage $storage): self
56
    {
57 11
        return $this->withProperty('storage', $storage);
58
    }
59
60
    /**
61
     * Get copy with logger.
62
     *
63
     * @param Logger $logger
64
     * @return static
65
     */
66 8
    public function withLogger(Logger $logger): self
67
    {
68 8
        return $this->withProperty('logger', $logger);
69
    }
70
71
    /**
72
     * Create a copy of this service with a specific subject.
73
     *
74
     * @param string $subject
75
     * @return static
76
     */
77 11
    public function withSubject(string $subject): self
78
    {
79 11
        return $this->withProperty('subject', $subject);
80
    }
81
82
83
    /**
84
     * Generate a confirmation token.
85
     */
86 1
    public function getToken(User $user, \DateTimeInterface $expire): string
87
    {
88 1
        $uidHex = $this->encodeUid($user->getAuthId());
89 1
        $expireHex = CarbonImmutable::instance($expire)->utc()->format('YmdHis');
90 1
        $checksum = $this->calcChecksum($user, $expire);
91
92 1
        return $this->createHashids()->encodeHex($checksum . $expireHex . $uidHex);
93
    }
94
95
96
    /**
97
     * Get user by confirmation token.
98
     *
99
     * @param string $token Confirmation token
100
     * @return User
101
     * @throws InvalidTokenException
102
     */
103 8
    public function from(string $token): User
104
    {
105 8
        $hex = $this->createHashids()->decodeHex($token);
106 8
        $info = $this->extractHex($hex);
107
108 8
        $context = ['subject' => $this->subject, 'token' => self::partialToken($token)];
109
110 8
        if ($info === null) {
111 3
            $this->logger->debug('Invalid confirmation token', $context);
112 3
            throw new InvalidTokenException("Invalid confirmation token");
113
        }
114
115
        /** @var CarbonImmutable $expire */
116 5
        ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid] = $info;
117 5
        $context += ['user' => $uid, 'expire' => $expire->format('c')];
118
119 5
        $user = $this->fetchUserFromStorage($uid, $context);
120 4
        $this->verifyChecksum($checksum, $user, $expire, $context);
121 3
        $this->verifyNotExpired($expire, $context);
122
123 2
        $this->logger->info('Verified confirmation token', $context);
124
125 2
        return $user;
126
    }
127
128
129
    /**
130
     * Extract uid, expire date and checksum from hex.
131
     *
132
     * @param string $hex
133
     * @return null|array{checksum:string,expire:CarbonImmutable,uid:string|int}
134
     */
135 8
    protected function extractHex(string $hex): ?array
136
    {
137 8
        if (strlen($hex) <= 78) {
138 1
            return null;
139
        }
140
141 7
        $checksum = substr($hex, 0, 64);
142 7
        $expireHex = substr($hex, 64, 14);
143 7
        $uidHex = substr($hex, 78);
144
145
        try {
146 7
            $uid = $this->decodeUid($uidHex);
147
148
            /** @var CarbonImmutable $expire */
149 6
            $expire = CarbonImmutable::createFromFormat('YmdHis', $expireHex, '+00:00');
150 2
        } catch (\Exception $exception) {
151 2
            return null;
152
        }
153
154 5
        return ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid];
155
    }
156
157
    /**
158
     * Encode the uid to a hex value.
159
     *
160
     * @param int|string $uid
161
     * @return string
162
     */
163 1
    protected function encodeUid($uid): string
164
    {
165 1
        return is_int($uid) ? '00' . dechex($uid) : '01' . (unpack('H*', $uid)[1]);
166
    }
167
168
    /**
169
     * Decode the uid to a hex value.
170
     *
171
     * @param string $hex
172
     * @return int|string
173
     */
174 7
    protected function decodeUid(string $hex)
175
    {
176 7
        $type = substr($hex, 0, 2);
177 7
        $uidHex = substr($hex, 2);
178
179 7
        if ($type !== '00' && $type !== '01') {
180 1
            throw new \RuntimeException("Invalid uid");
181
        }
182
183 6
        return $type === '00' ? (int)hexdec($uidHex) : pack('H*', $uidHex);
184
    }
185
186
    /**
187
     * Fetch user from storage by uid.
188
     *
189
     * @param string|int        $uid
190
     * @param array<int|string> $context
191
     * @return User
192
     * @throws InvalidTokenException
193
     */
194 5
    protected function fetchUserFromStorage($uid, array $context): User
195
    {
196 5
        $user = $this->storage->fetchUserById($uid);
197
198 5
        if ($user === null) {
199 1
            $this->logger->debug('Invalid confirmation token: user not available', $context);
200 1
            throw new InvalidTokenException("Token has been revoked");
201
        }
202
203 4
        return $user;
204
    }
205
206
    /**
207
     * Check that the checksum from the token matches the expected checksum.
208
     *
209
     * @param string            $checksum
210
     * @param User              $user
211
     * @param CarbonImmutable   $expire
212
     * @param array<int|string> $context
213
     * @throws InvalidTokenException
214
     */
215 4
    protected function verifyChecksum(string $checksum, User $user, CarbonImmutable $expire, array $context): void
216
    {
217 4
        $expected = $this->calcChecksum($user, $expire);
218
219 4
        if ($checksum === $expected) {
220 3
            return;
221
        }
222
223 1
        $this->logger->debug('Invalid confirmation token: bad checksum', $context);
224 1
        throw new InvalidTokenException("Token has been revoked");
225
    }
226
227
    /**
228
     * Check that the token isn't expired.
229
     *
230
     * @param CarbonImmutable   $expire
231
     * @param array<int|string> $context
232
     * @throws InvalidTokenException
233
     */
234 3
    protected function verifyNotExpired(CarbonImmutable $expire, array $context): void
235
    {
236 3
        if (!$expire->isPast()) {
237 2
            return;
238
        }
239
240 1
        $this->logger->debug('Expired confirmation token', $context);
241 1
        throw new InvalidTokenException("Token is expired");
242
    }
243
244
245
    /**
246
     * Calculate confirmation checksum.
247
     */
248 5
    protected function calcChecksum(User $user, \DateTimeInterface $expire): string
249
    {
250
        $parts = [
251 5
            CarbonImmutable::instance($expire)->utc()->format('YmdHis'),
252 5
            $user->getAuthId(),
253 5
            $user->getAuthChecksum(),
254 5
            $this->secret
255
        ];
256
257 5
        return hash('sha256', join("\0", $parts));
258
    }
259
260
261
    /**
262
     * Create a hashids service.
263
     */
264 11
    public function createHashids(): Hashids
265
    {
266 11
        $salt = hash('sha256', $this->subject . $this->secret, true);
267
268 11
        return ($this->createHashids)($salt);
269
    }
270
271
    /**
272
     * Create a partial token for logging.
273
     *
274
     * @param string $token
275
     * @return string
276
     */
277 8
    protected static function partialToken(string $token): string
278
    {
279 8
        return substr($token, 0, 8) . '...';
280
    }
281
}
282