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 15, 2024
1 parent 3718a95 commit 2e63f72
Show file tree
Hide file tree
Showing 17 changed files with 783 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;
}

0 comments on commit 2e63f72

Please sign in to comment.