├── src ├── QrImageGenerator │ ├── QrImageGeneratorInterface.php │ ├── GoogleQrImageGenerator.php │ └── EndroidQrImageGenerator.php ├── Secret.php ├── SecretFactory.php └── GoogleAuthenticator.php ├── LICENSE ├── composer.json ├── CHANGELOG.md └── README.md /src/QrImageGenerator/QrImageGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | width = $width; 19 | $this->height = $height; 20 | } 21 | 22 | public function generateUri(Secret $secret) 23 | { 24 | return "https://chart.googleapis.com/chart?chs={$this->width}x{$this->height}&chld=M|0&cht=qr&chl=".$secret->getUri(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/QrImageGenerator/EndroidQrImageGenerator.php: -------------------------------------------------------------------------------- 1 | size = $size; 20 | $this->writer = $writer; 21 | } 22 | 23 | public function generateUri(Secret $secret) 24 | { 25 | $qrCode = new QrCode($secret->getUri()); 26 | $qrCode->setSize($this->size); 27 | 28 | $qrCode->setWriterByName($this->writer); 29 | 30 | return $qrCode->writeDataUri(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Douglas Nelson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dolondro/google-authenticator", 3 | "description": "Code to authenticate against the Google Authenticator app", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Doug Nelson", 8 | "email": "dougnelson@silktide.com" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=5.4", 13 | "christian-riesen/base32": "^1.2", 14 | "psr/cache": "^1.0", 15 | "paragonie/random_compat": "^2.0|~9.99" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "~6", 19 | "endroid/qr-code": "~2.2|~3", 20 | "cache/filesystem-adapter": "^1.0", 21 | "phpstan/phpstan": "^0.11.3", 22 | "cache/array-adapter": "^1.0" 23 | }, 24 | "suggests": { 25 | "endroid/qr-code": "Allows use of EndroidQrImageGenerator to generate the QR code images" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Dolondro\\GoogleAuthenticator\\": "src" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Dolondro\\GoogleAuthenticator\\Tests\\": "tests" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Secret.php: -------------------------------------------------------------------------------- 1 | issuer = $issuer; 26 | $this->accountName = $accountName; 27 | $this->secretKey = $secretKey; 28 | } 29 | 30 | /** 31 | * @return string 32 | */ 33 | public function getUri() 34 | { 35 | return "otpauth://totp/".rawurlencode($this->getLabel())."?secret=".$this->getSecretKey()."&issuer=".rawurlencode($this->getIssuer()); 36 | } 37 | 38 | /** 39 | * @return string 40 | */ 41 | public function getLabel() 42 | { 43 | return $this->issuer.":".$this->accountName; 44 | } 45 | 46 | /** 47 | * @return mixed 48 | */ 49 | public function getIssuer() 50 | { 51 | return $this->issuer; 52 | } 53 | 54 | /** 55 | * @return mixed 56 | */ 57 | public function getAccountName() 58 | { 59 | return $this->accountName; 60 | } 61 | 62 | /** 63 | * @return mixed 64 | */ 65 | public function getSecretKey() 66 | { 67 | return $this->secretKey; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/SecretFactory.php: -------------------------------------------------------------------------------- 1 | 0) { 18 | throw new \InvalidArgumentException("Secret length must be longer than 0 and divisible by 8"); 19 | } 20 | $this->secretLength = $secretLength; 21 | } 22 | 23 | /** 24 | * The spec technically allows you to only have an accountName not an issuer, but as it's strongly recommended, 25 | * I don't feel particularly guilty about forcing it in the create. 26 | * 27 | * @param string $issuer 28 | * @param string $accountName 29 | * 30 | * @return Secret 31 | */ 32 | public function create($issuer, $accountName) 33 | { 34 | return new Secret($issuer, $accountName, $this->generateSecretKey()); 35 | } 36 | 37 | /** 38 | * Generates a secret key! 39 | * 40 | * Interestingly, the easiest way to get truly random key is just to iterate through the base 32 chars picking random 41 | * characters 42 | */ 43 | public function generateSecretKey() 44 | { 45 | $key = ""; 46 | while (strlen($key) < $this->secretLength) { 47 | $key .= $this->base32Chars[random_int(0, 31)]; 48 | } 49 | 50 | return $key; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [2.2.0] - 2021-05-24 8 | ### Added 9 | - Support for different types of writer for Endroid 10 | - Added LICENCE file 11 | 12 | ## [2.1.0] - 2019-03-12 13 | ### Added 14 | - Support for custom timeslice windows 15 | - Better unit testing support for timeslices 16 | - Support for PSR-16 17 | ### Bugfix 18 | - Fix for potential timing attack 19 | 20 | ## [2.0.7] - 2019-02-07 21 | ### Bugfix 22 | - Support for Composer 2.0 23 | - Updated endroid/qrcode to be the new format of endroid/qr-code 24 | 25 | ## [2.0.6] - 2019-01-31 26 | ### Bugfix 27 | - Support NOP version of paragonie/random_compat 28 | 29 | ## [2.0.5] - 2019-01-08 30 | ### Bugfix 31 | - Fixed timeslices not being used properly 32 | 33 | ## [2.0.4] - 2018-08-20 34 | ### Dependency compatibility 35 | - Added support for Endroid ~3 36 | 37 | ## [2.0.3] - 2018-07-09 38 | ### Bugfix 39 | - Actually fixed malformed composer.json 40 | 41 | ## [2.0.2] - 2018-07-09 42 | ### Bugfix 43 | - Fixed malformed composer.json 44 | 45 | ## [2.0.1] - 2018-07-08 46 | ### Bugfix 47 | - Updated tests to PHPUnit 6 48 | - Fixed cache to use correct PSR-6 compliant keys 49 | - Updated example to be clear about cache usage 50 | - Added cache/filesystem-adapter as a dev dependency 51 | 52 | ## [2.0.0] - 2018-05-24 53 | ### Bugfix 54 | - Moved to use Endroid as default QR provider 55 | - Fixed Google QR provider (for the time being at least) 56 | - Removed cache/filesystem-adapter as a compulsory dependency 57 | 58 | ## [1.1.1] - 2017-10-04 59 | ### Bugfix 60 | - Fixed verification that secret cannot contain a colon 61 | 62 | ## [1.1.0] - 2017-06-20 63 | ### Added 64 | - Endroid QR Generation Support 65 | 66 | ## [1.0.1] - 2016-09-19 67 | ### Added 68 | - Bugfixes regarding incorrect use of urlencode instead of rawurlencode 69 | - Added random-compat library to support php7 functions when running php5 70 | 71 | ## [1.0.0] 72 | ### Initial Release 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoogleAuthenticator 2 | 3 | ## Author's note 4 | Although this library is not deprecated by any means and should continue to work well, since the release of this library other projects have implemented this in a non-terrible style and have gained reasonable traction. 5 | 6 | Before you implement this, consider whether [otphp](https://github.com/Spomky-Labs/otphp) may suit your use case. 7 | 8 | ## Introduction 9 | 2 factor authentication is pretty awesome. Far too many people use the same password for multiple things, and sometimes it's nice to actually have a secure application. 10 | 11 | Using the Google Authenticator allows people to have another layer of security that will only allow them to access your web application/service if they have both the password and the correctly setup Google Authenticator app on their phone. 12 | 13 | ## Implementation 14 | As far as I could tell, there were (at the time of writing) 2 other PHP libraries for interacting with the Google Authenticator. Both of which work but neither of which seem to be updated much nor incorporate modern best practises. 15 | 16 | This library has the advantage of being slightly nicer (I hope) to integrate into existing libraries, and contains inbuilt support for using a PSR-6 cache interface to reduce the possibility of a replay attack. 17 | 18 | ## Usage 19 | You can initially create the a secret code for use in your application using: 20 | 21 | ```php 22 | $issuer = "MyAwesomeCorp"; 23 | $accountName = "MrsSmith"; 24 | $secretFactory = new SecretFactory(); 25 | $secret = $secretFactory->create($issuer, $accountName); 26 | ``` 27 | 28 | This gives you a secret. You should: 29 | 1. feed this object into a QrImageGenerator so your user can scan the QR code into their phone 30 | 2. attach the secret to their user account so you can query it 31 | 32 | There are 2 ImageGenerator implementations included with this library: 33 | 1. EndroidQrImageGenerator which requires you composer require `endroid/qr-code:~2.2|~3` which generates it without any external service dependencies. 34 | 2. GoogleImageGenerator which uses the Google QR code API to generate the image. 35 | 36 | I'd recommend using Endroid as it seems that Google has now [deprecated their QR code API](https://developers.google.com/chart/infographics/docs/qr_codes) 37 | 38 | If neither of these fit the bill for some reason, it's easy to create another implementation, as all it needs to do is generate a QR code for the data in `$secret->getUri()` 39 | 40 | You can verify that the user has been successful by using this: 41 | 42 | ```php 43 | $googleAuth = new GoogleAuthenticator(); 44 | $googleAuth->authenticate($secret, $code); 45 | ``` 46 | 47 | Authenticate will either boolean true/false. 48 | 49 | If you want to use a PSR-6 cache interface to attempt to prevent replay attacks, you can do so like so: 50 | 51 | ```php 52 | $googleAuth = new GoogleAuthenticator(); 53 | $googleAuth->setCache($cacheItemPoolInterface); 54 | $googleAuth->authenticate($secret, $code); 55 | ``` 56 | 57 | If the code has been used for that secret in the last 30 seconds, it will return false. 58 | 59 | ## Examples 60 | An example working implementation of this code can be found in the example.php file, which can be run either as: 61 | 62 | ```sh 63 | php example.php 64 | ``` 65 | 66 | Which will allow you to generate a secret, then test it, or: 67 | 68 | ```sh 69 | php example.php mysecretcode 70 | ``` 71 | 72 | Which will allow you to take an already existing code and again, test if your code is valid 73 | 74 | ## References 75 | Other PHP Google Authenticator implementations: 76 | - https://github.com/chregu/GoogleAuthenticator.php 77 | - https://github.com/PHPGangsta/GoogleAuthenticator 78 | 79 | Specification for Google Authenticator: 80 | - https://github.com/google/google-authenticator/wiki/Key-Uri-Format 81 | -------------------------------------------------------------------------------- /src/GoogleAuthenticator.php: -------------------------------------------------------------------------------- 1 | 1, 22 | "time" => null, 23 | ]; 24 | 25 | public function __construct($options = []) 26 | { 27 | $this->options = array_merge($this->options, $options); 28 | } 29 | 30 | /** 31 | * @param CacheItemPoolInterface|CacheInterface $cache 32 | * 33 | * @throws \Exception 34 | */ 35 | public function setCache($cache) 36 | { 37 | if ($cache instanceof CacheItemPoolInterface || $cache instanceof CacheInterface) { 38 | $this->cache = $cache; 39 | 40 | return; 41 | } 42 | 43 | throw new \Exception("Cache is not PSR-16 or PSR-6 compliant"); 44 | } 45 | 46 | /** 47 | * @param string $secret 48 | * @param string $code 49 | * 50 | * @return bool 51 | * 52 | * @throws \Exception 53 | * @throws \Psr\Cache\InvalidArgumentException 54 | */ 55 | public function authenticate($secret, $code) 56 | { 57 | $correct = false; 58 | $time = isset($this->options["time"]) ? $this->options["time"] : time(); 59 | 60 | $window = $this->options["window"]; 61 | 62 | for ($i = -$window; $i <= $window; $i++) { 63 | $timeSlice = $this->getTimeSlice($time, $i); 64 | 65 | //if ($this->isEqual) 66 | if ($this->isEqual($this->calculateCode($secret, $timeSlice), $code)) { 67 | $correct = true; 68 | 69 | break; 70 | } 71 | } 72 | 73 | // If they don't have a cache, then we return whatever we've got so far! 74 | if (is_null($this->cache)) { 75 | return $correct; 76 | } 77 | 78 | // Equally, if they were wrong, we also want to return 79 | if (!$correct) { 80 | return $correct; 81 | } 82 | 83 | // If we're here then we must be using a cache, and we must be right 84 | 85 | // We generate the key as securely as possible, then salt it using something that will always be replicable. 86 | // We're doing this hashing for de-duplication (aka, we want to know if it exists), but as we're also possibly 87 | // securing the secret somewhere, we want to try and have as secure as possible 88 | // 89 | // Annoyingly, crypt looks like it can return characters outside of the range of acceptable keys, so we're just 90 | // md5'ing again to make the characters acceptable :P 91 | // There definitely will be a better way of doing this, but this is a quick bugfix 92 | // 93 | // If someone has any better suggestions on how to achieve this, please send in a PR! :P 94 | $key = md5(crypt($secret."|".$code, md5($code))); 95 | 96 | // People mostly use PSR-16 these days as PSR-6 was a PITA 97 | if ($this->cache instanceof CacheInterface) { 98 | if ($this->cache->has($key)) { 99 | return false; 100 | } 101 | 102 | $this->cache->set($key, true, 30); 103 | 104 | return true; 105 | } 106 | 107 | if ($this->cache instanceof CacheItemPoolInterface) { 108 | if ($this->cache->hasItem($key)) { 109 | return false; 110 | } 111 | 112 | // If it didn't, then we want this function to add it to the cache 113 | // In PSR-6 getItem will always contain an CacheItemInterface and that seems to be the only way to add stuff 114 | // to the cachePool 115 | $item = $this->cache->getItem($key); 116 | // It's a quick expiry thing, 30 seconds is more than long enough 117 | $item->expiresAfter(30); 118 | // We don't care about the value at all, it's just something that's needed to use the caching interface 119 | $item->set(true); 120 | $this->cache->save($item); 121 | 122 | return true; 123 | } 124 | 125 | // I'd be pretty impressed if someone got here 126 | return true; 127 | } 128 | 129 | /** 130 | * @param int $time 131 | * @param int $offset 132 | * 133 | * @return float|int 134 | */ 135 | protected function getTimeSlice($time, $offset = 0) 136 | { 137 | return floor($time / 30) + $offset; 138 | } 139 | 140 | /** 141 | * @param $string1 142 | * @param $string2 143 | * 144 | * @return bool 145 | */ 146 | protected function isEqual($string1, $string2) 147 | { 148 | return substr_count($string1 ^ $string2, "\0") * 2 === strlen($string1.$string2); 149 | } 150 | 151 | /** 152 | * @param string $secret 153 | * @param int|null $timeSlice 154 | * 155 | * @return string 156 | */ 157 | public function calculateCode($secret, $timeSlice = null) 158 | { 159 | // If we haven't been fed a timeSlice, then get one. 160 | // It looks a bit unclean doing it like this, but it allows us to write testable code 161 | $time = isset($this->options["time"]) ? $this->options["time"] : time(); 162 | $timeSlice = $timeSlice ? $timeSlice : $this->getTimeSlice($time); 163 | 164 | // Packs the timeslice as a "unsigned long" (always 32 bit, big endian byte order) 165 | $timeSlice = pack("N", $timeSlice); 166 | 167 | // Then pad it with the null terminator 168 | $timeSlice = str_pad($timeSlice, 8, chr(0), STR_PAD_LEFT); 169 | 170 | // Hash it with SHA1. The spec does offer the idea of other algorithms, but notes that the authenticator is currently 171 | // ignoring it... 172 | $hash = hash_hmac("SHA1", $timeSlice, Base32::decode($secret), true); 173 | 174 | // Last 4 bits are an offset apparently 175 | $offset = ord(substr($hash, -1)) & 0x0F; 176 | 177 | // Grab the last 4 bytes 178 | $result = substr($hash, $offset, 4); 179 | 180 | // Unpack it again 181 | $value = unpack('N', $result)[1]; 182 | 183 | // Only 32 bits 184 | $value = $value & 0x7FFFFFFF; 185 | 186 | // Modulo down to the right number of digits 187 | $modulo = pow(10, $this->codeLength); 188 | 189 | // Finally, pad out the string with 0s 190 | return str_pad($value % $modulo, $this->codeLength, '0', STR_PAD_LEFT); 191 | } 192 | } 193 | --------------------------------------------------------------------------------