Update from Git Manager GUI
This commit is contained in:
179
includes/class-forum-totp.php
Normal file
179
includes/class-forum-totp.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user