179 lines
7.0 KiB
PHP
179 lines
7.0 KiB
PHP
<?php
|
|
if ( ! defined( 'ABSPATH' ) ) exit;
|
|
|
|
/**
|
|
* WBF_TOTP — RFC 6238 Time-based One-Time Password (TOTP)
|
|
*
|
|
* Keine externe Bibliothek nötig — reines PHP 7.0+.
|
|
* Kompatibel mit: Google Authenticator, Aegis, Authy, Bitwarden, 2FAS, etc.
|
|
*
|
|
* Secrets werden in forum_user_meta gespeichert (meta_key = 'totp_secret').
|
|
* Kein Schema-Change an der Haupt-Usertabelle nötig.
|
|
*/
|
|
class WBF_TOTP {
|
|
|
|
const DIGITS = 6;
|
|
const PERIOD = 30; // Sekunden pro Schritt
|
|
const WINDOW = 1; // ±1 Step Toleranz (= ±30 s Uhrabweichung OK)
|
|
const SECRET_LEN = 20; // Bytes → 32 Base32-Zeichen
|
|
|
|
// Meta-Keys
|
|
const META_SECRET = 'totp_secret';
|
|
const META_PENDING = 'totp_secret_pending';
|
|
|
|
// Session-Key für ausstehenden Login
|
|
const SESSION_PENDING = 'wbf_2fa_pending';
|
|
|
|
// ── Secret ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Erzeugt einen neuen, kryptografisch sicheren Base32-Secret.
|
|
* @return string z.B. "JBSWY3DPEBLW64TMMQ======"
|
|
*/
|
|
public static function generate_secret() {
|
|
return self::base32_encode( random_bytes( self::SECRET_LEN ) );
|
|
}
|
|
|
|
// ── Verifikation ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Prüft ob $code für $secret zum aktuellen Zeitfenster passt.
|
|
*
|
|
* @param string $secret Base32-Secret des Users
|
|
* @param string $code 6-stelliger Code aus der Authenticator-App
|
|
* @param int $window Anzahl Steps Toleranz (default = 1 = ±30 s)
|
|
* @return bool
|
|
*/
|
|
public static function verify( $secret, $code, $window = self::WINDOW ) {
|
|
// Leerzeichen tolerieren (z.B. "123 456")
|
|
$code = preg_replace( '/\s+/', '', (string) $code );
|
|
if ( strlen($code) !== self::DIGITS ) return false;
|
|
if ( ! ctype_digit($code) ) return false;
|
|
|
|
$key = self::base32_decode( $secret );
|
|
if ( empty($key) ) return false;
|
|
|
|
$ts = (int) floor( time() / self::PERIOD );
|
|
|
|
for ( $i = -$window; $i <= $window; $i++ ) {
|
|
$expected = self::hotp( $key, $ts + $i );
|
|
// Timing-safe Vergleich
|
|
if ( hash_equals( $expected, $code ) ) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── HOTP-Kern (RFC 4226) ─────────────────────────────────────────────────
|
|
|
|
private static function hotp( $key, $counter ) {
|
|
// 64-bit Big-Endian Counter
|
|
$msg = pack( 'N', 0 ) . pack( 'N', $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 )
|
|
) % ( 10 ** self::DIGITS );
|
|
|
|
return str_pad( (string) $code, self::DIGITS, '0', STR_PAD_LEFT );
|
|
}
|
|
|
|
// ── otpauth:// URI ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Gibt die otpauth:// URI zurück — wird vom QR-Code-Generator verwendet.
|
|
*
|
|
* @param string $username Forum-Benutzername
|
|
* @param string $secret Base32-Secret
|
|
* @param string|null $issuer Anzeigename in der App (default: Blogname)
|
|
* @return string
|
|
*/
|
|
public static function get_otpauth_uri( $username, $secret, $issuer = null ) {
|
|
if ( ! $issuer ) {
|
|
$issuer = html_entity_decode( get_bloginfo('name'), ENT_QUOTES ) ?: 'WP Business Forum';
|
|
}
|
|
$label = rawurlencode( $issuer . ':' . $username );
|
|
return 'otpauth://totp/' . $label . '?'
|
|
. 'secret=' . rawurlencode( $secret )
|
|
. '&issuer=' . rawurlencode( $issuer )
|
|
. '&algorithm=SHA1'
|
|
. '&digits=' . self::DIGITS
|
|
. '&period=' . self::PERIOD;
|
|
}
|
|
|
|
// ── User-Helfer ───────────────────────────────────────────────────────────
|
|
|
|
/** Ist 2FA für diesen User aktiv? */
|
|
public static function is_enabled_for( $user_id ) {
|
|
$s = WBF_DB::get_user_meta_single( (int) $user_id, self::META_SECRET );
|
|
return ! empty( $s );
|
|
}
|
|
|
|
/**
|
|
* 2FA für einen User deaktivieren (löscht Secret + ggf. pending Secret).
|
|
* Kann von Admin und User selbst (nach Verifikation) aufgerufen werden.
|
|
*/
|
|
public static function disable_for( $user_id ) {
|
|
global $wpdb;
|
|
$uid = (int) $user_id;
|
|
$wpdb->delete(
|
|
"{$wpdb->prefix}forum_user_meta",
|
|
[ 'user_id' => $uid, 'meta_key' => self::META_SECRET ],
|
|
[ '%d', '%s' ]
|
|
);
|
|
$wpdb->delete(
|
|
"{$wpdb->prefix}forum_user_meta",
|
|
[ 'user_id' => $uid, 'meta_key' => self::META_PENDING ],
|
|
[ '%d', '%s' ]
|
|
);
|
|
}
|
|
|
|
// ── Base32 ────────────────────────────────────────────────────────────────
|
|
|
|
private static $b32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
|
|
public static function base32_encode( $input ) {
|
|
$output = '';
|
|
$buf = 0;
|
|
$buf_bits = 0;
|
|
for ( $i = 0, $len = strlen($input); $i < $len; $i++ ) {
|
|
$buf = ( $buf << 8 ) | ord( $input[$i] );
|
|
$buf_bits += 8;
|
|
while ( $buf_bits >= 5 ) {
|
|
$buf_bits -= 5;
|
|
$output .= self::$b32[ ( $buf >> $buf_bits ) & 0x1f ];
|
|
}
|
|
}
|
|
if ( $buf_bits > 0 ) {
|
|
$output .= self::$b32[ ( $buf << ( 5 - $buf_bits ) ) & 0x1f ];
|
|
}
|
|
// Padding to multiple of 8
|
|
while ( strlen($output) % 8 !== 0 ) $output .= '=';
|
|
return $output;
|
|
}
|
|
|
|
public static function base32_decode( $input ) {
|
|
// Leerzeichen & Padding entfernen, Uppercase
|
|
$input = strtoupper( preg_replace( '/\s+/', '', $input ) );
|
|
$input = rtrim( $input, '=' );
|
|
$map = array_flip( str_split( self::$b32 ) );
|
|
|
|
$output = '';
|
|
$buf = 0;
|
|
$bits = 0;
|
|
for ( $i = 0, $len = strlen($input); $i < $len; $i++ ) {
|
|
if ( ! isset( $map[ $input[$i] ] ) ) continue; // ungültiges Zeichen ignorieren
|
|
$buf = ( $buf << 5 ) | $map[ $input[$i] ];
|
|
$bits += 5;
|
|
if ( $bits >= 8 ) {
|
|
$bits -= 8;
|
|
$output .= chr( ( $buf >> $bits ) & 0xff );
|
|
}
|
|
}
|
|
return $output;
|
|
}
|
|
} |