TwoFactorAuth.php 10 KB

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