mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: items #9-13 — password change, webmail SSO, DKIM live, file manager security, cache busting
#9 auth.php: add self-service change-password action (current+new+confirm) accounts.php: fix admin change-password — accept account_id, fetch username for chpasswd (was using int ID), add Auth::require('admin') guard user.js: add Change Password page + navItem + submitChangePassword() #10 EmailManager: store AES-256-CBC enc_password alongside SHA512-CRYPT hash webmail.php: rewrite login-url to use webmail_sso_tokens table novacpx-sso.php: Roundcube SSO bridge (validate token, decrypt, autosubmit) Migration 005: add enc_password column + webmail_sso_tokens table #11 opendkim: installed, configured (/etc/opendkim.conf, signing.table, key.table, trusted.hosts), socket at /var/spool/postfix/opendkim/, Postfix milter wired, service enabled+running, key generation verified #12 files.php: fix safe_path() for non-existent paths (write/mkdir), add safe_path_new() helper using parent-dir realpath check, fix delete guard (block deleting account root dirs), fix rename destination, clamp chmod to 0777 #13 nova.js: api() handles network errors, 429 rate-limit with retry-after, non-JSON responses (PHP fatal pages) — graceful error instead of throw admin/user/reseller index.php: filemtime-based cache-busting on all assets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
class TOTP {
|
||||
private const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
private const PERIOD = 30;
|
||||
private const DIGITS = 6;
|
||||
private const WINDOW = 1;
|
||||
|
||||
public static function generateSecret(int $length = 32): string {
|
||||
$secret = '';
|
||||
$bytes = random_bytes($length);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$secret .= self::CHARS[ord($bytes[$i]) & 31];
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
public static function generateCode(string $secret, ?int $timestamp = null): string {
|
||||
$counter = intdiv($timestamp ?? time(), self::PERIOD);
|
||||
$key = self::base32Decode($secret);
|
||||
$msg = pack('J', $counter);
|
||||
$hash = hash_hmac('sha1', $msg, $key, true);
|
||||
$offset = ord($hash[19]) & 0x0F;
|
||||
$code = ((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($code % (10 ** self::DIGITS), self::DIGITS, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public static function verify(string $secret, string $code): bool {
|
||||
$time = time();
|
||||
for ($i = -self::WINDOW; $i <= self::WINDOW; $i++) {
|
||||
if (hash_equals(self::generateCode($secret, $time + $i * self::PERIOD), $code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function qrUrl(string $secret, string $username, string $issuer = 'NovaCPX'): string {
|
||||
$label = rawurlencode("{$issuer}:{$username}");
|
||||
$otpauth = "otpauth://totp/{$label}?secret={$secret}&issuer=" . rawurlencode($issuer) . "&algorithm=SHA1&digits=6&period=30";
|
||||
return "https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=" . rawurlencode($otpauth);
|
||||
}
|
||||
|
||||
public static function generateBackupCodes(int $count = 8): array {
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$raw = bin2hex(random_bytes(4));
|
||||
$codes[] = strtoupper(substr($raw, 0, 4) . '-' . substr($raw, 4, 4));
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
public static function hashBackupCodes(array $codes): string {
|
||||
return json_encode(array_map(fn($c) => password_hash($c, PASSWORD_BCRYPT), $codes));
|
||||
}
|
||||
|
||||
public static function verifyBackupCode(string $code, string $hashedJson): bool {
|
||||
$hashes = json_decode($hashedJson, true) ?? [];
|
||||
foreach ($hashes as $hash) {
|
||||
if (password_verify(strtoupper($code), $hash)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function base32Decode(string $base32): string {
|
||||
$base32 = strtoupper(preg_replace('/[^A-Z2-7]/', '', $base32));
|
||||
$buf = 0; $bits = 0; $out = '';
|
||||
for ($i = 0; $i < strlen($base32); $i++) {
|
||||
$val = strpos(self::CHARS, $base32[$i]);
|
||||
$buf = ($buf << 5) | $val;
|
||||
$bits += 5;
|
||||
if ($bits >= 8) { $bits -= 8; $out .= chr(($buf >> $bits) & 0xFF); }
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user