├── LICENSE ├── composer.json ├── sample ├── example.php ├── tmpl │ ├── ask-for-otp.php │ ├── loggedin.php │ ├── login-error.php │ ├── login.php │ └── show-qr.php ├── users.dat └── web │ ├── Users.php │ └── index.php └── src ├── FixedBitNotation.php ├── GoogleAuthenticator.php ├── GoogleAuthenticatorInterface.php ├── GoogleQrUrl.php └── RuntimeException.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2010 Thomas Rabaix 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonata-project/google-authenticator", 3 | "description": "Library to integrate Google Authenticator into a PHP project", 4 | "license": "MIT", 5 | "type": "library", 6 | "abandoned": true, 7 | "keywords": [ 8 | "google authenticator" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "Thomas Rabaix", 13 | "email": "thomas.rabaix@gmail.com" 14 | }, 15 | { 16 | "name": "Christian Stocker", 17 | "email": "me@chregu.tv" 18 | }, 19 | { 20 | "name": "Andre DeMarre", 21 | "homepage": "http://www.devnetwork.net/viewtopic.php?f=50&t=94989" 22 | } 23 | ], 24 | "homepage": "https://github.com/sonata-project/GoogleAuthenticator", 25 | "require": { 26 | "php": "^7.3 || ^8.0" 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit": "^9.5", 30 | "symfony/phpunit-bridge": "^6.0" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Google\\Authenticator\\": "src/", 35 | "Sonata\\GoogleAuthenticator\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Google\\Authenticator\\Tests\\": "tests/", 41 | "Sonata\\GoogleAuthenticator\\Tests\\": "tests/" 42 | } 43 | }, 44 | "config": { 45 | "sort-packages": true 46 | }, 47 | "extra": { 48 | "branch-alias": { 49 | "dev-master": "2.x-dev" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /sample/example.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | include_once __DIR__.'/../src/FixedBitNotation.php'; 15 | include_once __DIR__.'/../src/GoogleAuthenticator.php'; 16 | include_once __DIR__.'/../src/GoogleQrUrl.php'; 17 | 18 | $secret = 'XVQ2UIGO75XRUKJO'; 19 | $code = '846474'; 20 | 21 | $g = new \Sonata\GoogleAuthenticator\GoogleAuthenticator(); 22 | 23 | echo 'Current Code is: '; 24 | echo $g->getCode($secret); 25 | 26 | echo "\n"; 27 | 28 | echo "Check if $code is valid: "; 29 | 30 | if ($g->checkCode($secret, $code)) { 31 | echo "YES \n"; 32 | } else { 33 | echo "NO \n"; 34 | } 35 | 36 | $secret = $g->generateSecret(); 37 | echo "Get a new Secret: $secret \n"; 38 | echo "The QR Code for this secret (to scan with the Google Authenticator App: \n"; 39 | 40 | echo \Sonata\GoogleAuthenticator\GoogleQrUrl::generate('chregu', $secret, 'GoogleAuthenticatorExample'); 41 | echo "\n"; 42 | -------------------------------------------------------------------------------- /sample/tmpl/ask-for-otp.php: -------------------------------------------------------------------------------- 1 | 2 |

please otp

3 |

4 |

5 | 7 |
8 | (Set $debug in index.php to false, if you don't want to have the OTP prefilled (for real life application, for example ;))
9 | 12 | 13 | otp:
20 |
21 | 22 | 23 |
24 | -------------------------------------------------------------------------------- /sample/tmpl/loggedin.php: -------------------------------------------------------------------------------- 1 | 2 |

3 | Hello getUsername(); ?> 4 |

5 | 8 | 9 |

10 | Show QR Code 11 |

12 | 13 | 16 | 17 |

18 | Logout 19 |

20 | -------------------------------------------------------------------------------- /sample/tmpl/login-error.php: -------------------------------------------------------------------------------- 1 |

2 | Wrong username or password or token. 3 |

4 |

5 | try again 6 |

7 | -------------------------------------------------------------------------------- /sample/tmpl/login.php: -------------------------------------------------------------------------------- 1 | 2 |

please login

3 |

4 |

5 | username:
6 | password:
7 | 8 |
9 | -------------------------------------------------------------------------------- /sample/tmpl/show-qr.php: -------------------------------------------------------------------------------- 1 |

Please scan this

2 | 3 |

with the Google Authenticator App

4 | 5 |

6 | getUsername(), $secret, 'GoogleAuthenticatorExample'); 8 | ?> 9 | 10 | 11 |

