blob: c46ac179459295c51118276c61d8b071b310d986 [file] [log] [blame]
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +01001<?php
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +02002
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +01003namespace RobThree\Auth;
4
5use RobThree\Auth\Providers\Qr\IQRCodeProvider;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +02006use RobThree\Auth\Providers\Qr\QRServerProvider;
7use RobThree\Auth\Providers\Rng\CSRNGProvider;
8use RobThree\Auth\Providers\Rng\HashRNGProvider;
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +01009use RobThree\Auth\Providers\Rng\IRNGProvider;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020010use RobThree\Auth\Providers\Rng\MCryptRNGProvider;
11use RobThree\Auth\Providers\Rng\OpenSSLRNGProvider;
12use RobThree\Auth\Providers\Time\HttpTimeProvider;
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010013use RobThree\Auth\Providers\Time\ITimeProvider;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020014use RobThree\Auth\Providers\Time\LocalMachineTimeProvider;
15use RobThree\Auth\Providers\Time\NTPTimeProvider;
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010016
17// Based on / inspired by: https://github.com/PHPGangsta/GoogleAuthenticator
18// Algorithms, digits, period etc. explained: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
19class TwoFactorAuth
20{
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020021 /** @var string */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010022 private $algorithm;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020023
24 /** @var int */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010025 private $period;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020026
27 /** @var int */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010028 private $digits;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020029
30 /** @var string */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010031 private $issuer;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020032
33 /** @var ?IQRCodeProvider */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010034 private $qrcodeprovider = null;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020035
36 /** @var ?IRNGProvider */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010037 private $rngprovider = null;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020038
39 /** @var ?ITimeProvider */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010040 private $timeprovider = null;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020041
42 /** @var string */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010043 private static $_base32dict = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=';
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020044
45 /** @var array */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010046 private static $_base32;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020047
48 /** @var array */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010049 private static $_base32lookup = array();
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020050
51 /** @var array */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010052 private static $_supportedalgos = array('sha1', 'sha256', 'sha512', 'md5');
53
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020054 /**
55 * @param ?string $issuer
56 * @param int $digits
57 * @param int $period
58 * @param string $algorithm
59 * @param ?IQRCodeProvider $qrcodeprovider
60 * @param ?IRNGProvider $rngprovider
61 * @param ?ITimeProvider $timeprovider
62 */
63 public function __construct($issuer = null, $digits = 6, $period = 30, $algorithm = 'sha1', IQRCodeProvider $qrcodeprovider = null, IRNGProvider $rngprovider = null, ITimeProvider $timeprovider = null)
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010064 {
65 $this->issuer = $issuer;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020066 if (!is_int($digits) || $digits <= 0) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010067 throw new TwoFactorAuthException('Digits must be int > 0');
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020068 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010069 $this->digits = $digits;
70
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020071 if (!is_int($period) || $period <= 0) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010072 throw new TwoFactorAuthException('Period must be int > 0');
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020073 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010074 $this->period = $period;
75
76 $algorithm = strtolower(trim($algorithm));
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020077 if (!in_array($algorithm, self::$_supportedalgos)) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010078 throw new TwoFactorAuthException('Unsupported algorithm: ' . $algorithm);
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020079 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010080 $this->algorithm = $algorithm;
81 $this->qrcodeprovider = $qrcodeprovider;
82 $this->rngprovider = $rngprovider;
83 $this->timeprovider = $timeprovider;
84
85 self::$_base32 = str_split(self::$_base32dict);
86 self::$_base32lookup = array_flip(self::$_base32);
87 }
88
89 /**
90 * Create a new secret
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020091 *
92 * @param int $bits
93 * @param bool $requirecryptosecure
94 *
95 * @return string
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +010096 */
97 public function createSecret($bits = 80, $requirecryptosecure = true)
98 {
99 $secret = '';
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200100 $bytes = (int) ceil($bits / 5); //We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32)
101 $rngprovider = $this->getRngProvider();
102 if ($requirecryptosecure && !$rngprovider->isCryptographicallySecure()) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100103 throw new TwoFactorAuthException('RNG provider is not cryptographically secure');
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200104 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100105 $rnd = $rngprovider->getRandomBytes($bytes);
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200106 for ($i = 0; $i < $bytes; $i++) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100107 $secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200108 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100109 return $secret;
110 }
111
112 /**
113 * Calculate the code with given secret and point in time
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200114 *
115 * @param string $secret
116 * @param ?int $time
117 *
118 * @return string
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100119 */
120 public function getCode($secret, $time = null)
121 {
122 $secretkey = $this->base32Decode($secret);
123
124 $timestamp = "\0\0\0\0" . pack('N*', $this->getTimeSlice($this->getTime($time))); // Pack time into binary string
125 $hashhmac = hash_hmac($this->algorithm, $timestamp, $secretkey, true); // Hash it with users secret key
126 $hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0F, 4); // Use last nibble of result as index/offset and grab 4 bytes of the result
127 $value = unpack('N', $hashpart); // Unpack binary value
128 $value = $value[1] & 0x7FFFFFFF; // Drop MSB, keep only 31 bits
129
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200130 return str_pad((string) ($value % pow(10, $this->digits)), $this->digits, '0', STR_PAD_LEFT);
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100131 }
132
133 /**
134 * Check if the code is correct. This will accept codes starting from ($discrepancy * $period) sec ago to ($discrepancy * period) sec from now
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200135 *
136 * @param string $secret
137 * @param string $code
138 * @param int $discrepancy
139 * @param ?int $time
140 * @param int $timeslice
141 *
142 * @return bool
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100143 */
144 public function verifyCode($secret, $code, $discrepancy = 1, $time = null, &$timeslice = 0)
145 {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200146 $timestamp = $this->getTime($time);
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100147
148 $timeslice = 0;
149
150 // To keep safe from timing-attacks we iterate *all* possible codes even though we already may have
151 // verified a code is correct. We use the timeslice variable to hold either 0 (no match) or the timeslice
152 // of the match. Each iteration we either set the timeslice variable to the timeslice of the match
153 // or set the value to itself. This is an effort to maintain constant execution time for the code.
154 for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200155 $ts = $timestamp + ($i * $this->period);
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100156 $slice = $this->getTimeSlice($ts);
157 $timeslice = $this->codeEquals($this->getCode($secret, $ts), $code) ? $slice : $timeslice;
158 }
159
160 return $timeslice > 0;
161 }
162
163 /**
164 * Timing-attack safe comparison of 2 codes (see http://blog.ircmaxell.com/2014/11/its-all-about-time.html)
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200165 *
166 * @param string $safe
167 * @param string $user
168 *
169 * @return bool
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100170 */
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200171 private function codeEquals($safe, $user)
172 {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100173 if (function_exists('hash_equals')) {
174 return hash_equals($safe, $user);
175 }
176 // In general, it's not possible to prevent length leaks. So it's OK to leak the length. The important part is that
177 // we don't leak information about the difference of the two strings.
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200178 if (strlen($safe) === strlen($user)) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100179 $result = 0;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200180 for ($i = 0; $i < strlen($safe); $i++) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100181 $result |= (ord($safe[$i]) ^ ord($user[$i]));
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200182 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100183 return $result === 0;
184 }
185 return false;
186 }
187
188 /**
189 * Get data-uri of QRCode
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200190 *
191 * @param string $label
192 * @param string $secret
193 * @param mixed $size
194 *
195 * @return string
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100196 */
197 public function getQRCodeImageAsDataUri($label, $secret, $size = 200)
198 {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200199 if (!is_int($size) || $size <= 0) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100200 throw new TwoFactorAuthException('Size must be int > 0');
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200201 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100202
203 $qrcodeprovider = $this->getQrCodeProvider();
204 return 'data:'
205 . $qrcodeprovider->getMimeType()
206 . ';base64,'
207 . base64_encode($qrcodeprovider->getQRCodeImage($this->getQRText($label, $secret), $size));
208 }
209
210 /**
211 * Compare default timeprovider with specified timeproviders and ensure the time is within the specified number of seconds (leniency)
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200212 * @param ?array $timeproviders
213 * @param int $leniency
214 *
215 * @return void
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100216 */
217 public function ensureCorrectTime(array $timeproviders = null, $leniency = 5)
218 {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200219 if ($timeproviders === null) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100220 $timeproviders = array(
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200221 new NTPTimeProvider(),
222 new HttpTimeProvider()
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100223 );
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200224 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100225
226 // Get default time provider
227 $timeprovider = $this->getTimeProvider();
228
229 // Iterate specified time providers
230 foreach ($timeproviders as $t) {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200231 if (!($t instanceof ITimeProvider)) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100232 throw new TwoFactorAuthException('Object does not implement ITimeProvider');
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200233 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100234
235 // Get time from default time provider and compare to specific time provider and throw if time difference is more than specified number of seconds leniency
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200236 if (abs($timeprovider->getTime() - $t->getTime()) > $leniency) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100237 throw new TwoFactorAuthException(sprintf('Time for timeprovider is off by more than %d seconds when compared to %s', $leniency, get_class($t)));
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200238 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100239 }
240 }
241
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200242 /**
243 * @param ?int $time
244 *
245 * @return int
246 */
247 private function getTime($time = null)
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100248 {
249 return ($time === null) ? $this->getTimeProvider()->getTime() : $time;
250 }
251
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200252 /**
253 * @param int $time
254 * @param int $offset
255 *
256 * @return int
257 */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100258 private function getTimeSlice($time = null, $offset = 0)
259 {
260 return (int)floor($time / $this->period) + ($offset * $this->period);
261 }
262
263 /**
264 * Builds a string to be encoded in a QR code
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200265 *
266 * @param string $label
267 * @param string $secret
268 *
269 * @return string
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100270 */
271 public function getQRText($label, $secret)
272 {
273 return 'otpauth://totp/' . rawurlencode($label)
274 . '?secret=' . rawurlencode($secret)
275 . '&issuer=' . rawurlencode($this->issuer)
276 . '&period=' . intval($this->period)
277 . '&algorithm=' . rawurlencode(strtoupper($this->algorithm))
278 . '&digits=' . intval($this->digits);
279 }
280
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200281 /**
282 * @param string $value
283 * @return string
284 */
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100285 private function base32Decode($value)
286 {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200287 if (strlen($value) == 0) {
288 return '';
289 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100290
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200291 if (preg_match('/[^' . preg_quote(self::$_base32dict) . ']/', $value) !== 0) {
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100292 throw new TwoFactorAuthException('Invalid base32 string');
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200293 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100294
295 $buffer = '';
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200296 foreach (str_split($value) as $char) {
297 if ($char !== '=') {
298 $buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, '0', STR_PAD_LEFT);
299 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100300 }
301 $length = strlen($buffer);
302 $blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, ' '));
303
304 $output = '';
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200305 foreach (explode(' ', $blocks) as $block) {
306 $output .= chr(bindec(str_pad($block, 8, '0', STR_PAD_RIGHT)));
307 }
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100308 return $output;
309 }
310
311 /**
312 * @return IQRCodeProvider
313 * @throws TwoFactorAuthException
314 */
315 public function getQrCodeProvider()
316 {
317 // Set default QR Code provider if none was specified
318 if (null === $this->qrcodeprovider) {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200319 return $this->qrcodeprovider = new QRServerProvider();
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100320 }
321 return $this->qrcodeprovider;
322 }
323
324 /**
325 * @return IRNGProvider
326 * @throws TwoFactorAuthException
327 */
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200328 public function getRngProvider()
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100329 {
330 if (null !== $this->rngprovider) {
331 return $this->rngprovider;
332 }
333 if (function_exists('random_bytes')) {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200334 return $this->rngprovider = new CSRNGProvider();
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100335 }
336 if (function_exists('mcrypt_create_iv')) {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200337 return $this->rngprovider = new MCryptRNGProvider();
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100338 }
339 if (function_exists('openssl_random_pseudo_bytes')) {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200340 return $this->rngprovider = new OpenSSLRNGProvider();
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100341 }
342 if (function_exists('hash')) {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200343 return $this->rngprovider = new HashRNGProvider();
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100344 }
345 throw new TwoFactorAuthException('Unable to find a suited RNGProvider');
346 }
347
348 /**
349 * @return ITimeProvider
350 * @throws TwoFactorAuthException
351 */
352 public function getTimeProvider()
353 {
354 // Set default time provider if none was specified
355 if (null === $this->timeprovider) {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200356 return $this->timeprovider = new LocalMachineTimeProvider();
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +0100357 }
358 return $this->timeprovider;
359 }
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200360}