123 lines
3.5 KiB
PHP
123 lines
3.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\library;
|
||
|
||
use ba\Random;
|
||
use app\common\facade\Token;
|
||
use RobThree\Auth\TwoFactorAuth;
|
||
use RobThree\Auth\Providers\Qr\BaconQrCodeProvider;
|
||
|
||
/**
|
||
* 管理员谷歌验证器(TOTP)
|
||
*/
|
||
class AdminTotp
|
||
{
|
||
public const TOKEN_TYPE_BIND = 'admin-totp-bind';
|
||
public const TOKEN_TYPE_VERIFY = 'admin-totp-verify';
|
||
public const TOKEN_EXPIRE = 300;
|
||
|
||
private static ?TwoFactorAuth $tfa = null;
|
||
|
||
private static function tfa(): TwoFactorAuth
|
||
{
|
||
if (self::$tfa === null) {
|
||
$issuer = (string) get_sys_config('site_name');
|
||
if ($issuer === '') {
|
||
$issuer = 'BuildAdmin';
|
||
}
|
||
self::$tfa = new TwoFactorAuth(new BaconQrCodeProvider(), $issuer);
|
||
}
|
||
return self::$tfa;
|
||
}
|
||
|
||
public static function isBound(?string $encryptedSecret): bool
|
||
{
|
||
return is_string($encryptedSecret) && $encryptedSecret !== '';
|
||
}
|
||
|
||
public static function generateSecret(): string
|
||
{
|
||
return self::tfa()->createSecret();
|
||
}
|
||
|
||
public static function getQrDataUri(string $label, string $secret): string
|
||
{
|
||
return self::tfa()->getQRCodeImageAsDataUri($label, $secret);
|
||
}
|
||
|
||
public static function verifyCode(string $plainSecret, string $code): bool
|
||
{
|
||
$code = trim($code);
|
||
if (!preg_match('/^\d{6}$/', $code)) {
|
||
return false;
|
||
}
|
||
return self::tfa()->verifyCode($plainSecret, $code);
|
||
}
|
||
|
||
public static function encryptSecret(string $plainSecret): string
|
||
{
|
||
$key = substr(hash('sha256', (string) config('buildadmin.token.key')), 0, 32);
|
||
$iv = random_bytes(16);
|
||
$encrypted = openssl_encrypt($plainSecret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||
if ($encrypted === false) {
|
||
return '';
|
||
}
|
||
return base64_encode($iv . $encrypted);
|
||
}
|
||
|
||
public static function decryptSecret(string $encryptedSecret): string
|
||
{
|
||
if ($encryptedSecret === '') {
|
||
return '';
|
||
}
|
||
$raw = base64_decode($encryptedSecret, true);
|
||
if ($raw === false || strlen($raw) < 17) {
|
||
return '';
|
||
}
|
||
$iv = substr($raw, 0, 16);
|
||
$encrypted = substr($raw, 16);
|
||
$key = substr(hash('sha256', (string) config('buildadmin.token.key')), 0, 32);
|
||
$plain = openssl_decrypt($encrypted, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||
return $plain === false ? '' : $plain;
|
||
}
|
||
|
||
public static function verifyStoredCode(string $encryptedSecret, string $code): bool
|
||
{
|
||
$plain = self::decryptSecret($encryptedSecret);
|
||
if ($plain === '') {
|
||
return false;
|
||
}
|
||
return self::verifyCode($plain, $code);
|
||
}
|
||
|
||
public static function createPendingToken(int $adminId, string $type): string
|
||
{
|
||
$token = Random::uuid();
|
||
Token::set($token, $type, $adminId, self::TOKEN_EXPIRE);
|
||
return $token;
|
||
}
|
||
|
||
public static function resolvePendingToken(string $token, string $type): int
|
||
{
|
||
$token = trim($token);
|
||
if ($token === '') {
|
||
return 0;
|
||
}
|
||
$data = Token::get($token);
|
||
if (!$data || ($data['type'] ?? '') !== $type) {
|
||
return 0;
|
||
}
|
||
Token::tokenExpirationCheck($data);
|
||
return (int) ($data['user_id'] ?? 0);
|
||
}
|
||
|
||
public static function deletePendingToken(string $token): void
|
||
{
|
||
if ($token !== '') {
|
||
Token::delete($token);
|
||
}
|
||
}
|
||
}
|