Skip to content

Commit 3545712

Browse files
authored
PHP Google Authenticator
2FA two-step verification with php and Google Authenticator in Laravel.
1 parent 74acce3 commit 3545712

File tree

3 files changed

+315
-1
lines changed

3 files changed

+315
-1
lines changed

README.md

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,65 @@
1-
# google-authenticator
1+
# PHP Google Authenticator
2+
23
2FA two-step verification with php and Google Authenticator in Laravel.
4+
5+
## Install
6+
7+
```sh
8+
composer require atomjoy/google-authenticator
9+
```
10+
11+
## GoogleAuthenticator in Laravel
12+
13+
How to generate code from user secret like in GoogleAuthenticator on Android but from php script.
14+
15+
```php
16+
<?php
17+
18+
use Atomjoy\GoogleAuthenticator\GoogleAuthenticator;
19+
use Illuminate\Support\Facades\Route;
20+
21+
// Get GoogleAuthenticator code
22+
Route::get('/test/2fa/google/{secret}', function ($secret) {
23+
// Authenticator
24+
$ga = new GoogleAuthenticator();
25+
26+
// Create secret for user (first time)
27+
if ($secret == 'create') {
28+
// Create secret enable 2fa (save secret in database)
29+
$secret = $ga->createSecret();
30+
31+
// QR-Code image
32+
$url = $ga->getQRCodeUrl('Appname:user@example.com', $secret);
33+
34+
// Show user qr-code
35+
echo "<h1>New secret is: " . $secret . "</h1>";
36+
echo '<h2>Google Charts URL for the QR-Code:</h2>';
37+
echo '<p><img src="' . $url . '"> </p>';
38+
} else {
39+
// Generate code from user secret like in GoogleAuthenticator on Android but from php script
40+
// Past this code in github 2fa confirm code form
41+
$code = $ga->getCode($secret);
42+
echo "<p>Checking code <b>" . $code . "</b> and Secret <b>" . $secret . "</b> use this code in 2fa on github, facebook, ...</p>";
43+
44+
// Confirm code (allow 2*30sec clock tolerance)
45+
if ($ga->verifyCode($secret, $code, 2)) {
46+
echo '<h3 style="color: #5c5">OK</h3>';
47+
} else {
48+
echo '<h3 style="color: #f23">FAILED</h3>';
49+
}
50+
}
51+
});
52+
```
53+
54+
## Enable github two factor with php script
55+
56+
```sh
57+
php artisan serve
58+
59+
# 1. Get and save generated secret when enabling two-factor authentication in panel settings
60+
# QR-code example github_2fa_secret: FA7DKAS2MK6SH3BN
61+
62+
# 2. Enable or Login with 2fa secret
63+
# Get 6-digit code and past in github 2fa form 😄 (refresh every 30 seconds)
64+
http://127.0.0.1:8000/test/2fa/google/{github_2fa_secret}
65+
```

composer.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "atomjoy/google-authenticator",
3+
"description": "Php Google Authenticator library.",
4+
"type": "library",
5+
"license": "MIT",
6+
"autoload": {
7+
"classmap": [
8+
"src/"
9+
],
10+
"psr-4": {
11+
"Atomjoy\\GoogleAuthenticator\\": "src/"
12+
}
13+
},
14+
"autoload-dev": {
15+
"psr-4": {
16+
"Atomjoy\\GoogleAuthenticator\\Tests\\": "tests/"
17+
}
18+
},
19+
"authors": [
20+
{
21+
"name": "Atomjoy",
22+
"email": "atomjoy.official@gmail.com"
23+
}
24+
],
25+
"minimum-stability": "dev",
26+
"require": {
27+
"php": "^8.1"
28+
},
29+
"extra": {
30+
"laravel": {
31+
"providers": []
32+
}
33+
}
34+
}

