includes/modules/two_factor.php
<?php
declare(strict_types=1);
/*
|--------------------------------------------------------------------------
| Two-factor authentication (TOTP)
|--------------------------------------------------------------------------
|
| Local RFC 6238-compatible TOTP implementation.
| No third-party API is called: authenticator apps receive a standard
| otpauth:// URI or the Base32 secret for manual entry.
|
*/
require_once __DIR__ . '/../graphs/qrcode.php';
function brivaciaTwoFactorSettings(): array
{
$settings = brivacia_setting('admin.two_factor', []);
return is_array($settings) ? $settings : [];
}
function brivaciaTwoFactorEnabled(): bool
{
$settings = brivaciaTwoFactorSettings();
return
!empty($settings['enabled'])
&& trim((string)($settings['secret'] ?? '')) !== '';
}
function brivaciaTwoFactorSecret(): string
{
return strtoupper(trim((string)(brivaciaTwoFactorSettings()['secret'] ?? '')));
}
function brivaciaTwoFactorBackupCodeHashes(): array
{
$codes = brivaciaTwoFactorSettings()['backup_codes'] ?? [];
return is_array($codes) ? array_values($codes) : [];
}
function brivaciaTwoFactorLastCounter(): int
{
return max(0, (int)(brivaciaTwoFactorSettings()['last_counter'] ?? 0));
}
function brivaciaTwoFactorSetupSettings(): array
{
$settings = brivacia_setting('admin.two_factor_setup', []);
return is_array($settings) ? $settings : [];
}
function brivaciaTwoFactorCreateSetup(): array
{
$secret = brivaciaTwoFactorGenerateSecret();
$token = bin2hex(random_bytes(16));
$settings = brivacia_settings();
$settings['admin']['two_factor_setup'] = [
'token_hash' => hash('sha256', $token),
'secret' => $secret,
'expires_at' => time() + 600,
];
brivacia_save_settings($settings);
return [
'token' => $token,
'secret' => $secret,
'otpauth_uri' => brivaciaTwoFactorOtpauthUri($secret),
];
}
function brivaciaTwoFactorPendingSecret(string $token): string
{
$token = trim($token);
$setup = brivaciaTwoFactorSetupSettings();
$tokenHash = (string)($setup['token_hash'] ?? '');
$secret = strtoupper(trim((string)($setup['secret'] ?? '')));
$expiresAt = (int)($setup['expires_at'] ?? 0);
if ($token === '' || $tokenHash === '' || $secret === '' || $expiresAt < time()) {
brivaciaTwoFactorClearSetup();
return '';
}
if (!hash_equals($tokenHash, hash('sha256', $token))) {
return '';
}
return $secret;
}
function brivaciaTwoFactorClearSetup(): void
{
$settings = brivacia_settings();
$settings['admin']['two_factor_setup'] = [];
brivacia_save_settings($settings);
}
function brivaciaTwoFactorBase32Encode(string $bytes): string
{
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
$output = '';
foreach (str_split($bytes) as $byte) {
$bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT);
}
foreach (str_split($bits, 5) as $chunk) {
if (strlen($chunk) < 5) {
$chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
}
$output .= $alphabet[bindec($chunk)];
}
return $output;
}
function brivaciaTwoFactorBase32Decode(string $secret): string|false
{
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$secret = strtoupper($secret);
$secret = preg_replace('/[^A-Z2-7]/', '', $secret) ?? '';
if ($secret === '') {
return false;
}
$bits = '';
foreach (str_split($secret) as $char) {
$value = strpos($alphabet, $char);
if ($value === false) {
return false;
}
$bits .= str_pad(decbin($value), 5, '0', STR_PAD_LEFT);
}
$output = '';
foreach (str_split($bits, 8) as $byte) {
if (strlen($byte) === 8) {
$output .= chr(bindec($byte));
}
}
return $output !== '' ? $output : false;
}
function brivaciaTwoFactorGenerateSecret(): string
{
return brivaciaTwoFactorBase32Encode(random_bytes(20));
}
function brivaciaTwoFactorFormatSecret(string $secret): string
{
return trim(chunk_split(strtoupper($secret), 4, ' '));
}
function brivaciaTwoFactorNormalizeBackupCode(string $code): string
{
return strtoupper(preg_replace('/[^A-Z2-7]/i', '', $code) ?? '');
}
function brivaciaTwoFactorGenerateBackupCodes(int $count = 10): array
{
$codes = [];
for ($i = 0; $i < $count; $i++) {
$raw = brivaciaTwoFactorBase32Encode(random_bytes(5));
$codes[] = substr($raw, 0, 5) . '-' . substr($raw, 5, 5);
}
return $codes;
}
function brivaciaTwoFactorHashBackupCodes(array $codes): array
{
return array_map(
fn (string $code): string => password_hash(
brivaciaTwoFactorNormalizeBackupCode($code),
PASSWORD_DEFAULT
),
$codes
);
}
function brivaciaTwoFactorOtpauthUri(string $secret): string
{
$issuer = brivacia_setting('dashboard.instance_name', 'Brivacia');
$issuer = trim((string)$issuer) ?: 'Brivacia';
$account = brivaciaAuthUser() ?: 'admin';
$label = rawurlencode($issuer . ':' . $account);
return 'otpauth://totp/' . $label . '?' . http_build_query([
'secret' => $secret,
'issuer' => $issuer,
'algorithm' => 'SHA1',
'digits' => 6,
'period' => 30,
], '', '&', PHP_QUERY_RFC3986);
}
function brivaciaTwoFactorCodeForCounter(string $secret, int $counter): ?string
{
$key = brivaciaTwoFactorBase32Decode($secret);
if ($key === false) {
return null;
}
$binaryCounter = pack('N*', 0, $counter);
$hash = hash_hmac('sha1', $binaryCounter, $key, true);
$offset = ord($hash[19]) & 0x0f;
$value =
((ord($hash[$offset]) & 0x7f) << 24)
| ((ord($hash[$offset + 1]) & 0xff) << 16)
| ((ord($hash[$offset + 2]) & 0xff) << 8)
| (ord($hash[$offset + 3]) & 0xff);
return str_pad((string)($value % 1000000), 6, '0', STR_PAD_LEFT);
}
function brivaciaTwoFactorVerifyTotp(
string $secret,
string $code,
int $timestamp = 0,
int $window = 1,
?int &$matchedCounter = null
): bool {
$code = preg_replace('/\D/', '', $code) ?? '';
if (!preg_match('/^\d{6}$/', $code)) {
return false;
}
$timestamp = $timestamp > 0 ? $timestamp : time();
$counter = intdiv($timestamp, 30);
for ($offset = -$window; $offset <= $window; $offset++) {
$testCounter = $counter + $offset;
if ($testCounter < 0) {
continue;
}
$expected = brivaciaTwoFactorCodeForCounter($secret, $testCounter);
if ($expected !== null && hash_equals($expected, $code)) {
$matchedCounter = $testCounter;
return true;
}
}
return false;
}
function brivaciaTwoFactorSave(array $twoFactor): void
{
$settings = brivacia_settings();
$settings['admin']['two_factor'] = $twoFactor;
brivacia_save_settings($settings);
}
function brivaciaTwoFactorEnable(string $secret, array $backupCodes, int $lastCounter = 0): void
{
$settings = brivacia_settings();
$settings['admin']['two_factor'] = [
'enabled' => true,
'secret' => $secret,
'backup_codes' => brivaciaTwoFactorHashBackupCodes($backupCodes),
'last_counter' => max(0, $lastCounter),
];
$settings['admin']['two_factor_setup'] = [];
brivacia_save_settings($settings);
}
function brivaciaTwoFactorDisable(): void
{
$settings = brivacia_settings();
$settings['admin']['two_factor'] = [
'enabled' => false,
'secret' => '',
'backup_codes' => [],
'last_counter' => 0,
];
$settings['admin']['two_factor_setup'] = [];
brivacia_save_settings($settings);
}
function brivaciaTwoFactorVerifyBackupCode(string $code): bool
{
$normalized = brivaciaTwoFactorNormalizeBackupCode($code);
if ($normalized === '') {
return false;
}
$hashes = brivaciaTwoFactorBackupCodeHashes();
foreach ($hashes as $index => $hash) {
if (password_verify($normalized, (string)$hash)) {
unset($hashes[$index]);
$settings = brivaciaTwoFactorSettings();
$settings['backup_codes'] = array_values($hashes);
brivaciaTwoFactorSave($settings);
return true;
}
}
return false;
}
function brivaciaTwoFactorVerifyLoginCode(string $code): bool
{
if (!brivaciaTwoFactorEnabled()) {
return true;
}
$secret = brivaciaTwoFactorSecret();
$matchedCounter = null;
if (brivaciaTwoFactorVerifyTotp($secret, $code, time(), 1, $matchedCounter)) {
if ($matchedCounter !== null && $matchedCounter <= brivaciaTwoFactorLastCounter()) {
return false;
}
$settings = brivaciaTwoFactorSettings();
$settings['last_counter'] = max((int)$matchedCounter, brivaciaTwoFactorLastCounter());
brivaciaTwoFactorSave($settings);
return true;
}
return brivaciaTwoFactorVerifyBackupCode($code);
}