TwoFactorAuth.php 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. <?php
  2. namespace RobThree\Auth;
  3. use RobThree\Auth\Providers\Qr\IQRCodeProvider;
  4. use RobThree\Auth\Providers\Rng\IRNGProvider;
  5. use RobThree\Auth\Providers\Time\ITimeProvider;
  6. // Based on / inspired by: https://github.com/PHPGangsta/GoogleAuthenticator
  7. // Algorithms, digits, period etc. explained: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
  8. class TwoFactorAuth
  9. {
  10. private $algorithm;
  11. private $period;
  12. private $digits;
  13. private $issuer;
  14. private $qrcodeprovider = null;
  15. private $rngprovider = null;
  16. private $timeprovider = null;
  17. private static $_base32dict = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=';
  18. private static $_base32;
  19. private static $_base32lookup = array();
  20. private static $_supportedalgos = array('sha1', 'sha256', 'sha512', 'md5');
  21. function __construct($issuer = null, $digits = 6, $period = 30, $algorithm = 'sha1', IQRCodeProvider $qrcodeprovider = null, IRNGProvider $rngprovider = null, ITimeProvider $timeprovider = null)
  22. {
  23. $this->issuer = $issuer;
  24. if (!is_int($digits) || $digits <= 0)
  25. throw new TwoFactorAuthException('Digits must be int > 0');
  26. $this->digits = $digits;
  27. if (!is_int($period) || $period <= 0)
  28. throw new TwoFactorAuthException('Period must be int > 0');
  29. $this->period = $period;
  30. $algorithm = strtolower(trim($algorithm));
  31. if (!in_array($algorithm, self::$_supportedalgos))
  32. throw new TwoFactorAuthException('Unsupported algorithm: ' . $algorithm);
  33. $this->algorithm = $algorithm;
  34. $this->qrcodeprovider = $qrcodeprovider;
  35. $this->rngprovider = $rngprovider;
  36. $this->timeprovider = $timeprovider;
  37. self::$_base32 = str_split(self::$_base32dict);
  38. self::$_base32lookup = array_flip(self::$_base32);
  39. }
  40. /**
  41. * Create a new secret
  42. */
  43. public function createSecret($bits = 80, $requirecryptosecure = true)
  44. {
  45. $secret = '';
  46. $bytes = ceil($bits / 5); //We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32)
  47. $rngprovider = $this->getRngprovider();
  48. if ($requirecryptosecure && !$rngprovider->isCryptographicallySecure())
  49. throw new TwoFactorAuthException('RNG provider is not cryptographically secure');
  50. $rnd = $rngprovider->getRandomBytes($bytes);
  51. for ($i = 0; $i < $bytes; $i++)
  52. $secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values
  53. return $secret;
  54. }
  55. /**
  56. * Calculate the code with given secret and point in time
  57. */
  58. public function getCode($secret, $time = null)
  59. {
  60. $secretkey = $this->base32Decode($secret);
  61. $timestamp = "\0\0\0\0" . pack('N*', $this->getTimeSlice($this->getTime($time))); // Pack time into binary string
  62. $hashhmac = hash_hmac($this->algorithm, $timestamp, $secretkey, true); // Hash it with users secret key
  63. $hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0F, 4); // Use last nibble of result as index/offset and grab 4 bytes of the result
  64. $value = unpack('N', $hashpart); // Unpack binary value
  65. $value = $value[1] & 0x7FFFFFFF; // Drop MSB, keep only 31 bits
  66. return str_pad($value % pow(10, $this->digits), $this->digits, '0', STR_PAD_LEFT);
  67. }
  68. /**
  69. * Check if the code is correct. This will accept codes starting from ($discrepancy * $period) sec ago to ($discrepancy * period) sec from now
  70. */
  71. public function verifyCode($secret, $code, $discrepancy = 1, $time = null, &$timeslice = 0)
  72. {
  73. $timetamp = $this->getTime($time);
  74. $timeslice = 0;
  75. // To keep safe from timing-attacks we iterate *all* possible codes even though we already may have
  76. // verified a code is correct. We use the timeslice variable to hold either 0 (no match) or the timeslice
  77. // of the match. Each iteration we either set the timeslice variable to the timeslice of the match
  78. // or set the value to itself. This is an effort to maintain constant execution time for the code.
  79. for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
  80. $ts = $timetamp + ($i * $this->period);
  81. $slice = $this->getTimeSlice($ts);
  82. $timeslice = $this->codeEquals($this->getCode($secret, $ts), $code) ? $slice : $timeslice;
  83. }
  84. return $timeslice > 0;
  85. }
  86. /**
  87. * Timing-attack safe comparison of 2 codes (see http://blog.ircmaxell.com/2014/11/its-all-about-time.html)
  88. */
  89. private function codeEquals($safe, $user) {
  90. if (function_exists('hash_equals')) {
  91. return hash_equals($safe, $user);
  92. }
  93. // In general, it's not possible to prevent length leaks. So it's OK to leak the length. The important part is that
  94. // we don't leak information about the difference of the two strings.
  95. if (strlen($safe)===strlen($user)) {
  96. $result = 0;
  97. for ($i = 0; $i < strlen($safe); $i++)
  98. $result |= (ord($safe[$i]) ^ ord($user[$i]));
  99. return $result === 0;
  100. }
  101. return false;
  102. }
  103. /**
  104. * Get data-uri of QRCode
  105. */
  106. public function getQRCodeImageAsDataUri($label, $secret, $size = 200)
  107. {
  108. if (!is_int($size) || $size <= 0)
  109. throw new TwoFactorAuthException('Size must be int > 0');
  110. $qrcodeprovider = $this->getQrCodeProvider();
  111. return 'data:'
  112. . $qrcodeprovider->getMimeType()
  113. . ';base64,'
  114. . base64_encode($qrcodeprovider->getQRCodeImage($this->getQRText($label, $secret), $size));
  115. }
  116. /**
  117. * Compare default timeprovider with specified timeproviders and ensure the time is within the specified number of seconds (leniency)
  118. */
  119. public function ensureCorrectTime(array $timeproviders = null, $leniency = 5)
  120. {
  121. if ($timeproviders != null && !is_array($timeproviders))
  122. throw new TwoFactorAuthException('No timeproviders specified');
  123. if ($timeproviders == null)
  124. $timeproviders = array(
  125. new Providers\Time\NTPTimeProvider(),
  126. new Providers\Time\HttpTimeProvider()
  127. );
  128. // Get default time provider
  129. $timeprovider = $this->getTimeProvider();
  130. // Iterate specified time providers
  131. foreach ($timeproviders as $t) {
  132. if (!($t instanceof ITimeProvider))
  133. throw new TwoFactorAuthException('Object does not implement ITimeProvider');
  134. // 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
  135. if (abs($timeprovider->getTime() - $t->getTime()) > $leniency)
  136. throw new TwoFactorAuthException(sprintf('Time for timeprovider is off by more than %d seconds when compared to %s', $leniency, get_class($t)));
  137. }
  138. }
  139. private function getTime($time)
  140. {
  141. return ($time === null) ? $this->getTimeProvider()->getTime() : $time;
  142. }
  143. private function getTimeSlice($time = null, $offset = 0)
  144. {
  145. return (int)floor($time / $this->period) + ($offset * $this->period);
  146. }
  147. /**
  148. * Builds a string to be encoded in a QR code
  149. */
  150. public function getQRText($label, $secret)
  151. {
  152. return 'otpauth://totp/' . rawurlencode($label)
  153. . '?secret=' . rawurlencode($secret)
  154. . '&issuer=' . rawurlencode($this->issuer)
  155. . '&period=' . intval($this->period)
  156. . '&algorithm=' . rawurlencode(strtoupper($this->algorithm))
  157. . '&digits=' . intval($this->digits);
  158. }
  159. private function base32Decode($value)
  160. {
  161. if (strlen($value)==0) return '';
  162. if (preg_match('/[^'.preg_quote(self::$_base32dict).']/', $value) !== 0)
  163. throw new TwoFactorAuthException('Invalid base32 string');
  164. $buffer = '';
  165. foreach (str_split($value) as $char)
  166. {
  167. if ($char !== '=')
  168. $buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, 0, STR_PAD_LEFT);
  169. }
  170. $length = strlen($buffer);
  171. $blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, ' '));
  172. $output = '';
  173. foreach (explode(' ', $blocks) as $block)
  174. $output .= chr(bindec(str_pad($block, 8, 0, STR_PAD_RIGHT)));
  175. return $output;
  176. }
  177. /**
  178. * @return IQRCodeProvider
  179. * @throws TwoFactorAuthException
  180. */
  181. public function getQrCodeProvider()
  182. {
  183. // Set default QR Code provider if none was specified
  184. if (null === $this->qrcodeprovider) {
  185. return $this->qrcodeprovider = new Providers\Qr\GoogleQRCodeProvider();
  186. }
  187. return $this->qrcodeprovider;
  188. }
  189. /**
  190. * @return IRNGProvider
  191. * @throws TwoFactorAuthException
  192. */
  193. public function getRngprovider()
  194. {
  195. if (null !== $this->rngprovider) {
  196. return $this->rngprovider;
  197. }
  198. if (function_exists('random_bytes')) {
  199. return $this->rngprovider = new Providers\Rng\CSRNGProvider();
  200. }
  201. if (function_exists('mcrypt_create_iv')) {
  202. return $this->rngprovider = new Providers\Rng\MCryptRNGProvider();
  203. }
  204. if (function_exists('openssl_random_pseudo_bytes')) {
  205. return $this->rngprovider = new Providers\Rng\OpenSSLRNGProvider();
  206. }
  207. if (function_exists('hash')) {
  208. return $this->rngprovider = new Providers\Rng\HashRNGProvider();
  209. }
  210. throw new TwoFactorAuthException('Unable to find a suited RNGProvider');
  211. }
  212. /**
  213. * @return ITimeProvider
  214. * @throws TwoFactorAuthException
  215. */
  216. public function getTimeProvider()
  217. {
  218. // Set default time provider if none was specified
  219. if (null === $this->timeprovider) {
  220. return $this->timeprovider = new Providers\Time\LocalMachineTimeProvider();
  221. }
  222. return $this->timeprovider;
  223. }
  224. }