1 | <?php |
||
2 | |||
3 | /** |
||
4 | * A class for generating the codes compatible with the Google Authenticator and similar TOTP |
||
5 | * clients. |
||
6 | * |
||
7 | * NOTE: A lot of the logic from this class has been borrowed from this class: |
||
8 | * https://www.idontplaydarts.com/wp-content/uploads/2011/07/ga.php_.txt |
||
9 | * |
||
10 | * @author Chris Cornutt <[email protected]> |
||
11 | * @package GAuth |
||
12 | * @license MIT |
||
13 | * |
||
14 | * Simple Machines Forum (SMF) |
||
15 | * |
||
16 | * @package SMF |
||
17 | * @author Simple Machines https://www.simplemachines.org |
||
18 | * @copyright 2022 Simple Machines and individual contributors |
||
19 | * @license https://www.simplemachines.org/about/smf/license.php BSD |
||
20 | * |
||
21 | * @version 2.1.0 |
||
22 | */ |
||
23 | |||
24 | namespace TOTP; |
||
25 | |||
26 | /** |
||
27 | * Class Auth |
||
28 | * |
||
29 | * @package TOTP |
||
30 | */ |
||
31 | class Auth |
||
32 | { |
||
33 | /** |
||
34 | * @var array Internal lookup table |
||
35 | */ |
||
36 | private $lookup = array(); |
||
37 | |||
38 | /** |
||
39 | * @var string Initialization key |
||
40 | */ |
||
41 | private $initKey = null; |
||
42 | |||
43 | /** |
||
44 | * @var integer Seconds between key refreshes |
||
45 | */ |
||
46 | private $refreshSeconds = 30; |
||
47 | |||
48 | /** |
||
49 | * @var integer The length of codes to generate |
||
50 | */ |
||
51 | private $codeLength = 6; |
||
52 | |||
53 | /** |
||
54 | * @var integer Range plus/minus for "window of opportunity" on allowed codes |
||
55 | */ |
||
56 | private $range = 2; |
||
57 | |||
58 | /** |
||
59 | * Initialize the object and set up the lookup table |
||
60 | * Optionally the Initialization key |
||
61 | * |
||
62 | * @param string $initKey Initialization key |
||
63 | */ |
||
64 | public function __construct($initKey = null) |
||
65 | { |
||
66 | $this->buildLookup(); |
||
67 | |||
68 | if ($initKey !== null) |
||
69 | { |
||
70 | $this->setInitKey($initKey); |
||
71 | } |
||
72 | } |
||
73 | |||
74 | /** |
||
75 | * Build the base32 lookup table |
||
76 | */ |
||
77 | public function buildLookup() |
||
78 | { |
||
79 | $lookup = array_combine( |
||
80 | array_merge(range('A', 'Z'), range(2, 7)), |
||
81 | range(0, 31) |
||
82 | ); |
||
83 | $this->setLookup($lookup); |
||
84 | } |
||
85 | |||
86 | /** |
||
87 | * Get the current "range" value |
||
88 | * |
||
89 | * @return integer Range value |
||
90 | */ |
||
91 | public function getRange() |
||
92 | { |
||
93 | return $this->range; |
||
94 | } |
||
95 | |||
96 | /** |
||
97 | * Set the "range" value |
||
98 | * |
||
99 | * @param integer $range Range value |
||
100 | * @return \TOTP\Auth instance |
||
101 | */ |
||
102 | public function setRange($range) |
||
103 | { |
||
104 | if (!is_numeric($range)) |
||
0 ignored issues
–
show
introduced
by
![]() |
|||
105 | { |
||
106 | throw new \InvalidArgumentException('Invalid window range'); |
||
107 | } |
||
108 | $this->range = $range; |
||
109 | return $this; |
||
110 | } |
||
111 | |||
112 | /** |
||
113 | * Set the initialization key for the object |
||
114 | * |
||
115 | * @param string $key Initialization key |
||
116 | * @throws \InvalidArgumentException If hash is not valid base32 |
||
117 | * @return \TOTP\Auth instance |
||
118 | */ |
||
119 | public function setInitKey($key) |
||
120 | { |
||
121 | if (preg_match('/^[' . implode('', array_keys($this->getLookup())) . ']+$/', $key) == false) |
||
0 ignored issues
–
show
|
|||
122 | { |
||
123 | throw new \InvalidArgumentException('Invalid base32 hash!'); |
||
124 | } |
||
125 | $this->initKey = $key; |
||
126 | return $this; |
||
127 | } |
||
128 | |||
129 | /** |
||
130 | * Get the current Initialization key |
||
131 | * |
||
132 | * @return string Initialization key |
||
133 | */ |
||
134 | public function getInitKey() |
||
135 | { |
||
136 | return $this->initKey; |
||
137 | } |
||
138 | |||
139 | /** |
||
140 | * Set the contents of the internal lookup table |
||
141 | * |
||
142 | * @param array $lookup Lookup data set |
||
143 | * @throws \InvalidArgumentException If lookup given is not an array |
||
144 | * @return \TOTP\Auth instance |
||
145 | */ |
||
146 | public function setLookup($lookup) |
||
147 | { |
||
148 | if (!is_array($lookup)) |
||
0 ignored issues
–
show
|
|||
149 | { |
||
150 | throw new \InvalidArgumentException('Lookup value must be an array'); |
||
151 | } |
||
152 | $this->lookup = $lookup; |
||
153 | return $this; |
||
154 | } |
||
155 | |||
156 | /** |
||
157 | * Get the current lookup data set |
||
158 | * |
||
159 | * @return array Lookup data |
||
160 | */ |
||
161 | public function getLookup() |
||
162 | { |
||
163 | return $this->lookup; |
||
164 | } |
||
165 | |||
166 | /** |
||
167 | * Get the number of seconds for code refresh currently set |
||
168 | * |
||
169 | * @return integer Refresh in seconds |
||
170 | */ |
||
171 | public function getRefresh() |
||
172 | { |
||
173 | return $this->refreshSeconds; |
||
174 | } |
||
175 | |||
176 | /** |
||
177 | * Set the number of seconds to refresh codes |
||
178 | * |
||
179 | * @param integer $seconds Seconds to refresh |
||
180 | * @throws \InvalidArgumentException If seconds value is not numeric |
||
181 | * @return \TOTP\Auth instance |
||
182 | */ |
||
183 | public function setRefresh($seconds) |
||
184 | { |
||
185 | if (!is_numeric($seconds)) |
||
0 ignored issues
–
show
|
|||
186 | { |
||
187 | throw new \InvalidArgumentException('Seconds must be numeric'); |
||
188 | } |
||
189 | $this->refreshSeconds = $seconds; |
||
190 | return $this; |
||
191 | } |
||
192 | |||
193 | /** |
||
194 | * Get the current length for generated codes |
||
195 | * |
||
196 | * @return integer Code length |
||
197 | */ |
||
198 | public function getCodeLength() |
||
199 | { |
||
200 | return $this->codeLength; |
||
201 | } |
||
202 | |||
203 | /** |
||
204 | * Set the length of the generated codes |
||
205 | * |
||
206 | * @param integer $length Code length |
||
207 | * @return \TOTP\Auth instance |
||
208 | */ |
||
209 | public function setCodeLength($length) |
||
210 | { |
||
211 | $this->codeLength = $length; |
||
212 | return $this; |
||
213 | } |
||
214 | |||
215 | /** |
||
216 | * Validate the given code |
||
217 | * |
||
218 | * @param string $code Code entered by user |
||
219 | * @param string $initKey Initialization key |
||
220 | * @param string $timestamp Timestamp for calculation |
||
221 | * @param integer $range Seconds before/after to validate hash against |
||
222 | * @throws \InvalidArgumentException If incorrect code length |
||
223 | * @return boolean Pass/fail of validation |
||
224 | */ |
||
225 | public function validateCode($code, $initKey = null, $timestamp = null, $range = null) |
||
226 | { |
||
227 | if (strlen($code) !== $this->getCodeLength()) |
||
228 | { |
||
229 | throw new \InvalidArgumentException('Incorrect code length'); |
||
230 | } |
||
231 | |||
232 | $range = ($range == null) ? $this->getRange() : $range; |
||
0 ignored issues
–
show
|
|||
233 | $timestamp = ($timestamp == null) ? $this->generateTimestamp() : $timestamp; |
||
0 ignored issues
–
show
|
|||
234 | $initKey = ($initKey == null) ? $this->getInitKey() : $initKey; |
||
0 ignored issues
–
show
|
|||
235 | |||
236 | $binary = $this->base32_decode($initKey); |
||
237 | |||
238 | for ($time = ($timestamp - $range); $time <= ($timestamp + $range); $time++) |
||
239 | { |
||
240 | if ($this->generateOneTime($binary, $time) == $code) |
||
241 | { |
||
242 | return true; |
||
243 | } |
||
244 | } |
||
245 | return false; |
||
246 | } |
||
247 | |||
248 | /** |
||
249 | * Generate a one-time code |
||
250 | * |
||
251 | * @param string $initKey Initialization key [optional] |
||
252 | * @param string $timestamp Timestamp for calculation [optional] |
||
253 | * @return string Generated code/hash |
||
254 | */ |
||
255 | public function generateOneTime($initKey = null, $timestamp = null) |
||
256 | { |
||
257 | $initKey = ($initKey == null) ? $this->getInitKey() : $initKey; |
||
0 ignored issues
–
show
|
|||
258 | $timestamp = ($timestamp == null) ? $this->generateTimestamp() : $timestamp; |
||
0 ignored issues
–
show
|
|||
259 | |||
260 | $hash = hash_hmac( |
||
261 | 'sha1', |
||
262 | pack('N*', 0) . pack('N*', $timestamp), |
||
263 | $initKey, |
||
264 | true |
||
265 | ); |
||
266 | |||
267 | return str_pad($this->truncateHash($hash), $this->getCodeLength(), '0', STR_PAD_LEFT); |
||
268 | } |
||
269 | |||
270 | /** |
||
271 | * Generate a code/hash |
||
272 | * Useful for making Initialization codes |
||
273 | * |
||
274 | * @param integer $length Length for the generated code |
||
275 | * @return string Generated code |
||
276 | */ |
||
277 | public function generateCode($length = 16) |
||
278 | { |
||
279 | global $smcFunc; |
||
280 | |||
281 | $lookup = implode('', array_keys($this->getLookup())); |
||
282 | $code = ''; |
||
283 | |||
284 | for ($i = 0; $i < $length; $i++) |
||
285 | { |
||
286 | $code .= $lookup[$smcFunc['random_int'](0, strlen($lookup) - 1)]; |
||
287 | } |
||
288 | |||
289 | return $code; |
||
290 | } |
||
291 | |||
292 | /** |
||
293 | * Generate the timestamp for the calculation |
||
294 | * |
||
295 | * @return integer Timestamp |
||
296 | */ |
||
297 | public function generateTimestamp() |
||
298 | { |
||
299 | return floor(microtime(true) / $this->getRefresh()); |
||
300 | } |
||
301 | |||
302 | /** |
||
303 | * Truncate the given hash down to just what we need |
||
304 | * |
||
305 | * @param string $hash Hash to truncate |
||
306 | * @return string Truncated hash value |
||
307 | */ |
||
308 | public function truncateHash($hash) |
||
309 | { |
||
310 | $offset = ord($hash[19]) & 0xf; |
||
311 | |||
312 | return ( |
||
313 | ((ord($hash[$offset + 0]) & 0x7f) << 24) | |
||
314 | ((ord($hash[$offset + 1]) & 0xff) << 16) | |
||
315 | ((ord($hash[$offset + 2]) & 0xff) << 8) | |
||
316 | (ord($hash[$offset + 3]) & 0xff) |
||
317 | ) % pow(10, $this->getCodeLength()); |
||
318 | } |
||
319 | |||
320 | /** |
||
321 | * Base32 decoding function |
||
322 | * |
||
323 | * @param string $hash The base32-encoded hash |
||
324 | * @throws \InvalidArgumentException When hash is not valid |
||
325 | * @return string Binary value of hash |
||
326 | */ |
||
327 | public function base32_decode($hash) |
||
328 | { |
||
329 | $lookup = $this->getLookup(); |
||
330 | |||
331 | if (preg_match('/^[' . implode('', array_keys($lookup)) . ']+$/', $hash) == false) |
||
0 ignored issues
–
show
|
|||
332 | { |
||
333 | throw new \InvalidArgumentException('Invalid base32 hash!'); |
||
334 | } |
||
335 | |||
336 | $hash = strtoupper($hash); |
||
337 | $buffer = 0; |
||
338 | $length = 0; |
||
339 | $binary = ''; |
||
340 | |||
341 | for ($i = 0; $i < strlen($hash); $i++) |
||
342 | { |
||
343 | $buffer = $buffer << 5; |
||
344 | $buffer += $lookup[$hash[$i]]; |
||
345 | $length += 5; |
||
346 | |||
347 | if ($length >= 8) |
||
348 | { |
||
349 | $length -= 8; |
||
350 | $binary .= chr(($buffer & (0xFF << $length)) >> $length); |
||
351 | } |
||
352 | } |
||
353 | |||
354 | return $binary; |
||
355 | } |
||
356 | |||
357 | /** |
||
358 | * Returns a URL to QR code for embedding the QR code |
||
359 | * |
||
360 | * @param string $name The name |
||
361 | * @param string $code The generated code |
||
362 | * @return string The URL to the QR code |
||
363 | */ |
||
364 | public function getQrCodeUrl($name, $code) |
||
365 | { |
||
366 | $url = 'otpauth://totp/' . urlencode($name) . '?secret=' . $code; |
||
367 | return $url; |
||
368 | } |
||
369 | } |
||
370 | |||
371 | ?> |