src/GoogleAuthenticator.php

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
<?php
2+
3+
namespace Atomjoy\GoogleAuthenticator;
4+
5+
use Exception;
6+
7+
/**
8+
* PHP Class for handling Google Authenticator 2-factor authentication.
9+
*
10+
* References
11+
* https://github.com/PHPGangsta/GoogleAuthenticator
12+
* https://github.com/google/google-authenticator/wiki/Key-Uri-Format
13+
* https://github.com/dochne/google-authenticator
14+
* https://jsfiddle.net/etk912zc/3/
15+
* https://jsfiddle.net/russau/ch8PK/
16+
* http://blog.tinisles.com/2011/10/google-authenticator-one-time-password-algorithm-in-javascript/
17+
*/
18+
class GoogleAuthenticator
19+
{
20+
protected $codeLength = 6;
21+
22+
/**
23+
* Create new secret.
24+
* 16 characters, randomly chosen from the allowed base32 characters.
25+
*
26+
* @param int $secretLength
27+
*
28+
* @return string
29+
*/
30+
public function createSecret($secretLength = 16)
31+
{
32+
$validChars = $this->allowedChars();
33+
34+
// Valid secret lengths are 80 to 640 bits
35+
if ($secretLength < 16 || $secretLength > 128) {
36+
throw new Exception('Bad secret length');
37+
}
38+
39+
$secret = '';
40+
41+
$rnd = random_bytes($secretLength);
42+
43+
if ($rnd !== false) {
44+
for ($i = 0; $i < $secretLength; ++$i) {
45+
$secret .= $validChars[ord($rnd[$i]) & 31];
46+
}
47+
} else {
48+
throw new Exception('No source of secure random');
49+
}
50+
51+
return $secret;
52+
}
53+
54+
/**
55+
* Calculate the code, with given secret and point in time.
56+
*
57+
* @param string $secret
58+
* @param int|null $timeSlice
59+
*
60+
* @return string
61+
*/
62+
public function getCode($secret, $timeSlice = null)
63+
{
64+
if ($timeSlice === null) {
65+
$timeSlice = floor(time() / 30);
66+
}
67+
// Key
68+
$secretkey = $this->base32Decode($secret);
69+
// Pack time into binary string
70+
$time = chr(0) . chr(0) . chr(0) . chr(0) . pack('N*', $timeSlice);
71+
// Hash it with users secret key
72+
$hm = hash_hmac('SHA1', $time, $secretkey, true);
73+
// Use last nipple of result as index/offset
74+
$offset = ord(substr($hm, -1)) & 0x0F;
75+
// grab 4 bytes of the result
76+
$hashpart = substr($hm, $offset, 4);
77+
// Unpak binary value
78+
$value = unpack('N', $hashpart);
79+
$value = $value[1];
80+
// Only 32 bits
81+
$value = $value & 0x7FFFFFFF;
82+
$modulo = pow(10, $this->codeLength);
83+
84+
return str_pad($value % $modulo, $this->codeLength, '0', STR_PAD_LEFT);
85+
}
86+
87+
/**
88+
* Get QR-Code URL for image, from google charts.
89+
*
90+
* @param string $name
91+
* @param string $secret
92+
* @param string $title
93+
* @param array $params
94+
*
95+
* @return string
96+
*/
97+
public function getQRCodeUrl($name, $secret, $title = null, $params = array())
98+
{
99+
$width = !empty($params['width']) && (int) $params['width'] > 0 ? (int) $params['width'] : 200;
100+
$height = !empty($params['height']) && (int) $params['height'] > 0 ? (int) $params['height'] : 200;
101+
$level = !empty($params['level']) && array_search($params['level'], array('L', 'M', 'Q', 'H')) !== false ? $params['level'] : 'M';
102+
103+
$urlencoded = urlencode('otpauth://totp/' . $name . '?secret=' . $secret . '');
104+
105+
if (isset($title)) {
106+
$urlencoded .= urlencode('&issuer=' . urlencode($title));
107+
}
108+
109+
return "https://chart.googleapis.com/chart?chs=" . $width . "x" . $height . "&cht=qr&chl=" . $width . "x" . $height . "&chld=" . $level . "|0&cht=qr&chl=" . $urlencoded;
110+
}
111+
112+
/**
113+
* Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now.
114+
*
115+
* @param string $secret
116+
* @param string $code
117+
* @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after)
118+
* @param int|null $currentTimeSlice time slice if we want use other that time()
119+
*
120+
* @return bool
121+
*/
122+
public function verifyCode($secret, $code, $discrepancy = 1, $currentTimeSlice = null)
123+
{
124+
if ($currentTimeSlice === null) {
125+
$currentTimeSlice = floor(time() / 30);
126+
}
127+
128+
if (strlen($code) != 6) {
129+
return false;
130+
}
131+
132+
for ($i = -$discrepancy; $i <= $discrepancy; ++$i) {
133+
$calculatedCode = $this->getCode($secret, $currentTimeSlice + $i);
134+
if (hash_equals($calculatedCode, $code)) {
135+
return true;
136+
}
137+
}
138+
139+
return false;
140+
}
141+
142+
/**
143+
* Set the code length, should be >=6.
144+
*
145+
* @param int $length
146+
*
147+
* @return GoogleAuthenticator
148+
*/
149+
public function setCodeLength($length)
150+
{
151+
$this->codeLength = $length;
152+
return $this;
153+
}
154+
155+
/**
156+
* Helper class to decode base32.
157+
*
158+
* @param $secret
159+
*
160+
* @return bool|string
161+
*/
162+
protected function base32Decode($secret)
163+
{
164+
if (empty($secret)) {
165+
return '';
166+
}
167+
$base32chars = $this->allowedChars();
168+
$base32charsFlipped = array_flip($base32chars);
169+
$paddingCharCount = substr_count($secret, $base32chars[32]);
170+
$allowedValues = array(6, 4, 3, 1, 0);
171+
if (!in_array($paddingCharCount, $allowedValues)) {
172+
return false;
173+
}
174+
for ($i = 0; $i < 4; ++$i) {
175+
if (
176+
$paddingCharCount == $allowedValues[$i] &&
177+
substr($secret, - ($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])
178+
) {
179+
return false;
180+
}
181+
}
182+
$secret = str_replace('=', '', $secret);
183+
$secret = str_split($secret);
184+
$binaryString = '';
185+
for ($i = 0; $i < count($secret); $i = $i + 8) {
186+
$x = '';
187+
if (!in_array($secret[$i], $base32chars)) {
188+
return false;
189+
}
190+
for ($j = 0; $j < 8; ++$j) {
191+
$x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT);
192+
}
193+
$eightBits = str_split($x, 8);
194+
for ($z = 0; $z < count($eightBits); ++$z) {
195+
$binaryString .= (($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48) ? $y : '';
196+
}
197+
}
198+
199+
return $binaryString;
200+
}
201+
202+
/**
203+
* Get array with all 32 characters for decoding from/encoding to base32.
204+
*
205+
* @return array
206+
*/
207+
protected function allowedChars()
208+
{
209+
return array(
210+
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7
211+
'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
212+
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
213+
'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
214+
'=', // padding char
215+
);
216+
}
217+
}

0 commit comments

Comments
 (0)