Completed
Pull Request — master (#11)
by Arnold
03:10
created

HashidsConfirmation::createHashids()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Jasny\Auth\Confirmation;
6
7
use Carbon\CarbonImmutable;
8
use DateTimeImmutable;
9
use DateTimeInterface;
10
use Hashids\Hashids;
11
use Jasny\Auth\UserInterface as User;
12
use Jasny\Auth\StorageInterface as Storage;
13
use Jasny\Immutable;
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 \Closure $encodeUid;
29
    protected Storage $storage;
30
31
    /**
32
     * HashidsConfirmation constructor.
33
     *
34
     * @param string                   $secret
35
     * @param callable(string):Hashids $createHashids
36
     */
37 11
    public function __construct(string $secret, ?callable $createHashids = null)
38
    {
39 11
        $this->secret = $secret;
40
41 11
        $this->createHashids = $createHashids !== null
42 10
            ? \Closure::fromCallable($createHashids)
43 1
            : fn(string $salt) => new Hashids($salt);
44 11
    }
45
46
    /**
47
     * Get copy with storage service.
48
     *
49
     * @param Storage $storage
50
     * @return static
51
     */
52 11
    public function withStorage(Storage $storage): self
53
    {
54 11
        return $this->withProperty('storage', $storage);
55
    }
56
57
    /**
58
     * Create a copy of this service with a specific subject.
59
     *
60
     * @param string $subject
61
     * @return static
62
     */
63 11
    public function withSubject(string $subject): self
64
    {
65 11
        return $this->withProperty('subject', $subject);
66
    }
67
68
69
    /**
70
     * Generate a confirmation token.
71
     */
72 1
    public function getToken(User $user, \DateTimeInterface $expire): string
73
    {
74 1
        $uidHex = $this->encodeUid($user->getAuthId());
75 1
        $expireHex = CarbonImmutable::instance($expire)->utc()->format('YmdHis');
76 1
        $checksum = $this->calcChecksum($user, $expire);
77
78 1
        return $this->createHashids()->encodeHex($checksum . $expireHex . $uidHex);
79
    }
80
81
    /**
82
     * Get user by confirmation token.
83
     *
84
     * @param string $token Confirmation token
85
     * @return User
86
     * @throws InvalidTokenException
87
     */
88 8
    public function from(string $token): User
89
    {
90 8
        $hex = $this->createHashids()->decodeHex($token);
91 8
        $info = $this->extractHex($hex);
92
93 8
        if ($info === null) {
94 3
            throw new InvalidTokenException('Invalid confirmation token');
95
        }
96
97
        /* @var CarbonImmutable $expire */
98 5
        ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid] = $info;
99
100 5
        if ($expire->isPast()) {
101 1
            throw new InvalidTokenException("Token is expired");
102
        }
103
104 4
        $user = $this->storage->fetchUserById($uid);
105 4
        if ($user === null) {
106 1
            throw new InvalidTokenException("User '$uid' doesn't exist");
107
        }
108
109 3
        if ($checksum !== $this->calcChecksum($user, $expire)) {
110 1
            throw new InvalidTokenException("Checksum doesn't match");
111
        }
112
        
113 2
        return $user;
114
    }
115
116
    /**
117
     * Extract uid, expire date and checksum from hex.
118
     *
119
     * @param string $hex
120
     * @return null|array{checksum:string,expire:CarbonImmutable,uid:string|int}
121
     */
122 8
    protected function extractHex(string $hex): ?array
123
    {
124 8
        if (strlen($hex) <= 78) {
125 1
            return null;
126
        }
127
128 7
        $checksum = substr($hex, 0, 64);
129 7
        $expireHex = substr($hex, 64, 14);
130 7
        $uidHex = substr($hex, 78);
131
132
        try {
133 7
            $uid = $this->decodeUid($uidHex);
134
135
            /** @var CarbonImmutable $expire */
136 6
            $expire = CarbonImmutable::createFromFormat('YmdHis', $expireHex, '+00:00');
137 2
        } catch (\Exception $exception) {
138 2
            return null;
139
        }
140
141 5
        return ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid];
142
    }
143
144
    /**
145
     * Encode the uid to a hex value.
146
     *
147
     * @param int|string $uid
148
     * @return string
149
     */
150 1
    protected function encodeUid($uid): string
151
    {
152 1
        return is_int($uid) ? '00' . dechex($uid) : '01' . (unpack('H*', $uid)[1]);
153
    }
154
155
    /**
156
     * Decode the uid to a hex value.
157
     *
158
     * @param string $hex
159
     * @return int|string
160
     */
161 7
    protected function decodeUid(string $hex)
162
    {
163 7
        $type = substr($hex, 0, 2);
164 7
        $uidHex = substr($hex, 2);
165
166 7
        if ($type !== '00' && $type !== '01') {
167 1
            throw new \RuntimeException("Invalid uid");
168
        }
169
170 6
        return $type === '00' ? (int)hexdec($uidHex) : pack('H*', $uidHex);
171
    }
172
173
    /**
174
     * Calculate confirmation checksum.
175
     */
176 4
    protected function calcChecksum(User $user, \DateTimeInterface $expire): string
177
    {
178 4
        $utc = CarbonImmutable::instance($expire)->utc();
179 4
        $parts = [$utc->format('YmdHis'), $user->getAuthId(), $user->getAuthChecksum(), $this->secret];
180
181 4
        return hash('sha256', join("\0", $parts));
182
    }
183
184
185
    /**
186
     * Create a hashids service.
187
     */
188 11
    public function createHashids(): Hashids
189
    {
190 11
        return ($this->createHashids)(hash('sha256', $this->subject . $this->secret, true));
191
    }
192
}
193