Skip to content

Commit

Permalink
Admin MFA
Browse files Browse the repository at this point in the history
Supports OTP via Authenticator app or Code-by-Email verification methods
  • Loading branch information
drbyte committed May 24, 2024
1 parent 8da6f3c commit 3d80b93
Show file tree
Hide file tree
Showing 18 changed files with 794 additions and 12 deletions.
37 changes: 32 additions & 5 deletions admin/admin_account.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@
<?php if ($action == 'password') { ?>
<th class="password"><?php echo TEXT_PASSWORD ?></th>
<th class="password"><?php echo TEXT_CONFIRM_PASSWORD ?></th>
<?php } else if ($action !== 'edit') { ?>
<th class="changed"><?php echo TEXT_PASS_LAST_CHANGED ?></th>
<th class="mfa_status"><?php echo TEXT_MFA_STATUS ?></th>
<?php } ?>
<th class="actions">&nbsp;</th>
</tr>
Expand Down Expand Up @@ -128,11 +131,35 @@
<td class="actions">&nbsp;</td>
<?php } ?>
<?php } else { ?>
<td class="actions">
<a href="<?php echo zen_href_link(FILENAME_ADMIN_ACCOUNT, 'action=edit'); ?>" class="btn btn-primary" role="button"><?php echo IMAGE_EDIT; ?></a>
<a href="<?php echo zen_href_link(FILENAME_ADMIN_ACCOUNT, 'action=password') ?>" class="btn btn-primary"><?php echo IMAGE_RESET_PWD; ?></a>
</td>
</tr>
<td class="changed"><?php echo zen_date_short($userDetails['pwd_last_change_date']); ?></td>
<?php
$user = zen_read_user($userDetails['name']);
$user_mfa_data = json_decode($user['mfa'] ?? '', true, 2);
$mfa_status_of_store = MFA_ENABLED === 'True';
$mfa_status = !empty($user_mfa_data['generated_at']) && !empty($user_mfa_data['secret']);
$mfa_date = $mfa_status ? (new DateTime)->setTimestamp($user_mfa_data['generated_at'])->setTimezone((new DateTime)->getTimezone())->format('Y-m-d H:i:s') : '';
$mfa_email = !empty($user_mfa_data['via_email']);
$mfa_exempt = !empty($user_mfa_data['exempt']);
$mfa_status_msg = TEXT_MFA_NOT_SET;
if (!empty($user_mfa_data['generated_at'])) {
$mfa_status_msg = sprintf(TEXT_MFA_ENABLED_DATE, zen_date_short($mfa_date));
} elseif (!empty($user_mfa_data['via_email'])) {
$mfa_status_msg = TEXT_MFA_BY_EMAIL;
} elseif ($mfa_exempt) {
$mfa_status_msg = TEXT_MFA_EXEMPT;
}
?>
<td class="mfa_status">
<?= $mfa_status_msg ?>
<?php if ($mfa_status_of_store && !$mfa_status && !$mfa_exempt && !$mfa_email) { ?>
<a href="<?php echo zen_href_link(FILENAME_MFA, 'action=setup') ?>" class="btn btn-sm btn-default"><?php echo TEXT_BUTTON_SET_UP; ?></a>
<?php } ?>
</td>
<td class="actions">
<a href="<?php echo zen_href_link(FILENAME_ADMIN_ACCOUNT, 'action=edit'); ?>" class="btn btn-primary" role="button"><?php echo IMAGE_EDIT; ?></a>
<a href="<?php echo zen_href_link(FILENAME_ADMIN_ACCOUNT, 'action=password') ?>" class="btn btn-primary"><?php echo IMAGE_RESET_PWD; ?></a>
</td>
</tr>
<?php } ?>
</tbody>
</table>
Expand Down
186 changes: 186 additions & 0 deletions admin/includes/classes/MultiFactorAuth.php
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);
}

}
34 changes: 30 additions & 4 deletions admin/includes/functions/admin_access.php
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,13 @@ function zen_update_user($name, $email, $id, $profile): array
*/
function zen_read_user(string $name): bool|array
{
global $db;
$sql = "SELECT admin_id, admin_name, admin_email, admin_pass, pwd_last_change_date, reset_token, failed_logins, lockout_expires, admin_profile
global $db, $sniffer;

if (!$sniffer->field_exists(TABLE_ADMIN, 'mfa')) {
$db->Execute('ALTER TABLE ' . TABLE_ADMIN . ' ADD COLUMN mfa TEXT DEFAULT NULL');
}

$sql = "SELECT admin_id, admin_name, admin_email, admin_pass, pwd_last_change_date, reset_token, failed_logins, lockout_expires, admin_profile, mfa
FROM " . TABLE_ADMIN . " WHERE admin_name = :adminname: ";
$sql = $db->bindVars($sql, ':adminname:', $name, 'stringIgnoreNull');
$result = $db->Execute($sql, 1);
Expand Down Expand Up @@ -372,10 +377,10 @@ function zen_validate_user_login(string $admin_name, string $admin_pass): array
// BEGIN 2-factor authentication
if ($error === false && defined('ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE') && ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE !== '') {
if (function_exists(ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE)) {
$response = zen_call_function(ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE, [$result['admin_id'], $result['admin_email'], $result['admin_name']]);
$response = zen_call_function(ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE, ['admin_id' => $result['admin_id'], 'email' => $result['admin_email'], 'admin_name' => $result['admin_name'], 'mfa' => $result['mfa']]);
if ($response !== true) {
$error = true;
$message = ERROR_WRONG_LOGIN;
$message = TEXT_MFA_ERROR;
zen_record_admin_activity('TFA Failure - Two-factor authentication failed', 'warning');
} else {
zen_record_admin_activity('TFA Passed - Two-factor authentication passed', 'warning');
Expand Down Expand Up @@ -976,3 +981,24 @@ function admin_menu_name_sort_callback($a, $b): int
}
return 1;
}

function zen_check_if_mfa_token_is_reused(string $token, ?string $admin_name): bool
{
global $db;
// cleanup all expired tokens
$sql = 'DELETE FROM ' . TABLE_ADMIN_EXPIRED_TOKENS . " WHERE used_date <= NOW() - INTERVAL 24 HOUR";
$db->Execute($sql);

if (empty($admin_name)) {
$admin_name = zen_get_admin_name($_SESSION['admin_id']);
}

// check for current token
$sql = 'SELECT * FROM ' . TABLE_ADMIN_EXPIRED_TOKENS . " WHERE admin_name = '" . zen_db_input($admin_name) . "' AND otp_code = '" . zen_db_input($token) . "'";
$results = $db->Execute($sql, 1);

// if re-used record is found then EOF is not true (if no records found, EOF is true)
$token_is_re_used = !$results->EOF;

return $token_is_re_used;
}

8 comments on commit 3d80b93

@torvista
Copy link
Member

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.

@drbyte
Copy link
Member Author

@drbyte drbyte commented on 3d80b93 May 25, 2024

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?

@drbyte
Copy link
Member Author

@drbyte drbyte commented on 3d80b93 May 25, 2024

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?

@torvista
Copy link
Member

@torvista torvista commented on 3d80b93 May 25, 2024

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.

@drbyte
Copy link
Member Author

@drbyte drbyte commented on 3d80b93 May 25, 2024

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.)

@torvista
Copy link
Member

@torvista torvista commented on 3d80b93 May 26, 2024

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.

@drbyte
Copy link
Member Author

@drbyte drbyte commented on 3d80b93 May 28, 2024

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)

@torvista
Copy link
Member

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?

Please sign in to comment.