-
-
Notifications
You must be signed in to change notification settings - Fork 227
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Supports OTP via Authenticator app or Code-by-Email verification methods
- Loading branch information
Showing
18 changed files
with
794 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
<?php | ||
|
||
/** | ||
* Standalone PHP Class for handling Google Authenticator 2-factor authentication. | ||
* | ||
* Based on / inspired by: https://github.com/RobThree/TwoFactorAuth | ||
* Based on / inspired by: https://github.com/PHPGangsta/GoogleAuthenticator | ||
* | ||
* Algorithms, digits, period etc. explained: https://github.com/google/google-authenticator/wiki/Key-Uri-Format | ||
* | ||
*/ | ||
|
||
class MultiFactorAuth | ||
{ | ||
private static string $_base32dict = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567='; | ||
|
||
/** @var array<string> */ | ||
private static array $_base32; | ||
|
||
/** @var array<string, int> */ | ||
private static array $_base32lookup = []; | ||
|
||
public function __construct( | ||
private int $codeLength = 6, | ||
private int $period = 30, | ||
private string $algorithm = 'sha1', | ||
private ?string $issuer = null, | ||
) { | ||
if ($this->codeLength <= 0) { | ||
throw new ValueError('codeLength must be int > 0'); | ||
} | ||
|
||
if ($this->period <= 0) { | ||
throw new ValueError('Period must be int > 0'); | ||
} | ||
|
||
self::$_base32 = str_split(self::$_base32dict); | ||
self::$_base32lookup = array_flip(self::$_base32); | ||
} | ||
|
||
/** | ||
* Create a new secret | ||
* @throws Exception | ||
*/ | ||
public function createSecret(int $bits = 160): string | ||
{ | ||
$secret = ''; | ||
$bytes = (int)ceil($bits / 5); // We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32) | ||
$rnd = random_bytes($bytes); | ||
for ($i = 0; $i < $bytes; $i++) { | ||
$secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values | ||
} | ||
return $secret; | ||
} | ||
|
||
/** | ||
* Calculate the code with given secret and point in time | ||
*/ | ||
public function getCode(string $secret, ?int $time = null): string | ||
{ | ||
$secretkey = $this->base32Decode($secret); | ||
|
||
$timestamp = "\0\0\0\0" . pack('N*', $this->getTimeSlice($time ?? time())); // Pack time into binary string | ||
$hashhmac = hash_hmac($this->algorithm, $timestamp, $secretkey, true); // Hash it with users secret key | ||
$hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0F, 4); // Use last nibble of result as index/offset and grab 4 bytes of the result | ||
$value = unpack('N', $hashpart); // Unpack binary value | ||
$value = $value[1] & 0x7FFFFFFF; // Drop MSB, keep only 31 bits | ||
|
||
return str_pad((string)($value % 10 ** $this->codeLength), $this->codeLength, '0', STR_PAD_LEFT); | ||
} | ||
|
||
/** | ||
* Check if the code is correct. This will accept codes starting from ($discrepancy * $period) sec ago to ($discrepancy * period) sec from now | ||
*/ | ||
public function verifyCode(string $secret, string $code, int $discrepancy = 1, ?int $time = null, ?int &$timeslice = 0): bool | ||
{ | ||
$timeslice = 0; | ||
|
||
// To keep safe from timing-attacks we iterate *all* possible codes even though we already may have | ||
// verified a code is correct. We use the timeslice variable to hold either 0 (no match) or the timeslice | ||
// of the match. Each iteration we either set the timeslice variable to the timeslice of the match | ||
// or set the value to itself. This is an effort to maintain constant execution time for the code. | ||
for ($i = -$discrepancy; $i <= $discrepancy; $i++) { | ||
$ts = ($time ?? time()) + ($i * $this->period); | ||
$slice = $this->getTimeSlice($ts); | ||
$timeslice = hash_equals($this->getCode($secret, $ts), $code) ? $slice : $timeslice; | ||
} | ||
|
||
return $timeslice > 0; | ||
} | ||
|
||
/** | ||
* Set the code length, should be >=6. | ||
*/ | ||
public function setCodeLength(int $length): static | ||
{ | ||
$this->codeLength = $length; | ||
|
||
return $this; | ||
} | ||
|
||
public function getCodeLength(): int | ||
{ | ||
return $this->codeLength; | ||
} | ||
|
||
private function getTimeSlice(?int $time = null, int $offset = 0): int | ||
{ | ||
return (int)floor($time / $this->period) + ($offset * $this->period); | ||
} | ||
|
||
private function base32Decode(string $value): string | ||
{ | ||
if ($value === '') { | ||
return ''; | ||
} | ||
|
||
if (preg_match('/[^' . preg_quote(self::$_base32dict, '/') . ']/', $value) !== 0) { | ||
throw new ValueError('Invalid base32 string'); | ||
} | ||
|
||
$buffer = ''; | ||
foreach (str_split($value) as $char) { | ||
if ($char !== '=') { | ||
$buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, '0', STR_PAD_LEFT); | ||
} | ||
} | ||
$length = strlen($buffer); | ||
$blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, ' ')); | ||
|
||
$output = ''; | ||
foreach (explode(' ', $blocks) as $block) { | ||
$output .= chr(bindec(str_pad($block, 8, '0', STR_PAD_RIGHT))); | ||
} | ||
return $output; | ||
} | ||
|
||
/** | ||
* Builds a string to be encoded in a QR code | ||
*/ | ||
public function getQRText(string $label, string $secret): string | ||
{ | ||
return 'otpauth://totp/' . rawurlencode($label) | ||
. '?secret=' . rawurlencode($secret) | ||
. '&issuer=' . rawurlencode((string)$this->issuer) | ||
. '&period=' . $this->period | ||
. '&algorithm=' . rawurlencode(strtoupper($this->algorithm)) | ||
. '&digits=' . $this->codeLength; | ||
} | ||
|
||
/** | ||
* Get QR-Code URL for image from QRserver.com. | ||
* See https://goqr.me/api/doc/create-qr-code/ | ||
*/ | ||
public function getQrCodeQrServerUrl(string $domain, string $secretkey, int $size = 200): string | ||
{ | ||
$queryParameters = [ | ||
'size' => $size . 'x' . $size, | ||
'ecc' => 'L', | ||
'margin' => 4, | ||
'qzone' => 1, | ||
'format' => 'png', // 'svg' | ||
'data' => $this->getQRText($domain, $secretkey), | ||
]; | ||
|
||
return 'https://api.qrserver.com/v1/create-qr-code/?' . http_build_query($queryParameters); | ||
} | ||
|
||
/** | ||
* See http://qrickit.com/qrickit_apps/qrickit_api.php | ||
*/ | ||
public function getQrCodeQRicketUrl(string $domain, string $secretkey, int $size = 200): string | ||
{ | ||
$queryParameters = [ | ||
'qrsize' => $size, | ||
'e' => 'l', | ||
'bgdcolor' => 'ffffff', | ||
'fgdcolor' => '000000', | ||
't' => 'p', // png | ||
'd' => $this->getQRText($domain, $secretkey), | ||
]; | ||
|
||
return 'https://qrickit.com/api/qr?' . http_build_query($queryParameters); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
3d80b93
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this work locally? I get invalid QR code with my Authenticator app.
3d80b93
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it works with both local URLs and live URLs.
Which Authenticator app?
3d80b93
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@torvista ... I tested this with various Authenticator apps, including Google Authenticator, Microsoft Authenticator, 1Password, Apple's Safari and iPhone built-in auth QR scanner and code generator, LastPass mobile, DuoMobile, and Authy.
If you scan the QR code in the screenshots in this original PR, does it recognize the code?
3d80b93
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TOTP Authenticator on Android.
Yes it reads the PR screenshot but not the admin image.
3d80b93
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@torvista what's the local store URL and subdirectory name? I wanna simulate it here ...
Or maybe even post a screenshot of the QR code ... .perhaps there's something about the URL being used to build your QR code that's getting mangled (like a subdir name or something maybe?).
(You can delete the screenshot later after we resolve the matter. It'll be fine anyway cuz as long as the encoded "secret" is changed, it'll be useless to anyone seeing it later .... and that "secret" can be changed by using the "Reset" button via your store Admin, or just by clearing the MFA field in the db. Again, for local testing it's moot anyway.)
3d80b93
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A QR reader can read the link, then it passes to the authenticator that says it is invalid....there is no exterior access to these local sites.
These are the links that the QR reader gets:
https://www.website.es.local/
gives
otpauth://totp/www.website.es.local?secret=3TPM2PUSXJIGCQUCCLARJOVKZ5OAYH5L&issuer=&period=30&algorithm=SHA1&digits=6
and
https://www.github.local/zencart
I note there is no subdirectory in this:
otpauth://totp/www.github.local?secret=3XO52CBDQH5II3JXDS6ORJTGJTCJX7SK&issuer=&period=30&algorithm=SHA1&digits=6
Production Site, same issue.
otpauth://totp/www.website.es?secret=X723VFQBP2SRWAYJ24QIOGDSLJC4XYOI&issuer=&period=30&algorithm=SHA1&digits=6
I tried the email option, and that did not arrive/no evidence of it being sent.
3d80b93
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@torvista check out latest updates.
5b9f527 has hopefully mitigated the rejected QR Code, by virtue of passing both an issuer and an admin username.
(I notice the links you posted don't reflect an admin username ... which should never have been the case, but that could be your problem)
3d80b93
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
works for me both email and athenticator, without the local-qr-gen.
I didn't remove any username from the links I posted.
Should not the pointless Please Select be the pre-selected option instead of OTP?