12 | -------------------------------------------------------------------------------- /sample/users.dat: -------------------------------------------------------------------------------- 1 | {"chregu":{"password":"foobar"}} -------------------------------------------------------------------------------- /sample/web/Users.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | class Users 15 | { 16 | public function __construct(string $file = '../users.dat') 17 | { 18 | $this->userFile = $file; 19 | 20 | $this->users = json_decode(file_get_contents($file), true); 21 | } 22 | 23 | public function hasSession() 24 | { 25 | session_start(); 26 | if (isset($_SESSION['username'])) { 27 | return $_SESSION['username']; 28 | } 29 | 30 | return false; 31 | } 32 | 33 | public function storeData(User $user): void 34 | { 35 | $this->users[$user->getUsername()] = $user->getData(); 36 | file_put_contents($this->userFile, json_encode($this->users)); 37 | } 38 | 39 | public function loadUser($name) 40 | { 41 | if (isset($this->users[$name])) { 42 | return new User($name, $this->users[$name]); 43 | } 44 | 45 | return false; 46 | } 47 | } 48 | 49 | class User 50 | { 51 | public function __construct($user, $data) 52 | { 53 | $this->data = $data; 54 | $this->user = $user; 55 | } 56 | 57 | public function auth($pass) 58 | { 59 | if ($this->data['password'] === $pass) { 60 | return true; 61 | } 62 | 63 | return false; 64 | } 65 | 66 | public function startSession(): void 67 | { 68 | $_SESSION['username'] = $this->user; 69 | } 70 | 71 | public function doLogin(): void 72 | { 73 | session_regenerate_id(); 74 | $_SESSION['loggedin'] = true; 75 | $_SESSION['ua'] = $_SERVER['HTTP_USER_AGENT']; 76 | } 77 | 78 | public function doOTP(): void 79 | { 80 | $_SESSION['OTP'] = true; 81 | } 82 | 83 | public function isOTP() 84 | { 85 | if (isset($_SESSION['OTP']) && true === $_SESSION['OTP']) { 86 | return true; 87 | } 88 | 89 | return false; 90 | } 91 | 92 | public function isLoggedIn() 93 | { 94 | if (isset($_SESSION['loggedin']) && true === $_SESSION['loggedin'] && 95 | isset($_SESSION['ua']) && $_SESSION['ua'] === $_SERVER['HTTP_USER_AGENT'] 96 | ) { 97 | return $_SESSION['username']; 98 | } 99 | 100 | return false; 101 | } 102 | 103 | public function getUsername() 104 | { 105 | return $this->user; 106 | } 107 | 108 | public function getSecret() 109 | { 110 | if (isset($this->data['secret'])) { 111 | return $this->data['secret']; 112 | } 113 | 114 | return false; 115 | } 116 | 117 | public function generateSecret() 118 | { 119 | $g = new \Sonata\GoogleAuthenticator\GoogleAuthenticator(); 120 | $secret = $g->generateSecret(); 121 | $this->data['secret'] = $secret; 122 | 123 | return $secret; 124 | } 125 | 126 | public function getData() 127 | { 128 | return $this->data; 129 | } 130 | 131 | public function setOTPCookie(): void 132 | { 133 | $time = floor(time() / (3600 * 24)); // get day number 134 | //about using the user agent: It's easy to fake it, but it increases the barrier for stealing and reusing cookies nevertheless 135 | // and it doesn't do any harm (except that it's invalid after a browser upgrade, but that may be even intented) 136 | $cookie = $time.':'.hash_hmac('sha1', $this->getUsername().':'.$time.':'.$_SERVER['HTTP_USER_AGENT'], $this->getSecret()); 137 | setcookie('otp', $cookie, time() + (30 * 24 * 3600), null, null, null, true); 138 | } 139 | 140 | public function hasValidOTPCookie() 141 | { 142 | // 0 = tomorrow it is invalid 143 | $daysUntilInvalid = 0; 144 | $time = (string) floor((time() / (3600 * 24))); // get day number 145 | if (isset($_COOKIE['otp'])) { 146 | [$otpday, $hash] = explode(':', $_COOKIE['otp']); 147 | 148 | if ($otpday >= $time - $daysUntilInvalid && $hash === hash_hmac('sha1', $this->getUsername().':'.$otpday.':'.$_SERVER['HTTP_USER_AGENT'], $this->getSecret())) { 149 | return true; 150 | } 151 | } 152 | 153 | return false; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /sample/web/index.php: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | Google Authenticator in PHP demo 16 | 17 | 18 | hasSession()) { 26 | //load the user data from the json storage. 27 | $user = $users->loadUser($username); 28 | //if he clicked logout, destroy the session and redirect to the startscreen. 29 | if (isset($_GET['logout'])) { 30 | session_destroy(); 31 | header('Location: ./'); 32 | } 33 | // check if the user is logged in. 34 | if ($user->isLoggedIn()) { 35 | include __DIR__.'/../tmpl/loggedin.php'; 36 | //show the QR code if whished so 37 | if (isset($_GET['showqr'])) { 38 | $secret = $user->getSecret(); 39 | include __DIR__.'/../tmpl/show-qr.php'; 40 | } 41 | } 42 | //if the user is in the OTP phase and submit the OTP. 43 | else { 44 | if ($user->isOTP() && isset($_POST['otp'])) { 45 | $g = new \Google\Authenticator\GoogleAuthenticator(); 46 | // check if the submitted token is the right one and log in 47 | if ($g->checkCode($user->getSecret(), $_POST['otp'])) { 48 | // do log-in the user 49 | $user->doLogin(); 50 | //if the user clicked the "remember the token" checkbox, set the cookie 51 | if (isset($_POST['remember']) && $_POST['remember']) { 52 | $user->setOTPCookie(); 53 | } 54 | include __DIR__.'/../tmpl/loggedin.php'; 55 | } 56 | //if the OTP is wrong, destroy the session and tell the user to try again 57 | else { 58 | session_destroy(); 59 | include __DIR__.'/../tmpl/login-error.php'; 60 | } 61 | } 62 | // if the user is neither logged in nor in the OTP phase, show the login form 63 | else { 64 | session_destroy(); 65 | include __DIR__.'/../tmpl/login.php'; 66 | } 67 | } 68 | exit(); 69 | } 70 | //if the username is set in _POST, then we assume the user filled in the login form. 71 | 72 | if (isset($_POST['username'])) { 73 | // check if we can load the user (ie. the user exists in our db) 74 | $user = $users->loadUser($_POST['username']); 75 | if ($user) { 76 | //try to authenticate the password and start the session if it's correct. 77 | if ($user->auth($_POST['password'])) { 78 | $user->startSession(); 79 | //check if the user has a valid OTP cookie, so we don't have to 80 | // ask for the current token and can directly log in 81 | if ($user->hasValidOTPCookie()) { 82 | include __DIR__.'/../tmpl/loggedin.php'; 83 | $user->doLogin(); 84 | } 85 | // try to get the users' secret from the db, 86 | // if he doesn't have one, generate one, store it and show it. 87 | else { 88 | if (!$user->getSecret()) { 89 | include __DIR__.'/../tmpl/loggedin.php'; 90 | 91 | $secret = $user->generateSecret(); 92 | $users->storeData($user); 93 | $user->doLogin(); 94 | include __DIR__.'/../tmpl/show-qr.php'; 95 | } 96 | // if the user neither has a valid OTP cookie nor it's the first login 97 | // ask for the OTP 98 | else { 99 | $user->doOTP(); 100 | include __DIR__.'/../tmpl/ask-for-otp.php'; 101 | } 102 | } 103 | 104 | exit(); 105 | } 106 | } 107 | // if we're here, something went wrong, destroy the session and show a login error 108 | session_destroy(); 109 | 110 | include __DIR__.'/../tmpl/login-error.php'; 111 | exit(); 112 | } 113 | 114 | // if neither a session nor tried to submit the login credentials -> login screen 115 | include __DIR__.'/../tmpl/login.php'; 116 | 117 | ?> 118 | 119 | 120 | -------------------------------------------------------------------------------- /src/FixedBitNotation.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\GoogleAuthenticator; 15 | 16 | /** 17 | * FixedBitNotation. 18 | * 19 | * The FixedBitNotation class is for binary to text conversion. It 20 | * can handle many encoding schemes, formally defined or not, that 21 | * use a fixed number of bits to encode each character. 22 | * 23 | * @author Andre DeMarre 24 | */ 25 | final class FixedBitNotation 26 | { 27 | /** 28 | * @var string 29 | */ 30 | private $chars; 31 | 32 | /** 33 | * @var int 34 | */ 35 | private $bitsPerCharacter; 36 | 37 | /** 38 | * @var int 39 | */ 40 | private $radix; 41 | 42 | /** 43 | * @var bool 44 | */ 45 | private $rightPadFinalBits; 46 | 47 | /** 48 | * @var bool 49 | */ 50 | private $padFinalGroup; 51 | 52 | /** 53 | * @var string 54 | */ 55 | private $padCharacter; 56 | 57 | /** 58 | * @var string[] 59 | */ 60 | private $charmap; 61 | 62 | /** 63 | * @param int $bitsPerCharacter Bits to use for each encoded character 64 | * @param string $chars Base character alphabet 65 | * @param bool $rightPadFinalBits How to encode last character 66 | * @param bool $padFinalGroup Add padding to end of encoded output 67 | * @param string $padCharacter Character to use for padding 68 | */ 69 | public function __construct(int $bitsPerCharacter, ?string $chars = null, bool $rightPadFinalBits = false, bool $padFinalGroup = false, string $padCharacter = '=') 70 | { 71 | // Ensure validity of $chars 72 | if (!\is_string($chars) || ($charLength = \strlen($chars)) < 2) { 73 | $chars = 74 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-,'; 75 | $charLength = 64; 76 | } 77 | 78 | // Ensure validity of $bitsPerCharacter 79 | if ($bitsPerCharacter < 1) { 80 | // $bitsPerCharacter must be at least 1 81 | $bitsPerCharacter = 1; 82 | $radix = 2; 83 | } elseif ($charLength < 1 << $bitsPerCharacter) { 84 | // Character length of $chars is too small for $bitsPerCharacter 85 | // Set $bitsPerCharacter to greatest acceptable value 86 | $bitsPerCharacter = 1; 87 | $radix = 2; 88 | 89 | while ($charLength >= ($radix <<= 1) && $bitsPerCharacter < 8) { 90 | ++$bitsPerCharacter; 91 | } 92 | 93 | $radix >>= 1; 94 | } elseif ($bitsPerCharacter > 8) { 95 | // $bitsPerCharacter must not be greater than 8 96 | $bitsPerCharacter = 8; 97 | $radix = 256; 98 | } else { 99 | $radix = 1 << $bitsPerCharacter; 100 | } 101 | 102 | $this->chars = $chars; 103 | $this->bitsPerCharacter = $bitsPerCharacter; 104 | $this->radix = $radix; 105 | $this->rightPadFinalBits = $rightPadFinalBits; 106 | $this->padFinalGroup = $padFinalGroup; 107 | $this->padCharacter = $padCharacter[0]; 108 | } 109 | 110 | /** 111 | * Encode a string. 112 | * 113 | * @param string $rawString Binary data to encode 114 | */ 115 | public function encode($rawString): string 116 | { 117 | // Unpack string into an array of bytes 118 | $bytes = unpack('C*', $rawString); 119 | $byteCount = \count($bytes); 120 | 121 | $encodedString = ''; 122 | $byte = array_shift($bytes); 123 | $bitsRead = 0; 124 | 125 | $chars = $this->chars; 126 | $bitsPerCharacter = $this->bitsPerCharacter; 127 | $rightPadFinalBits = $this->rightPadFinalBits; 128 | $padFinalGroup = $this->padFinalGroup; 129 | $padCharacter = $this->padCharacter; 130 | 131 | // Generate encoded output; 132 | // each loop produces one encoded character 133 | for ($c = 0; $c < $byteCount * 8 / $bitsPerCharacter; ++$c) { 134 | // Get the bits needed for this encoded character 135 | if ($bitsRead + $bitsPerCharacter > 8) { 136 | // Not enough bits remain in this byte for the current 137 | // character 138 | // Save the remaining bits before getting the next byte 139 | $oldBitCount = 8 - $bitsRead; 140 | $oldBits = $byte ^ ($byte >> $oldBitCount << $oldBitCount); 141 | $newBitCount = $bitsPerCharacter - $oldBitCount; 142 | 143 | if (!$bytes) { 144 | // Last bits; match final character and exit loop 145 | if ($rightPadFinalBits) { 146 | $oldBits <<= $newBitCount; 147 | } 148 | $encodedString .= $chars[$oldBits]; 149 | 150 | if ($padFinalGroup) { 151 | // Array of the lowest common multiples of 152 | // $bitsPerCharacter and 8, divided by 8 153 | $lcmMap = [1 => 1, 2 => 1, 3 => 3, 4 => 1, 5 => 5, 6 => 3, 7 => 7, 8 => 1]; 154 | $bytesPerGroup = $lcmMap[$bitsPerCharacter]; 155 | $pads = (int) ($bytesPerGroup * 8 / $bitsPerCharacter 156 | - ceil((\strlen($rawString) % $bytesPerGroup) 157 | * 8 / $bitsPerCharacter)); 158 | $encodedString .= str_repeat($padCharacter[0], $pads); 159 | } 160 | 161 | break; 162 | } 163 | 164 | // Get next byte 165 | $byte = array_shift($bytes); 166 | $bitsRead = 0; 167 | } else { 168 | $oldBitCount = 0; 169 | $newBitCount = $bitsPerCharacter; 170 | } 171 | 172 | // Read only the needed bits from this byte 173 | $bits = $byte >> 8 - ($bitsRead + $newBitCount); 174 | $bits ^= $bits >> $newBitCount << $newBitCount; 175 | $bitsRead += $newBitCount; 176 | 177 | if ($oldBitCount) { 178 | // Bits come from seperate bytes, add $oldBits to $bits 179 | $bits = ($oldBits << $newBitCount) | $bits; 180 | } 181 | 182 | $encodedString .= $chars[$bits]; 183 | } 184 | 185 | return $encodedString; 186 | } 187 | 188 | /** 189 | * Decode a string. 190 | * 191 | * @param string $encodedString Data to decode 192 | * @param bool $caseSensitive 193 | * @param bool $strict Returns null if $encodedString contains 194 | * an undecodable character 195 | */ 196 | public function decode($encodedString, $caseSensitive = true, $strict = false): string 197 | { 198 | if (!$encodedString || !\is_string($encodedString)) { 199 | // Empty string, nothing to decode 200 | return ''; 201 | } 202 | 203 | $chars = $this->chars; 204 | $bitsPerCharacter = $this->bitsPerCharacter; 205 | $radix = $this->radix; 206 | $rightPadFinalBits = $this->rightPadFinalBits; 207 | $padCharacter = $this->padCharacter; 208 | 209 | // Get index of encoded characters 210 | if ($this->charmap) { 211 | $charmap = $this->charmap; 212 | } else { 213 | $charmap = []; 214 | 215 | for ($i = 0; $i < $radix; ++$i) { 216 | $charmap[$chars[$i]] = $i; 217 | } 218 | 219 | $this->charmap = $charmap; 220 | } 221 | 222 | // The last encoded character is $encodedString[$lastNotatedIndex] 223 | $lastNotatedIndex = \strlen($encodedString) - 1; 224 | 225 | // Remove trailing padding characters 226 | while ($encodedString[$lastNotatedIndex] === $padCharacter[0]) { 227 | $encodedString = substr($encodedString, 0, $lastNotatedIndex); 228 | --$lastNotatedIndex; 229 | } 230 | 231 | $rawString = ''; 232 | $byte = 0; 233 | $bitsWritten = 0; 234 | 235 | // Convert each encoded character to a series of unencoded bits 236 | for ($c = 0; $c <= $lastNotatedIndex; ++$c) { 237 | if (!isset($charmap[$encodedString[$c]]) && !$caseSensitive) { 238 | // Encoded character was not found; try other case 239 | if (isset($charmap[$cUpper = strtoupper($encodedString[$c])])) { 240 | $charmap[$encodedString[$c]] = $charmap[$cUpper]; 241 | } elseif (isset($charmap[$cLower = strtolower($encodedString[$c])])) { 242 | $charmap[$encodedString[$c]] = $charmap[$cLower]; 243 | } 244 | } 245 | 246 | if (isset($charmap[$encodedString[$c]])) { 247 | $bitsNeeded = 8 - $bitsWritten; 248 | $unusedBitCount = $bitsPerCharacter - $bitsNeeded; 249 | 250 | // Get the new bits ready 251 | if ($bitsNeeded > $bitsPerCharacter) { 252 | // New bits aren't enough to complete a byte; shift them 253 | // left into position 254 | $newBits = $charmap[$encodedString[$c]] << $bitsNeeded 255 | - $bitsPerCharacter; 256 | $bitsWritten += $bitsPerCharacter; 257 | } elseif ($c !== $lastNotatedIndex || $rightPadFinalBits) { 258 | // Zero or more too many bits to complete a byte; 259 | // shift right 260 | $newBits = $charmap[$encodedString[$c]] >> $unusedBitCount; 261 | $bitsWritten = 8; //$bitsWritten += $bitsNeeded; 262 | } else { 263 | // Final bits don't need to be shifted 264 | $newBits = $charmap[$encodedString[$c]]; 265 | $bitsWritten = 8; 266 | } 267 | 268 | $byte |= $newBits; 269 | 270 | if (8 === $bitsWritten || $c === $lastNotatedIndex) { 271 | // Byte is ready to be written 272 | $rawString .= pack('C', $byte); 273 | 274 | if ($c !== $lastNotatedIndex) { 275 | // Start the next byte 276 | $bitsWritten = $unusedBitCount; 277 | $byte = ($charmap[$encodedString[$c]] 278 | ^ ($newBits << $unusedBitCount)) << 8 - $bitsWritten; 279 | } 280 | } 281 | } elseif ($strict) { 282 | // Unable to decode character; abort 283 | return null; 284 | } 285 | } 286 | 287 | return $rawString; 288 | } 289 | } 290 | 291 | // NEXT_MAJOR: Remove class alias 292 | class_alias('Sonata\GoogleAuthenticator\FixedBitNotation', 'Google\Authenticator\FixedBitNotation', false); 293 | -------------------------------------------------------------------------------- /src/GoogleAuthenticator.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\GoogleAuthenticator; 15 | 16 | /** 17 | * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format 18 | */ 19 | final class GoogleAuthenticator implements GoogleAuthenticatorInterface 20 | { 21 | /** 22 | * @var int 23 | */ 24 | private $passCodeLength; 25 | 26 | /** 27 | * @var int 28 | */ 29 | private $secretLength; 30 | 31 | /** 32 | * @var int 33 | */ 34 | private $pinModulo; 35 | 36 | /** 37 | * @var \DateTimeInterface 38 | */ 39 | private $instanceTime; 40 | 41 | /** 42 | * @var int 43 | */ 44 | private $codePeriod; 45 | 46 | /** 47 | * @var int 48 | */ 49 | private $periodSize = 30; 50 | 51 | public function __construct(int $passCodeLength = 6, int $secretLength = 10, ?\DateTimeInterface $instanceTime = null, int $codePeriod = 30) 52 | { 53 | /* 54 | * codePeriod is the duration in seconds that the code is valid. 55 | * periodSize is the length of a period to calculate periods since Unix epoch. 56 | * periodSize cannot be larger than the codePeriod. 57 | */ 58 | 59 | $this->passCodeLength = $passCodeLength; 60 | $this->secretLength = $secretLength; 61 | $this->codePeriod = $codePeriod; 62 | $this->periodSize = $codePeriod < $this->periodSize ? $codePeriod : $this->periodSize; 63 | $this->pinModulo = 10 ** $passCodeLength; 64 | $this->instanceTime = $instanceTime ?? new \DateTimeImmutable(); 65 | } 66 | 67 | /** 68 | * @param string $secret 69 | * @param string $code 70 | * @param int $discrepancy 71 | */ 72 | public function checkCode($secret, $code, $discrepancy = 1): bool 73 | { 74 | /** 75 | * Discrepancy is the factor of periodSize ($discrepancy * $periodSize) allowed on either side of the 76 | * given codePeriod. For example, if a code with codePeriod = 60 is generated at 10:00:00, a discrepancy 77 | * of 1 will allow a periodSize of 30 seconds on either side of the codePeriod resulting in a valid code 78 | * from 09:59:30 to 10:00:29. 79 | * 80 | * The result of each comparison is stored as a timestamp here instead of using a guard clause 81 | * (https://refactoring.com/catalog/replaceNestedConditionalWithGuardClauses.html). This is to implement 82 | * constant time comparison to make side-channel attacks harder. See 83 | * https://cryptocoding.net/index.php/Coding_rules#Compare_secret_strings_in_constant_time for details. 84 | * Each comparison uses hash_equals() instead of an operator to implement constant time equality comparison 85 | * for each code. 86 | */ 87 | $periods = floor($this->codePeriod / $this->periodSize); 88 | 89 | $result = 0; 90 | for ($i = -$discrepancy; $i < $periods + $discrepancy; ++$i) { 91 | $dateTime = new \DateTimeImmutable('@'.($this->instanceTime->getTimestamp() - ($i * $this->periodSize))); 92 | $result = hash_equals($this->getCode($secret, $dateTime), $code) ? $dateTime->getTimestamp() : $result; 93 | } 94 | 95 | return $result > 0; 96 | } 97 | 98 | /** 99 | * NEXT_MAJOR: add the interface typehint to $time and remove deprecation. 100 | * 101 | * @param string $secret 102 | * @param float|string|int|\DateTimeInterface|null $time 103 | */ 104 | public function getCode($secret, /* \DateTimeInterface */ $time = null): string 105 | { 106 | if (null === $time) { 107 | $time = $this->instanceTime; 108 | } 109 | 110 | if ($time instanceof \DateTimeInterface) { 111 | $timeForCode = floor($time->getTimestamp() / $this->periodSize); 112 | } else { 113 | @trigger_error( 114 | 'Passing anything other than null or a DateTimeInterface to $time is deprecated as of 2.0 '. 115 | 'and will not be possible as of 3.0.', 116 | \E_USER_DEPRECATED 117 | ); 118 | $timeForCode = $time; 119 | } 120 | 121 | $base32 = new FixedBitNotation(5, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', true, true); 122 | $secret = $base32->decode($secret); 123 | 124 | $timeForCode = str_pad(pack('N', $timeForCode), 8, \chr(0), \STR_PAD_LEFT); 125 | 126 | $hash = hash_hmac('sha1', $timeForCode, $secret, true); 127 | $offset = \ord(substr($hash, -1)); 128 | $offset &= 0xF; 129 | 130 | $truncatedHash = $this->hashToInt($hash, $offset) & 0x7FFFFFFF; 131 | 132 | return str_pad((string) ($truncatedHash % $this->pinModulo), $this->passCodeLength, '0', \STR_PAD_LEFT); 133 | } 134 | 135 | /** 136 | * NEXT_MAJOR: Remove this method. 137 | * 138 | * @param string $user 139 | * @param string $hostname 140 | * @param string $secret 141 | * 142 | * @deprecated deprecated as of 2.1 and will be removed in 3.0. Use Sonata\GoogleAuthenticator\GoogleQrUrl::generate() instead. 143 | */ 144 | public function getUrl($user, $hostname, $secret): string 145 | { 146 | @trigger_error(sprintf( 147 | 'Using %s() is deprecated as of 2.1 and will be removed in 3.0. '. 148 | 'Use Sonata\GoogleAuthenticator\GoogleQrUrl::generate() instead.', 149 | __METHOD__ 150 | ), \E_USER_DEPRECATED); 151 | 152 | $issuer = \func_get_args()[3] ?? null; 153 | $accountName = sprintf('%s@%s', $user, $hostname); 154 | 155 | // manually concat the issuer to avoid a change in URL 156 | $url = GoogleQrUrl::generate($accountName, $secret); 157 | 158 | if ($issuer) { 159 | $url .= '%26issuer%3D'.$issuer; 160 | } 161 | 162 | return $url; 163 | } 164 | 165 | public function generateSecret(): string 166 | { 167 | return (new FixedBitNotation(5, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', true, true)) 168 | ->encode(random_bytes($this->secretLength)); 169 | } 170 | 171 | private function hashToInt(string $bytes, int $start): int 172 | { 173 | return unpack('N', substr(substr($bytes, $start), 0, 4))[1]; 174 | } 175 | } 176 | 177 | // NEXT_MAJOR: Remove class alias 178 | class_alias('Sonata\GoogleAuthenticator\GoogleAuthenticator', 'Google\Authenticator\GoogleAuthenticator', false); 179 | -------------------------------------------------------------------------------- /src/GoogleAuthenticatorInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\GoogleAuthenticator; 15 | 16 | interface GoogleAuthenticatorInterface 17 | { 18 | /** 19 | * @param string $secret 20 | * @param string $code 21 | */ 22 | public function checkCode($secret, $code, $discrepancy = 1): bool; 23 | 24 | /** 25 | * NEXT_MAJOR: add the interface typehint to $time and remove deprecation. 26 | * 27 | * @param string $secret 28 | * @param float|string|int|\DateTimeInterface|null $time 29 | */ 30 | public function getCode($secret, /* \DateTimeInterface */ $time = null): string; 31 | 32 | /** 33 | * NEXT_MAJOR: Remove this method. 34 | * 35 | * @param string $user 36 | * @param string $hostname 37 | * @param string $secret 38 | * 39 | * @deprecated deprecated as of 2.1 and will be removed in 3.0. Use Sonata\GoogleAuthenticator\GoogleQrUrl::generate() instead. 40 | */ 41 | public function getUrl($user, $hostname, $secret): string; 42 | 43 | public function generateSecret(): string; 44 | } 45 | -------------------------------------------------------------------------------- /src/GoogleQrUrl.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\GoogleAuthenticator; 15 | 16 | /** 17 | * Responsible for QR image url generation. 18 | * 19 | * @see http://goqr.me/api/ 20 | * @see http://goqr.me/api/doc/ 21 | * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format 22 | * 23 | * @author Iltar van der Berg 24 | */ 25 | final class GoogleQrUrl 26 | { 27 | /** 28 | * Private by design. 29 | */ 30 | private function __construct() 31 | { 32 | } 33 | 34 | /** 35 | * Generates a URL that is used to show a QR code. 36 | * 37 | * Account names may not contain a double colon (:). Valid account name 38 | * examples: 39 | * - "John.Doe@gmail.com" 40 | * - "John Doe" 41 | * - "John_Doe_976" 42 | * 43 | * The Issuer may not contain a double colon (:). The issuer is recommended 44 | * to pass along. If used, it will also be appended before the accountName. 45 | * 46 | * The previous examples with the issuer "Acme inc" would result in label: 47 | * - "Acme inc:John.Doe@gmail.com" 48 | * - "Acme inc:John Doe" 49 | * - "Acme inc:John_Doe_976" 50 | * 51 | * The contents of the label, issuer and secret will be encoded to generate 52 | * a valid URL. 53 | * 54 | * @param string $accountName The account name to show and identify 55 | * @param string $secret The secret is the generated secret unique to that user 56 | * @param string|null $issuer Where you log in to 57 | * @param int $size Image size in pixels, 200 will make it 200x200 58 | */ 59 | public static function generate(string $accountName, string $secret, ?string $issuer = null, int $size = 200): string 60 | { 61 | if ('' === $accountName || false !== strpos($accountName, ':')) { 62 | throw RuntimeException::InvalidAccountName($accountName); 63 | } 64 | 65 | if ('' === $secret) { 66 | throw RuntimeException::InvalidSecret(); 67 | } 68 | 69 | $label = $accountName; 70 | $otpauthString = 'otpauth://totp/%s?secret=%s'; 71 | 72 | if (null !== $issuer) { 73 | if ('' === $issuer || false !== strpos($issuer, ':')) { 74 | throw RuntimeException::InvalidIssuer($issuer); 75 | } 76 | 77 | // use both the issuer parameter and label prefix as recommended by Google for BC reasons 78 | $label = $issuer.':'.$label; 79 | $otpauthString .= '&issuer=%s'; 80 | } 81 | 82 | $otpauthString = rawurlencode(sprintf($otpauthString, $label, $secret, $issuer)); 83 | 84 | return sprintf( 85 | 'https://api.qrserver.com/v1/create-qr-code/?size=%1$dx%1$d&data=%2$s&ecc=M', 86 | $size, 87 | $otpauthString 88 | ); 89 | } 90 | } 91 | 92 | // NEXT_MAJOR: Remove class alias 93 | class_alias('Sonata\GoogleAuthenticator\GoogleQrUrl', 'Google\Authenticator\GoogleQrUrl', false); 94 | -------------------------------------------------------------------------------- /src/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\GoogleAuthenticator; 15 | 16 | /** 17 | * Contains runtime exception templates. 18 | * 19 | * @author Iltar van der Berg 20 | */ 21 | final class RuntimeException extends \RuntimeException 22 | { 23 | public static function InvalidAccountName(string $accountName): self 24 | { 25 | return new self(sprintf( 26 | 'The account name may not contain a double colon (:) and may not be an empty string. Given "%s".', 27 | $accountName 28 | )); 29 | } 30 | 31 | public static function InvalidIssuer(string $issuer): self 32 | { 33 | return new self(sprintf( 34 | 'The issuer name may not contain a double colon (:) and may not be an empty string. Given "%s".', 35 | $issuer 36 | )); 37 | } 38 | 39 | public static function InvalidSecret(): self 40 | { 41 | return new self('The secret name may not be an empty string.'); 42 | } 43 | } 44 | 45 | // NEXT_MAJOR: Remove class alias 46 | class_alias('Sonata\GoogleAuthenticator\RuntimeException', 'Google\Authenticator\RuntimeException', false); 47 | --------------------------------------------------------------------------------