Ver Fonte

Merge pull request #2522 from pplulee/mfa

feat: multifactor authentication(totp/fido/webauthn)
Anankke há 2 meses atrás
pai
commit
05221b0f52

+ 15 - 3
app/routes.php

@@ -74,9 +74,15 @@ return static function (Slim\App $app): void {
         // 发送验证邮件
         $group->post('/edit/send', App\Controllers\AuthController::class . ':sendVerify');
         // MFA
-        $group->post('/ga_check', App\Controllers\User\MFAController::class . ':checkGa');
-        $group->post('/ga_set', App\Controllers\User\MFAController::class . ':setGa');
-        $group->post('/ga_reset', App\Controllers\User\MFAController::class . ':resetGa');
+        $group->get('/totp', App\Controllers\User\MFAController::class . ':totpRegisterRequest');
+        $group->post('/totp', App\Controllers\User\MFAController::class . ':totpRegisterHandle');
+        $group->delete('/totp', App\Controllers\User\MFAController::class . ':totpDelete');
+        $group->get('/webauthn', App\Controllers\User\MFAController::class . ':webauthnRegisterRequest');
+        $group->post('/webauthn', App\Controllers\User\MFAController::class . ':webauthnRegisterHandle');
+        $group->delete('/webauthn/{id:[0-9]+}', App\Controllers\User\MFAController::class . ':webauthnDelete');
+        $group->get('/fido', App\Controllers\User\MFAController::class . ':fidoRegisterRequest');
+        $group->post('/fido', App\Controllers\User\MFAController::class . ':fidoRegisterHandle');
+        $group->delete('/fido/{id:[0-9]+}', App\Controllers\User\MFAController::class . ':fidoDelete');
         // 账户余额
         $group->get('/money', App\Controllers\User\MoneyController::class . ':index');
         $group->post('/giftcard', App\Controllers\User\MoneyController::class . ':applyGiftCard');
@@ -117,6 +123,12 @@ return static function (Slim\App $app): void {
         $group->post('/register', App\Controllers\AuthController::class . ':registerHandle');
         $group->post('/send', App\Controllers\AuthController::class . ':sendVerify');
         $group->get('/logout', App\Controllers\AuthController::class . ':logout');
+        $group->get('/webauthn', App\Controllers\AuthController::class . ':webauthnRequest');
+        $group->post('/webauthn', App\Controllers\AuthController::class . ':webauthnHandle');
+        $group->get('/mfa', App\Controllers\AuthController::class . ':mfaPage');
+        $group->post('/totp', App\Controllers\AuthController::class . ':totpHandle');
+        $group->get('/fido', App\Controllers\AuthController::class . ':fidoRequest');
+        $group->post('/fido', App\Controllers\AuthController::class . ':fidoHandle');
     })->add(new Guest());
     // Password
     $app->group('/password', static function (RouteCollectorProxy $group): void {

+ 2 - 1
composer.json

@@ -46,7 +46,8 @@
         "symfony/translation": "^7",
         "twig/twig": "^3",
         "vectorface/googleauthenticator": "^3",
-        "voku/anti-xss": "^4"
+        "voku/anti-xss": "^4",
+        "web-auth/webauthn-framework": "^5.2"
     },
     "autoload": {
         "psr-4": {

Diff do ficheiro suprimidas por serem muito extensas
+ 678 - 75
composer.lock


+ 56 - 0
db/migrations/2025073100-refactor_mfa.php

@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Interfaces\MigrationInterface;
+use App\Models\MFADevice;
+use App\Models\User;
+use App\Services\DB;
+
+return new class() implements MigrationInterface {
+    public function up(): int
+    {
+        DB::getPdo()->exec("
+            CREATE TABLE `mfa_devices` (
+                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+                `userid` int(11) unsigned NOT NULL COMMENT '用户ID',
+                `body` text NOT NULL COMMENT '密钥内容',
+                `name` varchar(255) DEFAULT NULL COMMENT '设备名称',
+                `rawid` varchar(255) DEFAULT NULL COMMENT '设备ID',
+                `created_at` datetime NOT NULL COMMENT '创建时间',
+                `used_at` datetime DEFAULT NULL COMMENT '上次使用时间',
+                `type` varchar(50) DEFAULT NULL COMMENT '设备类型',
+                PRIMARY KEY (`id`),
+                KEY `userid` (`userid`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+        ");
+
+        $users = (new User())->where('ga_enable', 1)->get();
+
+        foreach ($users as $user) {
+            $token = $user->ga_token;
+
+            $MFADevice = new MFADevice();
+            $MFADevice->userid = $user->id;
+            $MFADevice->name = 'TOTP';
+            $MFADevice->rawid = 'TOTP';
+            $MFADevice->body = json_encode(['token' => $token]);
+            $MFADevice->type = 'totp';
+            $MFADevice->created_at = date('Y-m-d H:i:s');
+            $MFADevice->save();
+        }
+
+        DB::getPdo()->exec('ALTER TABLE `user` DROP COLUMN `ga_enable`, DROP COLUMN `ga_token`;');
+
+        return 2025073100;
+    }
+
+    public function down(): int
+    {
+        DB::getPdo()->exec('
+            DROP TABLE IF EXISTS `mfa_devices`;
+        ');
+
+        return 2024061600;
+    }
+};

+ 39 - 6
resources/views/tabler/auth/login.tpl

@@ -1,5 +1,7 @@
 {include file='header.tpl'}
 
+<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
+
 <body class="border-top-wide border-primary d-flex flex-column">
 <div class="page page-center">
     <div class="container-tight my-auto">
@@ -26,10 +28,6 @@
                         <input id="password" type="password" class="form-control" autocomplete="off">
                     </div>
                 </div>
-                <div class="mb-2">
-                    <label class="form-label">两步认证</label>
-                    <input id="mfa_code" type="email" class="form-control" placeholder="如果没有设置两步认证可留空">
-                </div>
                 <div class="mb-2">
                     <label class="form-check">
                         <input id="remember_me" type="checkbox" class="form-check-input"/>
@@ -44,18 +42,20 @@
                     </div>
                 </div>
                 <div class="form-footer">
-                    <button class="btn btn-primary w-100"
+                    <button class="btn btn-primary w-100 mb-3"
                             hx-post="/auth/login" hx-swap="none" hx-vals='js:{
                                 {if $public_setting['enable_login_captcha']}
                                     {include file='captcha/ajax.tpl'}
                                 {/if}
                                 email: document.getElementById("email").value,
                                 password: document.getElementById("password").value,
-                                mfa_code: document.getElementById("mfa_code").value,
                                 remember_me: document.getElementById("remember_me").checked,
                              }'>
                         登录
                     </button>
+                    <button class="btn btn-primary w-100" id="webauthnLogin">
+                        使用WebAuthn登录
+                    </button>
                 </div>
             </div>
         </div>
@@ -70,3 +70,36 @@
 {/if}
 
 {include file='footer.tpl'}
+
+{literal}
+    <script>
+        const { startAuthentication } = SimpleWebAuthnBrowser;
+        document.getElementById('webauthnLogin').addEventListener('click', async () => {
+            const resp = await fetch('/auth/webauthn');
+            const options = await resp.json();
+            let asseResp;
+            try {
+                asseResp = await startAuthentication({ optionsJSON: options });
+            } catch (error) {
+                document.getElementById("fail-message").innerHTML = error;
+                throw error;
+            }
+            const verificationResp = await fetch('/auth/webauthn', {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: JSON.stringify(asseResp),
+            });
+            const verificationJSON = await verificationResp.json();
+            if (verificationJSON.ret === 1) {
+                document.getElementById("success-message").innerHTML = verificationJSON.msg;
+                successDialog.show();
+                window.location.href = verificationJSON.redir;
+            } else {
+                document.getElementById("fail-message").innerHTML = verificationJSON.msg;
+                failDialog.show();
+            }
+        });
+    </script>
+{/literal}

+ 131 - 0
resources/views/tabler/auth/mfa.tpl

@@ -0,0 +1,131 @@
+{include file='header.tpl'}
+
+<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
+
+<body class="border-top-wide border-primary d-flex flex-column">
+<div class="page page-center">
+    <div class="container-tight my-auto">
+        <div class="card card-md">
+            <div class="card-body">
+                <h2 class="card-title text-center mb-4">二步验证</h2>
+                <p>您的账户已启用二步验证,为了您的账户安全,请您完成附加身份验证。</p>
+                {if $method['totp']}
+                    <div class="my-5">
+                        <div class="row g-4">
+                            <div class="col">
+                                <div class="row g-2">
+                                    <div class="col">
+                                        <input type="text" class="form-control form-control-lg text-center py-3 px-3"
+                                               maxlength="1" inputmode="numeric" pattern="[0-9]*" data-code-input="">
+                                    </div>
+                                    <div class="col">
+                                        <input type="text" class="form-control form-control-lg text-center py-3 px-3"
+                                               maxlength="1" inputmode="numeric" pattern="[0-9]*" data-code-input="">
+                                    </div>
+                                    <div class="col">
+                                        <input type="text" class="form-control form-control-lg text-center py-3 px-3"
+                                               maxlength="1" inputmode="numeric" pattern="[0-9]*" data-code-input="">
+                                    </div>
+                                </div>
+                            </div>
+                            <div class="col">
+                                <div class="row g-2">
+                                    <div class="col">
+                                        <input type="text" class="form-control form-control-lg text-center py-3 px-3"
+                                               maxlength="1" inputmode="numeric" pattern="[0-9]*" data-code-input="">
+                                    </div>
+                                    <div class="col">
+                                        <input type="text" class="form-control form-control-lg text-center py-3 px-3"
+                                               maxlength="1" inputmode="numeric" pattern="[0-9]*" data-code-input="">
+                                    </div>
+                                    <div class="col">
+                                        <input type="text" class="form-control form-control-lg text-center py-3 px-3"
+                                               maxlength="1" inputmode="numeric" pattern="[0-9]*" data-code-input="">
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                {/if}
+                <div class="form-footer">
+                    {if $method['totp']}
+                        <button class="btn btn-primary w-100 mb-3"
+                                hx-post="/auth/totp" hx-swap="none" hx-vals="js:{
+                                code: code,
+                             }">
+                            提交
+                        </button>
+                    {/if}
+                    {if $method['fido']}
+                        <button class="btn btn-primary w-100" id="webauthnLogin">
+                            使用 FIDO2 验证
+                        </button>
+                    {/if}
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+{include file='footer.tpl'}
+
+{if $method['totp']}
+    <script>
+        var code = '';
+        document.addEventListener("DOMContentLoaded", function () {
+            var inputs = document.querySelectorAll('[data-code-input]');
+
+            for (let i = 0; i < inputs.length; i++) {
+                inputs[i].addEventListener('input', function (e) {
+                    if (e.target.value.length === e.target.maxLength && i + 1 < inputs.length) {
+                        inputs[i + 1].focus();
+                    }
+                    code = '';
+                    inputs.forEach(input => {
+                        code += input.value;
+                    });
+                });
+                inputs[i].addEventListener('keydown', function (e) {
+                    if (e.target.value.length === 0 && e.keyCode === 8 && i > 0) {
+                        inputs[i - 1].focus();
+                    }
+                });
+            }
+        });
+    </script>
+{/if}
+
+{include file='footer.tpl'}
+
+{if $method['fido']}
+<script>
+    const { startAuthentication } = SimpleWebAuthnBrowser;
+    document.getElementById('webauthnLogin').addEventListener('click', async () => {
+        const resp = await fetch('/auth/fido');
+        const options = await resp.json();
+        let asseResp;
+        try {
+            asseResp = await startAuthentication({ optionsJSON: options });
+        } catch (error) {
+            document.getElementById("fail-message").innerHTML = error;
+            throw error;
+        }
+        const verificationResp = await fetch('/auth/fido', {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json',
+            },
+            body: JSON.stringify(asseResp),
+        });
+        const verificationJSON = await verificationResp.json();
+        if (verificationJSON.ret === 1) {
+            document.getElementById("success-message").innerHTML = verificationJSON.msg;
+            successDialog.show();
+            window.location.href = verificationJSON.redir;
+        } else {
+            document.getElementById("fail-message").innerHTML = verificationJSON.msg;
+            failDialog.show();
+        }
+    });
+</script>
+{/if}

+ 269 - 78
resources/views/tabler/user/edit.tpl

@@ -1,6 +1,7 @@
 {include file='user/header.tpl'}
 
 <script src="//{$config['jsdelivr_url']}/npm/jquery/dist/jquery.min.js"></script>
+<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
 
 <div class="page-wrapper">
     <div class="container-xl">
@@ -187,75 +188,6 @@
                                 </div>
                                 <div class="tab-pane" id="login_security" role="tabpanel">
                                     <div class="row row-deck row-cards">
-                                        <div class="col-sm-12 col-md-6">
-                                            <div class="card">
-                                                <div class="card-body">
-                                                    <h3 class="card-title">多因素认证</h3>
-                                                    <div class="col-md-12">
-                                                        <div class="col-sm-6 col-md-6">
-                                                            <i class="ti ti-brand-apple"></i>
-                                                            <a target="view_window"
-                                                               href="https://apps.apple.com/us/app/google-authenticator/id388497605">iOS
-                                                                客户端
-                                                            </a>
-                                                            &nbsp;&nbsp;&nbsp;
-                                                            <i class="ti ti-brand-android"></i>
-                                                            <a target="view_window"
-                                                               href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android
-                                                                客户端
-                                                            </a>
-                                                        </div>
-                                                    </div>
-                                                    <br>
-                                                    <div class="row">
-                                                        <div class="col-md-3">
-                                                            <p id="qrcode"></p>
-                                                        </div>
-                                                        <div class="col-md-9">
-                                                            <div class="mb-3">
-                                                                <select id="ga-enable" class="form-select">
-                                                                    <option value="0">不使用</option>
-                                                                    <option value="1"
-                                                                            {if $user->ga_enable === '1'}selected{/if}>
-                                                                        使用两步认证登录
-                                                                    </option>
-                                                                </select>
-                                                            </div>
-                                                            <div class="mb-3">
-                                                                <input id="ga-test-code" type="text"
-                                                                       class="form-control"
-                                                                       placeholder="测试两步认证验证码">
-                                                            </div>
-                                                            <div class="col-md-12">
-                                                                <p>密钥:
-                                                                    <code id="ga-token" class="spoiler">
-                                                                        {$user->ga_token}
-                                                                    </code>
-                                                                </p>
-                                                            </div>
-                                                        </div>
-                                                    </div>
-                                                </div>
-                                                <div class="card-footer">
-                                                    <div class="d-flex">
-                                                        <button class="btn btn-link"
-                                                                hx-post="/user/ga_reset" hx-swap="none" >
-                                                            重置
-                                                        </button>
-                                                        <button class="btn btn-link"
-                                                                hx-post="/user/ga_check" hx-swap="none"
-                                                                hx-vals='js:{ code: document.getElementById("ga-test-code").value }'>
-                                                            测试
-                                                        </button>
-                                                        <button class="btn btn-primary ms-auto"
-                                                                hx-post="/user/ga_set" hx-swap="none"
-                                                                hx-vals='js:{ enable: document.getElementById("ga-enable").value }'>
-                                                            设置
-                                                        </button>
-                                                    </div>
-                                                </div>
-                                            </div>
-                                        </div>
                                         <div class="col-sm-12 col-md-6">
                                             <div class="card">
                                                 <div class="card-body">
@@ -296,6 +228,120 @@
                                                 </div>
                                             </div>
                                         </div>
+                                        <div class="col-sm-12 col-md-6">
+                                            <div class="card">
+                                                <div class="card-body">
+                                                    <h3 class="card-title">TOTP
+                                                        {if $totpDevices}
+                                                            <span class="badge bg-green text-green-fg">已启用</span>
+                                                        {else}
+                                                            <span class="badge bg-red text-red-fg">未启用</span>
+                                                        {/if}
+                                                    </h3>
+                                                    <p class="card-subtitle">TOTP 是一种基于时间的一次性密码算法,可以使用
+                                                        Google Authenticator 或者 Authy
+                                                        等客户端进行验证</p>
+                                                </div>
+                                                <div class="card-footer">
+                                                    <div class="d-flex">
+                                                        {if $totpDevices}
+                                                            <button class="btn btn-red ms-auto"
+                                                                    hx-delete="/user/totp"
+                                                                    hx-confirm="确认禁用TOTP?"
+                                                                    hx-swap="none">
+                                                                禁用
+                                                            </button>
+                                                        {else}
+                                                            <button class="btn btn-primary ms-auto" id="enableTotp">
+                                                                启用
+                                                            </button>
+                                                        {/if}
+                                                    </div>
+                                                </div>
+                                            </div>
+                                        </div>
+                                        <div class="col-sm-12 col-md-12">
+                                            <div class="card">
+                                                <div class="card-body">
+                                                    <h3 class="card-title">Passkey</h3>
+                                                    <p class="card-subtitle">Passkey
+                                                        是一种新的身份验证标准,使用生物识别或者安全密钥进行身份验证以取代传统密码。</p>
+                                                    <div class="row row-cols-1 row-cols-md-4 g-4">
+                                                        {foreach $webauthnDevices as $device}
+                                                            <div class="col">
+                                                                <div class="card">
+                                                                    <div class="card-body">
+                                                                        <h5 class="card-title">{$device->name|default:'未命名'}</h5>
+                                                                        <p class="card-text">
+                                                                            添加时间: {$device->created_at}</p>
+                                                                        <p class="card-text">
+                                                                            上次使用: {$device->used_at|default:'从未使用'}</p>
+                                                                        <button class="btn btn-danger"
+                                                                                hx-delete="/user/webauthn/{$device->id}"
+                                                                                hx-swap="none"
+                                                                                hx-confirm="确认删除此设备?"
+                                                                        >删除
+                                                                        </button>
+                                                                    </div>
+                                                                </div>
+                                                            </div>
+                                                        {/foreach}
+                                                    </div>
+                                                </div>
+                                                <div class="card-footer">
+                                                    <div class="d-flex">
+                                                        <button class="btn btn-primary ms-auto" id="webauthnReg">
+                                                            注册 Passkey 设备
+                                                        </button>
+                                                    </div>
+                                                </div>
+                                            </div>
+                                        </div>
+                                        <div class="col-sm-12 col-md-12">
+                                            <div class="card">
+                                                <div class="card-body">
+                                                    <h3 class="card-title">FIDO
+                                                        {if $fidoDevices}
+                                                            <span class="badge bg-green text-green-fg">已启用</span>
+                                                        {else}
+                                                            <span class="badge bg-red text-red-fg">未启用</span>
+                                                        {/if}
+                                                    </h3>
+                                                    <p class="card-subtitle">FIDO2
+                                                        是一种基于公钥加密的身份验证标准,可以提供更安全的登录方式。支持Yubikey等硬件安全密钥。</p>
+                                                    {if $fidoDevices}
+                                                        <div class="row row-cols-1 row-cols-md-4 g-4">
+                                                            {foreach $fidoDevices as $device}
+                                                                <div class="col">
+                                                                    <div class="card">
+                                                                        <div class="card-body">
+                                                                            <h5 class="card-title">{$device->name|default:'未命名'}</h5>
+                                                                            <p class="card-text">
+                                                                                添加时间: {$device->created_at}</p>
+                                                                            <p class="card-text">
+                                                                                上次使用: {$device->used_at|default:'从未使用'}</p>
+                                                                            <button class="btn btn-danger"
+                                                                                    hx-delete="/user/fido/{$device->id}"
+                                                                                    hx-swap="none"
+                                                                                    hx-confirm="确认删除此设备?"
+                                                                            >删除
+                                                                            </button>
+                                                                        </div>
+                                                                    </div>
+                                                                </div>
+                                                            {/foreach}
+                                                        </div>
+                                                    {/if}
+                                                </div>
+                                                <div class="card-footer">
+                                                    <div class="d-flex">
+                                                        <button class="btn btn-primary ms-auto" id="fidoReg">
+                                                            注册 FIDO 设备
+                                                        </button>
+                                                    </div>
+                                                </div>
+                                            </div>
+                                        </div>
                                     </div>
                                 </div>
                                 <div class="tab-pane" id="use_safety" role="tabpanel">
@@ -555,16 +601,163 @@
     </div>
     {/if}
 
+    <div class="modal" id="totpModal">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title">设置TOTP</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                </div>
+                <div class="modal-body text-center">
+                    <div class="row">
+                        <div class="col-md-12">
+                            <p>请使用 Google Authenticator 或者 Authy 扫描下面的二维码</p>
+                        </div>
+                        <div class="col-md-12 d-flex justify-content-center align-items-center">
+                            <div id="qrcode"></div>
+                        </div>
+                        <div class="col-md-12">
+                            <p>若无法扫描二维码,可以手动输入以下密钥</p>
+                            <p id="totpSecret"></p>
+                        </div>
+                        <div class="col-md-12">
+                            <input type="text" id="totpCode" placeholder="输入TOTP代码" class="form-control mx-auto">
+                        </div>
+                    </div>
+                    <div id="qrcode"></div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-primary" id="submitTotp">提交</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    {include file='user/footer.tpl'}
     <script>
-        let qrcode = new QRCode('qrcode', {
-            text: "{$ga_url}",
-            width: 128,
-            height: 128,
-            colorDark: '#000000',
-            colorLight: '#ffffff',
-            correctLevel: QRCode.CorrectLevel.H
+        {if not $totpDevices}
+        document.querySelector('#enableTotp').addEventListener('click', async () => {
+            const resp = await fetch('/user/totp');
+            const data = await resp.json();
+            var modal = new tabler.bootstrap.Modal(document.getElementById('totpModal'), {
+                backdrop: 'static',
+                keyboard: false
+            });
+            if (data.ret === 1) {
+                let qrcodeElement = document.getElementById('qrcode');
+                qrcodeElement.innerHTML = '';
+                let totpSecret = document.getElementById('totpSecret');
+                totpSecret.innerHTML = data.token;
+                let qrcode = new QRCode(qrcodeElement, {
+                    text: data.url,
+                    width: 256,
+                    height: 256,
+                    colorDark: '#000000',
+                    colorLight: '#ffffff',
+                    correctLevel: QRCode.CorrectLevel.H
+                });
+                modal.show();
+            } else {
+                var fail_modal = new tabler.bootstrap.Modal(document.getElementById('fail-dialog'));
+                document.getElementById('fail-message').innerText = data.msg;
+                fail_modal.show();
+            }
         });
 
+        document.getElementById('submitTotp').addEventListener('click', function () {
+            var totpCode = document.getElementById('totpCode').value;
+
+            fetch('/user/totp', {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                {literal}
+                body: JSON.stringify({code: totpCode}),
+                {/literal}
+            })
+                .then(response => response.json())
+                .then(data => {
+                    var totpModal = new tabler.bootstrap.Modal(document.getElementById('totpModal'));
+
+                    if (data.ret === 1) {
+                        totpModal.hide();
+                        document.getElementById("success-message").innerHTML = data.msg;
+                        successDialog.show();
+                        setTimeout(function () {
+                            location.reload();
+                        }, 1000);
+                    } else {
+                        document.getElementById("fail-message").innerHTML = data.msg;
+                        failDialog.show();
+                    }
+                })
+        });
+        {/if}
+        const { startRegistration } = SimpleWebAuthnBrowser;
+        document.getElementById('fidoReg').addEventListener('click', async () => {
+            const resp = await fetch('/user/fido');
+            let attResp;
+            const options = await resp.json();
+            try {
+                attResp = await startRegistration({ optionsJSON: options });
+            } catch (error) {
+                $('#error-message').text(error.message);
+                $('#fail-dialog').modal('show');
+                throw error;
+            }
+            attResp.name = prompt("请输入设备名称:");
+            const verificationResp = await fetch('/user/fido', {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: JSON.stringify(attResp),
+            });
+
+            const verificationJSON = await verificationResp.json();
+            if (verificationJSON.ret === 1) {
+                $('#success-message').text(verificationJSON.msg);
+                $('#success-dialog').modal('show');
+                setTimeout(function () {
+                    location.reload();
+                }, 1000);
+            } else {
+                $('#error-message').text(verificationJSON.msg);
+                $('#fail-dialog').modal('show');
+            }
+        });
+        document.getElementById('webauthnReg').addEventListener('click', async () => {
+            const resp = await fetch('/user/webauthn');
+            const options = await resp.json();
+            let attResp;
+            try {
+                attResp = await startRegistration({ optionsJSON: options });
+            } catch (error) {
+                $('#error-message').text(error.message);
+                $('#fail-dialog').modal('show');
+                throw error;
+            }
+            attResp.name = prompt("请输入设备名称:");
+            const verificationResp = await fetch('/user/webauthn', {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: JSON.stringify(attResp),
+            });
+            const verificationJSON = await verificationResp.json();
+            if (verificationJSON.ret === 1) {
+                $('#success-message').text(verificationJSON.msg);
+                $('#success-dialog').modal('show');
+                setTimeout(function () {
+                    location.reload();
+                }, 1000);
+            } else {
+                $('#error-message').text(verificationJSON.msg);
+                $('#fail-dialog').modal('show');
+            }
+        });
         {if $user->im_type === 0 && $user->im_value === ''}
         let oauthProvider = $('#oauth-provider');
 
@@ -643,5 +836,3 @@
         }
         {/if}
     </script>
-
-    {include file='user/footer.tpl'}

+ 0 - 24
src/Command/Tool.php

@@ -8,7 +8,6 @@ use App\Models\Config;
 use App\Models\Link;
 use App\Models\Node;
 use App\Models\User as ModelsUser;
-use App\Services\MFA;
 use App\Utils\Hash;
 use App\Utils\Tools;
 use danielsreichenbach\GeoIP2Update\Client;
@@ -44,7 +43,6 @@ final class Tool extends Command
 │ ├─ resetPasswd         - 重置所有用户连接密码
 │ ├─ clearSubToken       - 清除用户 Sub Token
 │ ├─ generateUUID        - 为所有用户生成新的 UUID
-│ ├─ generateGa          - 为所有用户生成新的 Ga Secret
 │ ├─ generateApiToken    - 为所有用户生成新的 API Token
 │ ├─ setTheme            - 为所有用户设置新的主题
 │ ├─ setLocale           - 为所有用户设置新的语言
@@ -267,25 +265,6 @@ EOL;
         echo '已为所有用户生成新的 UUID' . PHP_EOL;
     }
 
-    /**
-     * 二次验证
-     */
-    public function generateGa(): void
-    {
-        $users = ModelsUser::all();
-
-        foreach ($users as $user) {
-            try {
-                $user->ga_token = MFA::generateGaToken();
-                $user->save();
-            } catch (Exception $e) {
-                echo $e->getMessage();
-            }
-        }
-
-        echo '已为所有用户生成新的 Ga Secret' . PHP_EOL;
-    }
-
     /**
      * 为所有用户生成新的 Api Token
      */
@@ -387,9 +366,6 @@ EOL;
             $user->theme = $_ENV['theme'];
             $user->locale = $_ENV['locale'];
 
-            $user->ga_token = MFA::generateGaToken();
-            $user->ga_enable = 0;
-
             if ($user->save()) {
                 echo '创建成功,请在主页登录' . PHP_EOL;
             } else {

+ 142 - 15
src/Controllers/AuthController.php

@@ -13,7 +13,9 @@ use App\Services\Cache;
 use App\Services\Captcha;
 use App\Services\Filter;
 use App\Services\Mail;
-use App\Services\MFA;
+use App\Services\MFA\FIDO;
+use App\Services\MFA\TOTP;
+use App\Services\MFA\WebAuthn;
 use App\Services\RateLimit;
 use App\Services\Reward;
 use App\Utils\Cookie;
@@ -63,7 +65,6 @@ final class AuthController extends BaseController
             ]);
         }
 
-        $mfa_code = $this->antiXss->xss_clean($request->getParam('mfa_code'));
         $password = $request->getParam('password');
         $rememberMe = $request->getParam('remember_me') === 'true' ? 1 : 0;
         $email = strtolower(trim($this->antiXss->xss_clean($request->getParam('email'))));
@@ -89,20 +90,25 @@ final class AuthController extends BaseController
             ]);
         }
 
-        if ($user->ga_enable && (strlen($mfa_code) !== 6 || ! MFA::verifyGa($user, $mfa_code))) {
-            $loginIp->collectLoginIP($_SERVER['REMOTE_ADDR'], 1, $user->id);
-
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => '两步验证码错误',
+        $mfaStatus = $user->checkMfaStatus();
+        if ($mfaStatus['require']) {
+            $redis = (new Cache())->initRedis();
+            $redis->setex('mfa_login_' . session_id(), 300, json_encode([
+                'userid' => $user->id,
+                'method' => $mfaStatus,
+                'redir' => $redir,
+                'remember_me' => $rememberMe,
+            ]));
+
+            return $response
+                ->withHeader('HX-Redirect', '/auth/mfa')
+                ->withJson([
+                    'ret' => 1,
+                    'msg' => '请完成二步认证',
             ]);
         }
 
-        $time = 3600;
-
-        if ($rememberMe) {
-            $time = 86400 * ($_ENV['rememberMeDuration'] ?: 7);
-        }
+        $time = $rememberMe ? 86400 * ($_ENV['rememberMeDuration'] ?? 7) : 3600; // Cookie 过期时间
 
         Auth::login($user->id, $time);
         // 记录登录成功
@@ -113,6 +119,22 @@ final class AuthController extends BaseController
         return $response->withHeader('HX-Redirect', $redir);
     }
 
+    public function mfaPage(ServerRequest $request, Response $response, $next): ResponseInterface
+    {
+        $redis = (new Cache())->initRedis();
+        $mfa_session = $redis->get('mfa_login_' . session_id());
+        if ($mfa_session === false) {
+            return $response->withStatus(302)->withHeader('Location', '/auth/login');
+        }
+        $mfa_session = json_decode($mfa_session, true);
+        return $response->write(
+            $this->view()
+                ->assign('base_url', $_ENV['baseUrl'])
+                ->assign('method', $mfa_session['method'])
+                ->fetch('auth/mfa.tpl')
+        );
+    }
+
     /**
      * @throws Exception
      */
@@ -243,8 +265,6 @@ final class AuthController extends BaseController
             }
         }
 
-        $user->ga_token = MFA::generateGaToken();
-        $user->ga_enable = 0;
         $user->class = $configs['reg_class'];
         $user->class_expire = date('Y-m-d H:i:s', time() + (int) $configs['reg_class_time'] * 86400);
         $user->node_iplimit = $configs['reg_ip_limit'];
@@ -365,4 +385,111 @@ final class AuthController extends BaseController
 
         return $response->withStatus(302)->withHeader('Location', '/auth/login');
     }
+
+    public function webauthnRequest(ServerRequest $request, Response $response, $next): ResponseInterface
+    {
+        return $response->withJson(WebAuthn::AssertRequest());
+    }
+
+    public function webauthnHandle(ServerRequest $request, Response $response, $next): ResponseInterface
+    {
+        $data = $this->antiXss->xss_clean((array) $request->getParsedBody());
+        $redir = $this->antiXss->xss_clean(Cookie::get('redir')) ?? '/user';
+        $result = WebAuthn::AssertHandle($data);
+        if ($result['ret'] === 1) {
+            $user = $result['user'];
+            if ($user === null) {
+                return $response->withJson([
+                    'ret' => 0,
+                    'msg' => '用户不存在',
+                ]);
+            }
+            $rememberMe = $request->getParam('remember_me') === 'true';
+            $time = $rememberMe ? 86400 * ($_ENV['rememberMeDuration'] ?? 7) : 3600;
+            Auth::login($user->id, $time);
+            $loginIp = new LoginIp();
+            $loginIp->collectLoginIP($_SERVER['REMOTE_ADDR'], 0, $user->id);
+            $user->last_login_time = time();
+            $user->save();
+            return $response->withJson([
+                'ret' => 1,
+                'msg' => '登录成功',
+                'redir' => $redir,
+            ]);
+        }
+        return $response->withJson($result);
+    }
+
+    public function totpHandle(ServerRequest $request, Response $response, $next): ResponseInterface
+    {
+        $redis = (new Cache())->initRedis();
+        $login_session = $redis->get('mfa_login_' . session_id());
+        if ($login_session === false) {
+            return $response->withJson(['ret' => 0, 'msg' => '登录会话已过期'])->withHeader('HX-Redirect', '/auth/login');
+        }
+        $login_session = json_decode($login_session, true);
+        $code = $this->antiXss->xss_clean($request->getParam('code'));
+        $user = (new User())->where('id', $login_session['userid'])->first();
+        if ($user === null) {
+            return $response->withJson(['ret' => 0, 'msg' => '用户不存在'])->withHeader('HX-Redirect', '/auth/login');
+        }
+        $result = TOTP::AssertHandle($user, $code);
+        if ($result['ret'] === 1) {
+            $redis->del('mfa_login_' . session_id());
+            $rememberMe = $login_session['remember_me'];
+            $time = $rememberMe ? 86400 * ($_ENV['rememberMeDuration'] ?? 7) : 3600;
+            Auth::login($user->id, $time);
+            $loginIp = new LoginIp();
+            $loginIp->collectLoginIP($_SERVER['REMOTE_ADDR'], 0, $user->id);
+            $user->last_login_time = time();
+            $user->save();
+            return $response
+                ->withHeader('HX-Redirect', $login_session['redir'])
+                ->withJson(['ret' => 1, 'msg' => '登录成功']);
+        }
+        return $response->withJson($result);
+    }
+
+    public function fidoRequest(ServerRequest $request, Response $response, $next): ResponseInterface
+    {
+        $redis = (new Cache())->initRedis();
+        $login_session = $redis->get('mfa_login_' . session_id());
+        if ($login_session === false) {
+            return $response->withJson(['ret' => 0, 'msg' => '登录会话已过期'])->withHeader('HX-Redirect', '/auth/login');
+        }
+        $login_session = json_decode($login_session, true);
+        $user = (new User())->where('id', $login_session['userid'])->first();
+        if ($user === null) {
+            return $response->withJson(['ret' => 0, 'msg' => '用户不存在'])->withHeader('HX-Redirect', '/auth/login');
+        }
+        return $response->withJson(FIDO::AssertRequest($user));
+    }
+
+    public function fidoHandle(ServerRequest $request, Response $response, $next): ResponseInterface
+    {
+        $redis = (new Cache())->initRedis();
+        $login_session = $redis->get('mfa_login_' . session_id());
+        if ($login_session === false) {
+            return $response->withJson(['ret' => 0, 'msg' => '登录会话已过期'])->withHeader('HX-Redirect', '/auth/login');
+        }
+        $login_session = json_decode($login_session, true);
+        $data = $this->antiXss->xss_clean((array) $request->getParsedBody());
+        $user = (new User())->where('id', $login_session['userid'])->first();
+        if ($user === null) {
+            return $response->withJson(['ret' => 0, 'msg' => '用户不存在'])->withHeader('HX-Redirect', '/auth/login');
+        }
+        $result = FIDO::AssertHandle($user, $data);
+        if ($result['ret'] === 1) {
+            $redis->del('mfa_login_' . session_id());
+            $rememberMe = $login_session['remember_me'];
+            $time = $rememberMe ? 86400 * ($_ENV['rememberMeDuration'] ?? 7) : 3600;
+            Auth::login($user->id, $time);
+            $loginIp = new LoginIp();
+            $loginIp->collectLoginIP($_SERVER['REMOTE_ADDR'], 0, $user->id);
+            $user->last_login_time = time();
+            $user->save();
+            return $response->withJson(['ret' => 1, 'msg' => '登录成功', 'redir' => $login_session['redir']]);
+        }
+        return $response->withJson($result);
+    }
 }

+ 9 - 4
src/Controllers/User/InfoController.php

@@ -6,11 +6,11 @@ namespace App\Controllers\User;
 
 use App\Controllers\BaseController;
 use App\Models\Config;
+use App\Models\MFADevice;
 use App\Models\User;
 use App\Services\Auth;
 use App\Services\Cache;
 use App\Services\Filter;
-use App\Services\MFA;
 use App\Utils\Hash;
 use App\Utils\ResponseHelper;
 use App\Utils\Tools;
@@ -34,14 +34,19 @@ final class InfoController extends BaseController
     {
         $themes = Tools::getDir(BASE_PATH . '/resources/views');
         $methods = Tools::getSsMethod();
-        $ga_url = MFA::getGaUrl($this->user);
+        $webauthnDevices = array_map(fn($item) => (object) $item, (new MFADevice())->where('userid', $this->user->id)->where('type', 'passkey')->get()->toArray());
+        $totpDevices = array_map(fn($item) => (object) $item, (new MFADevice())->where('userid', $this->user->id)->where('type', 'totp')->get()->toArray());
+        $fidoDevices = array_map(fn($item) => (object) $item, (new MFADevice())->where('userid', $this->user->id)->where('type', 'fido')->get()->toArray());
 
         return $response->write($this->view()
             ->assign('user', $this->user)
             ->assign('themes', $themes)
             ->assign('methods', $methods)
-            ->assign('ga_url', $ga_url)
-            ->fetch('user/edit.tpl'));
+            ->assign('webauthnDevices', $webauthnDevices)
+            ->assign('totpDevices', $totpDevices)
+            ->assign('fidoDevices', $fidoDevices)
+            ->fetch('user/edit.tpl')
+        );
     }
 
     /**

+ 75 - 48
src/Controllers/User/MFAController.php

@@ -5,7 +5,10 @@ declare(strict_types=1);
 namespace App\Controllers\User;
 
 use App\Controllers\BaseController;
-use App\Services\MFA;
+use App\Models\MFADevice;
+use App\Services\MFA\FIDO;
+use App\Services\MFA\TOTP;
+use App\Services\MFA\WebAuthn;
 use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
@@ -16,80 +19,104 @@ use Slim\Http\ServerRequest;
  */
 final class MFAController extends BaseController
 {
-    public function checkGa(ServerRequest $request, Response $response, array $args): ResponseInterface
+    public function webauthnRegisterRequest(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $code = $request->getParam('code');
+        return $response->withJson(WebAuthn::RegisterRequest($this->user));
+    }
 
-        if ($code === '') {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => '二维码不能为空',
-            ]);
+    public function webauthnRegisterHandle(ServerRequest $request, Response $response, array $args): ResponseInterface
+    {
+        try {
+            return $response->withJson(WebAuthn::RegisterHandle($this->user, $this->antiXss->xss_clean($request)));
+        } catch (Exception $e) {
+            return $response->withJson(['ret' => 0, 'msg' => '请求失败: ' . $e->getMessage()]);
         }
+    }
 
-        if (! MFA::verifyGa($this->user, $code)) {
+    public function webauthnDelete(ServerRequest $request, Response $response, array $args): ResponseInterface
+    {
+        $webauthnDevice = (new MFADevice())
+            ->where('id', (int) $args['id'])
+            ->where('userid', $this->user->id)
+            ->where('type', 'passkey')
+            ->first();
+        if ($webauthnDevice === null) {
             return $response->withJson([
                 'ret' => 0,
-                'msg' => '测试错误',
+                'msg' => '设备不存在',
             ]);
         }
-
-        return $response->withJson([
+        $webauthnDevice->delete();
+        return $response->withHeader('HX-Refresh', 'true')->withJson([
             'ret' => 1,
-            'msg' => '测试成功',
+            'msg' => '删除成功',
         ]);
     }
 
-    public function setGa(ServerRequest $request, Response $response, array $args): ResponseInterface
+    public function totpRegisterRequest(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $enable = $request->getParam('enable');
+        return $response->withJson(TOTP::RegisterRequest($this->user));
+    }
 
-        if ($enable === '') {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => '选项无效',
-            ]);
+    public function totpRegisterHandle(ServerRequest $request, Response $response, array $args): ResponseInterface
+    {
+        try {
+            return $response->withJson(TOTP::RegisterHandle($this->user, $this->antiXss->xss_clean($request->getParam('code', ''))));
+        } catch (Exception $e) {
+            return $response->withJson(['ret' => 0, 'msg' => '请求失败: ' . $e->getMessage()]);
         }
+    }
 
-        $user = $this->user;
-        $user->ga_enable = $enable;
-        $user->save();
-
-        if ($user->save()) {
+    public function totpDelete(ServerRequest $request, Response $response, array $args): ResponseInterface
+    {
+        $totpDevice = (new MFADevice())
+            ->where('userid', $this->user->id)
+            ->where('type', 'totp')
+            ->first();
+        if ($totpDevice === null) {
             return $response->withJson([
-                'ret' => 1,
-                'msg' => '设置成功',
+                'ret' => 0,
+                'msg' => '设备不存在',
             ]);
         }
-
-        return $response->withJson([
-            'ret' => 0,
-            'msg' => '设置失败',
+        $totpDevice->delete();
+        return $response->withHeader('HX-Refresh', 'true')->withJson([
+            'ret' => 1,
+            'msg' => '删除成功',
         ]);
     }
 
-    /**
-     * @throws Exception
-     */
-    public function resetGa(ServerRequest $request, Response $response, array $args): ResponseInterface
+    public function fidoRegisterRequest(ServerRequest $request, Response $response, array $args): ResponseInterface
+    {
+        return $response->withJson(FIDO::RegisterRequest($this->user));
+    }
+
+    public function fidoRegisterHandle(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $user = $this->user;
-        $user->ga_token = MFA::generateGaToken();
+        try {
+            return $response->withJson(FIDO::RegisterHandle($this->user, $this->antiXss->xss_clean($request->getParsedBody())));
+        } catch (Exception $e) {
+            return $response->withJson(['ret' => 0, 'msg' => '请求失败: ' . $e->getMessage()]);
+        }
+    }
 
-        if ($user->save()) {
+    public function fidoDelete(ServerRequest $request, Response $response, array $args): ResponseInterface
+    {
+        $fidoDevice = (new MFADevice())
+            ->where('id', (int) $args['id'])
+            ->where('userid', $this->user->id)
+            ->where('type', 'fido')
+            ->first();
+        if ($fidoDevice === null) {
             return $response->withJson([
-                'ret' => 1,
-                'msg' => '重置成功',
-                'data' => [
-                    'ga-token' => $user->ga_token,
-                    'ga-url' => MFA::getGaUrl($user),
-                ],
+                'ret' => 0,
+                'msg' => '设备不存在',
             ]);
         }
-
-        return $response->withJson([
-            'ret' => 0,
-            'msg' => '重置失败',
+        $fidoDevice->delete();
+        return $response->withHeader('HX-Refresh', 'true')->withJson([
+            'ret' => 1,
+            'msg' => '删除成功',
         ]);
     }
 }

+ 25 - 0
src/Models/MFADevice.php

@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Illuminate\Database\Query\Builder;
+
+/**
+ * @property string $id 凭证ID
+ * @property int $userid 用户ID
+ * @property string $name 设备名称
+ * @property string $rawid 设备ID
+ * @property string $body 内容
+ * @property string $created_at 创建时间
+ * @property string $used_at 上次使用时间
+ * @property string $type 设备类型
+ *
+ * @mixin Builder
+ */
+final class MFADevice extends Model
+{
+    protected $connection = 'default';
+    protected $table = 'mfa_devices';
+}

+ 19 - 0
src/Models/User.php

@@ -253,6 +253,7 @@ final class User extends Model
         (new Link())->where('userid', $uid)->delete();
         (new LoginIp())->where('userid', $uid)->delete();
         (new SubscribeLog())->where('user_id', $uid)->delete();
+        (new MFADevice())->where('userid', $uid)->delete();
 
         return $this->delete();
     }
@@ -317,4 +318,22 @@ final class User extends Model
             }
         }
     }
+
+    /**
+     * 检查多因素认证启用状态
+     *
+     * @return array
+     */
+    public function checkMfaStatus(): array
+    {
+        $fido = (new MFADevice())->where('userid', $this->id)->where('type', 'fido')->first();
+        $totp = (new MFADevice())->where('userid', $this->id)->where('type', 'totp')->first();
+        $hasFido = $fido !== null;
+        $hasTotp = $totp !== null;
+        if (! $hasFido && ! $hasTotp) {
+            return ['require' => false];
+        } else {
+            return ['require' => true, 'fido' => $hasFido, 'totp' => $hasTotp];
+        }
+    }
 }

+ 14 - 1
src/Services/Cache.php

@@ -10,7 +10,20 @@ final class Cache
 {
     public function initRedis(): Redis
     {
-        return new Redis(self::getRedisConfig());
+        $config = self::getRedisConfig();
+        $redis = new Redis();
+        $redis->connect($config['host'], $config['port'], $config['connectTimeout']);
+        // 认证
+        if (! empty($config['auth']['user']) && ! empty($config['auth']['pass'])) {
+            $redis->auth([$config['auth']['user'], $config['auth']['pass']]);
+        } elseif (! empty($config['auth']['pass'])) {
+            $redis->auth($config['auth']['pass']);
+        }
+        // 选择数据库
+        if (isset($config['database'])) {
+            $redis->select($config['database']);
+        }
+        return $redis;
     }
 
     public static function getRedisConfig(): array

+ 0 - 32
src/Services/MFA.php

@@ -1,32 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\Services;
-
-use Exception;
-use Vectorface\GoogleAuthenticator;
-
-final class MFA
-{
-    /**
-     * @throws Exception
-     */
-    public static function generateGaToken(): string
-    {
-        $ga = new GoogleAuthenticator();
-        return $ga->createSecret();
-    }
-
-    public static function verifyGa($user, string $code): bool
-    {
-        $ga = new GoogleAuthenticator();
-        return $ga->verifyCode($user->ga_token, $code);
-    }
-
-    public static function getGaUrl($user): string
-    {
-        return 'otpauth://totp/' .
-            rawurlencode($_ENV['appName'] . ' (' . $user->email . ')') . '?secret=' . $user->ga_token;
-    }
-}

+ 165 - 0
src/Services/MFA/FIDO.php

@@ -0,0 +1,165 @@
+<?php
+
+namespace App\Services\MFA;
+
+use App\Models\MFADevice;
+use App\Models\User;
+use App\Services\Cache;
+use App\Utils\Tools;
+use Exception;
+use Webauthn\AuthenticatorAssertionResponse;
+use Webauthn\AuthenticatorAttestationResponse;
+use Webauthn\AuthenticatorSelectionCriteria;
+use Webauthn\PublicKeyCredential;
+use Webauthn\PublicKeyCredentialCreationOptions;
+use Webauthn\PublicKeyCredentialDescriptor;
+use Webauthn\PublicKeyCredentialRequestOptions;
+use Webauthn\PublicKeyCredentialSource;
+
+class FIDO
+{
+
+    public static function RegisterRequest(User $user): array
+    {
+        $rpEntity = WebAuthn::generateRPEntity();
+        $userEntity = WebAuthn::generateUserEntity($user);
+        $authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create();
+        $publicKeyCredentialCreationOptions =
+            PublicKeyCredentialCreationOptions::create(
+                $rpEntity,
+                $userEntity,
+                random_bytes(32),
+                pubKeyCredParams: WebAuthn::getPublicKeyCredentialParametersList(),
+                authenticatorSelection: $authenticatorSelectionCriteria,
+                attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
+                timeout: WebAuthn::$timeout,
+            );
+        $serializer = WebAuthn::getSerializer();
+        $jsonObject = $serializer->serialize($publicKeyCredentialCreationOptions, 'json');
+        $redis = (new Cache())->initRedis();
+        $redis->setex('fido_register_' . session_id(), 300, $jsonObject);
+        return json_decode($jsonObject, true);
+    }
+
+    public static function RegisterHandle(User $user, array $data): array
+    {
+        $serializer = WebAuthn::getSerializer();
+        try {
+            $publicKeyCredential = $serializer->deserialize(
+                json_encode($data),
+                PublicKeyCredential::class,
+                'json'
+            );
+        } catch (Exception $e) {
+            return ['ret' => 0, 'msg' => $e->getMessage()];
+        }
+        if (! isset($publicKeyCredential->response) || ! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
+            return ['ret' => 0, 'msg' => '密钥类型错误'];
+        }
+        $redis = (new Cache())->initRedis();
+        $publicKeyCredentialCreationOptions = $serializer->deserialize(
+            $redis->get('fido_register_' . session_id()),
+            PublicKeyCredentialCreationOptions::class,
+            'json'
+        );
+
+        try {
+            $authenticatorAttestationResponseValidator = WebAuthn::getAuthenticatorAttestationResponseValidator();
+            $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
+                $publicKeyCredential->response,
+                $publicKeyCredentialCreationOptions,
+                Tools::getSiteDomain()
+            );
+        } catch (Exception) {
+            return ['ret' => 0, 'msg' => '验证失败'];
+        }
+        $jsonStr = WebAuthn::getSerializer()->serialize($publicKeyCredentialSource, 'json');
+        $jsonObject = json_decode($jsonStr);
+        $mfaCredential = new MFADevice();
+        $mfaCredential->userid = $user->id;
+        $mfaCredential->rawid = $jsonObject->publicKeyCredentialId;
+        $mfaCredential->body = $jsonStr;
+        $mfaCredential->created_at = date('Y-m-d H:i:s');
+        $mfaCredential->used_at = null;
+        $mfaCredential->name = $data['name'] === '' ? null : $data['name'];
+        $mfaCredential->type = 'fido';
+        $mfaCredential->save();
+        $redis->del('fido_register_' . session_id());
+        return ['ret' => 1, 'msg' => '注册成功'];
+    }
+
+    public static function AssertRequest(User $user): array
+    {
+        try {
+            $serializer = WebAuthn::getSerializer();
+            $userCredentials = (new MFADevice())
+                ->where('userid', $user->id)
+                ->where('type', 'fido')
+                ->select('body')->get();
+            $credentials = [];
+            foreach ($userCredentials as $credential) {
+                $credentials[] = $serializer->deserialize($credential->body, PublicKeyCredentialSource::class, 'json');
+            }
+            $allowedCredentials = array_map(
+                static function (PublicKeyCredentialSource $credential): PublicKeyCredentialDescriptor {
+                    return $credential->getPublicKeyCredentialDescriptor();
+                },
+                $credentials
+            );
+            $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create(
+                random_bytes(32),
+                rpId: Tools::getSiteDomain(),
+                allowCredentials: $allowedCredentials,
+                userVerification: 'discouraged',
+                timeout: WebAuthn::$timeout,
+            );
+            $jsonObject = $serializer->serialize($publicKeyCredentialRequestOptions, 'json');
+            $redis = (new Cache())->initRedis();
+            $redis->setex('fido_assertion_' . session_id(), 300, $jsonObject);
+            return json_decode($jsonObject, true);
+        } catch (Exception $e) {
+            return ['ret' => 0, 'msg' => '请求失败: ' . $e->getMessage()];
+        }
+    }
+
+    public static function AssertHandle(?User $user, array $data): array
+    {
+        $serializer = WebAuthn::getSerializer();
+        $publicKeyCredential = $serializer->deserialize(json_encode($data), PublicKeyCredential::class, 'json');
+        if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) {
+            return ['ret' => 0, 'msg' => '验证失败'];
+        }
+        $publicKeyCredentialSource = (new MFADevice())
+            ->where('rawid', $data['id'])
+            ->where('userid', $user->id)
+            ->where('type', 'fido')
+            ->first();
+        if ($publicKeyCredentialSource === null) {
+            return ['ret' => 0, 'msg' => '设备未注册'];
+        }
+        $redis = (new Cache())->initRedis();
+        try {
+            $publicKeyCredentialRequestOptions = $serializer->deserialize(
+                $redis->get('fido_assertion_' . session_id()),
+                PublicKeyCredentialRequestOptions::class,
+                'json'
+            );
+            $authenticatorAssertionResponseValidator = WebAuthn::getAuthenticatorAssertionResponseValidator();
+            $publicKeyCredentialSource_body = $serializer->deserialize($publicKeyCredentialSource->body, PublicKeyCredentialSource::class, 'json');
+            $result = $authenticatorAssertionResponseValidator->check(
+                $publicKeyCredentialSource_body,
+                $publicKeyCredential->response,
+                $publicKeyCredentialRequestOptions,
+                Tools::getSiteDomain(),
+                $user->uuid,
+            );
+        } catch (Exception $e) {
+            return ['ret' => 0, 'msg' => $e->getMessage()];
+        }
+        $publicKeyCredentialSource->body = $serializer->serialize($result, 'json');
+        $publicKeyCredentialSource->used_at = date('Y-m-d H:i:s');
+        $publicKeyCredentialSource->save();
+        $redis->del('fido_assertion_' . session_id());
+        return ['ret' => 1, 'msg' => '验证成功', 'userid' => $user->id];
+    }
+}

+ 112 - 0
src/Services/MFA/TOTP.php

@@ -0,0 +1,112 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\MFA;
+
+use App\Models\MFADevice;
+use App\Models\User;
+use App\Services\Cache;
+use Exception;
+use Vectorface\GoogleAuthenticator;
+
+final class TOTP
+{
+    /**
+     * @throws Exception
+     */
+    public static function generateGaToken(): string
+    {
+        $ga = new GoogleAuthenticator();
+        return $ga->createSecret(32);
+    }
+
+    public static function RegisterRequest(User $user): array
+    {
+        try {
+            $TOTPDevice = (new MFADevice())->where('userid', $user->id)
+                ->where('type', 'TOTP')
+                ->first();
+            if ($TOTPDevice !== null) {
+                return [
+                    'ret' => 0,
+                    'msg' => '您已经注册过TOTP设备,请勿重复注册',
+                ];
+            }
+            $ga = new GoogleAuthenticator();
+            $token = $ga->createSecret(32);
+            $redis = (new Cache())->initRedis();
+            $redis->setex('totp_register_' . session_id(), 300, $token);
+            return [
+                'ret' => 1,
+                'msg' => '',
+                'token' => $token,
+                'url' => self::getGaUrl($user, $token),
+            ];
+        } catch (Exception $e) {
+            return [
+                'ret' => 0,
+                'msg' => '请求失败: ' . $e->getMessage(),
+            ];
+        }
+    }
+
+    public static function getGaUrl(User $user, string $token): string
+    {
+        return 'otpauth://totp/' . rawurlencode($_ENV['appName']) . ':' . rawurlencode($user->email) . '?secret=' . $token . '&issuer=' . rawurlencode($_ENV['appName']);
+    }
+
+    public static function RegisterHandle(User $user, string $code): array
+    {
+        $redis = (new Cache())->initRedis();
+        $token = $redis->get('totp_register_' . session_id());
+        if ($token === false) {
+            return ['ret' => 0, 'msg' => '注册请求已过期,请刷新页面重试'];
+        }
+        $ga = new GoogleAuthenticator();
+        if (! $ga->verifyCode($token, $code)) {
+            return ['ret' => 0, 'msg' => '验证码错误'];
+        }
+        $MFADevice = new MFADevice();
+        $MFADevice->userid = $user->id;
+        $MFADevice->name = 'TOTP';
+        $MFADevice->rawid = 'TOTP';
+        $MFADevice->body = json_encode(['token' => $token]);
+        $MFADevice->type = 'totp';
+        $MFADevice->created_at = date('Y-m-d H:i:s');
+        $MFADevice->save();
+        $redis->del('totp_register_' . session_id());
+        return ['ret' => 1, 'msg' => '注册成功'];
+    }
+
+    public static function AssertHandle(User $user, string $code): array
+    {
+        try {
+            $TOTPDevice = (new MFADevice())->where('userid', $user->id)
+                ->where('type', 'totp')
+                ->first();
+            if ($TOTPDevice === null) {
+                return [
+                    'ret' => 0,
+                    'msg' => '您还没有注册TOTP设备,请先注册',
+                ];
+            }
+            $ga = new GoogleAuthenticator();
+            if (! $ga->verifyCode(json_decode($TOTPDevice->body, true)['token'], $code)) {
+                return [
+                    'ret' => 0,
+                    'msg' => '验证码错误',
+                ];
+            }
+            return [
+                'ret' => 1,
+                'msg' => '',
+            ];
+        } catch (Exception $e) {
+            return [
+                'ret' => 0,
+                'msg' => '请求失败: ' . $e->getMessage(),
+            ];
+        }
+    }
+}

+ 257 - 0
src/Services/MFA/WebAuthn.php

@@ -0,0 +1,257 @@
+<?php
+
+namespace App\Services\MFA;
+
+use App\Models\MFADevice;
+use App\Models\User;
+use App\Services\Cache;
+use App\Utils\Tools;
+use Cose\Algorithm\Manager;
+use Cose\Algorithm\Signature\ECDSA;
+use Cose\Algorithm\Signature\RSA;
+use Cose\Algorithms;
+use Exception;
+use Symfony\Component\Clock\NativeClock;
+use Symfony\Component\Serializer\SerializerInterface;
+use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport;
+use Webauthn\AttestationStatement\AppleAttestationStatementSupport;
+use Webauthn\AttestationStatement\AttestationStatementSupportManager;
+use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport;
+use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
+use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
+use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
+use Webauthn\AuthenticatorAssertionResponse;
+use Webauthn\AuthenticatorAssertionResponseValidator;
+use Webauthn\AuthenticatorAttestationResponse;
+use Webauthn\AuthenticatorAttestationResponseValidator;
+use Webauthn\AuthenticatorSelectionCriteria;
+use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
+use Webauthn\Denormalizer\WebauthnSerializerFactory;
+use Webauthn\PublicKeyCredential;
+use Webauthn\PublicKeyCredentialCreationOptions;
+use Webauthn\PublicKeyCredentialParameters;
+use Webauthn\PublicKeyCredentialRequestOptions;
+use Webauthn\PublicKeyCredentialRpEntity;
+use Webauthn\PublicKeyCredentialSource;
+use Webauthn\PublicKeyCredentialUserEntity;
+
+class WebAuthn
+{
+    public static int $timeout = 30_000;
+
+    public static function RegisterRequest(User $user): array
+    {
+        $redis = (new Cache())->initRedis();
+        try {
+            $rpEntity = self::generateRPEntity();
+            $userEntity = self::generateUserEntity($user);
+            $authenticatorSelectionCriteria = AuthenticatorSelectionCriteria::create(
+                userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
+                residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED
+            );
+            $publicKeyCredentialCreationOptions =
+                PublicKeyCredentialCreationOptions::create(
+                    $rpEntity,
+                    $userEntity,
+                    random_bytes(32),
+                    pubKeyCredParams: self::getPublicKeyCredentialParametersList(),
+                    authenticatorSelection: $authenticatorSelectionCriteria,
+                    attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
+                    timeout: self::$timeout,
+                );
+            $serializer = self::getSerializer();
+            $jsonObject = $serializer->serialize($publicKeyCredentialCreationOptions, 'json');
+            $redis->setex('webauthn_register_' . session_id(), 300, $jsonObject);
+            return json_decode($jsonObject, true);
+        } catch (Exception $e) {
+            return [
+                'ret' => 0,
+                'msg' => '请求失败: ' . $e->getMessage(),
+            ];
+        }
+    }
+
+    public static function generateRPEntity(): PublicKeyCredentialRpEntity
+    {
+        return PublicKeyCredentialRpEntity::create($_ENV['appName'], Tools::getSiteDomain());
+    }
+
+    public static function generateUserEntity(User $user): PublicKeyCredentialUserEntity
+    {
+        return PublicKeyCredentialUserEntity::create(
+            $user->email,
+            $user->uuid,
+            $user->email
+        );
+    }
+
+    public static function getPublicKeyCredentialParametersList(): array
+    {
+        return [
+            PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES256K),
+            PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES256),
+            PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_RS256),
+            PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_PS256),
+            PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ED256),
+        ];
+    }
+
+    public static function getSerializer(): SerializerInterface
+    {
+        $clock = new NativeClock();
+        $coseAlgorithmManager = Manager::create();
+        $coseAlgorithmManager->add(ECDSA\ES256::create());
+        $coseAlgorithmManager->add(RSA\RS256::create());
+        $attestationStatementSupportManager = AttestationStatementSupportManager::create();
+        $attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
+        $attestationStatementSupportManager->add(FidoU2FAttestationStatementSupport::create());
+        $attestationStatementSupportManager->add(AppleAttestationStatementSupport::create());
+        $attestationStatementSupportManager->add(AndroidKeyAttestationStatementSupport::create());
+        $attestationStatementSupportManager->add(TPMAttestationStatementSupport::create($clock));
+        $attestationStatementSupportManager->add(PackedAttestationStatementSupport::create($coseAlgorithmManager));
+        $factory = new WebauthnSerializerFactory($attestationStatementSupportManager);
+        return $factory->create();
+    }
+
+    public static function AssertRequest(): array
+    {
+        try {
+            $publicKeyCredentialRequestOptions = self::getPublicKeyCredentialRequestOptions();
+            $serializer = self::getSerializer();
+            $jsonObject = $serializer->serialize($publicKeyCredentialRequestOptions, 'json');
+            $redis = (new Cache())->initRedis();
+            $redis->setex('webauthn_assertion_' . session_id(), 300, $jsonObject);
+            return json_decode($jsonObject, true);
+        } catch (Exception $e) {
+            return [
+                'ret' => 0,
+                'msg' => '请求失败: ' . $e->getMessage(),
+            ];
+        }
+    }
+
+    public static function getPublicKeyCredentialRequestOptions(): PublicKeyCredentialRequestOptions
+    {
+        return PublicKeyCredentialRequestOptions::create(
+            random_bytes(32),
+            rpId: Tools::getSiteDomain(),
+            userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED,
+            timeout: self::$timeout,
+        );
+    }
+
+    public static function AssertHandle(array $data): array
+    {
+        $serializer = self::getSerializer();
+        $publicKeyCredential = $serializer->deserialize(json_encode($data), PublicKeyCredential::class, 'json');
+        if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) {
+            return ['ret' => 0, 'msg' => '验证失败'];
+        }
+        $publicKeyCredentialSource = (new MFADevice())
+            ->where('rawid', $data['id'])
+            ->where('type', 'passkey')
+            ->first();
+        if ($publicKeyCredentialSource === null) {
+            return ['ret' => 0, 'msg' => '设备未注册'];
+        }
+        $user = (new User())->where('id', $publicKeyCredentialSource->userid)->first();
+        if ($user === null) {
+            return ['ret' => 0, 'msg' => '用户不存在'];
+        }
+        $redis = (new Cache())->initRedis();
+        try {
+
+            $publicKeyCredentialRequestOptions = $serializer->deserialize(
+                $redis->get('webauthn_assertion_' . session_id()),
+                PublicKeyCredentialRequestOptions::class,
+                'json'
+            );
+            $authenticatorAssertionResponseValidator = self::getAuthenticatorAssertionResponseValidator();
+            $publicKeyCredentialSource_body = $serializer->deserialize($publicKeyCredentialSource->body, PublicKeyCredentialSource::class, 'json');
+            $result = $authenticatorAssertionResponseValidator->check(
+                $publicKeyCredentialSource_body,
+                $publicKeyCredential->response,
+                $publicKeyCredentialRequestOptions,
+                Tools::getSiteDomain(),
+                $user->uuid,
+            );
+        } catch (Exception $e) {
+            return ['ret' => 0, 'msg' => $e->getMessage()];
+        }
+        $publicKeyCredentialSource->body = $serializer->serialize($result, 'json');
+        $publicKeyCredentialSource->used_at = date('Y-m-d H:i:s');
+        $publicKeyCredentialSource->save();
+        $redis->del('webauthn_assertion_' . session_id());
+        return ['ret' => 1, 'msg' => '验证成功', 'user' => $user];
+    }
+
+    public static function getAuthenticatorAssertionResponseValidator(): AuthenticatorAssertionResponseValidator
+    {
+        $csmFactory = new CeremonyStepManagerFactory();
+        $requestCSM = $csmFactory->requestCeremony();
+        return AuthenticatorAssertionResponseValidator::create(
+            ceremonyStepManager: $requestCSM
+        );
+    }
+
+    public static function RegisterHandle(User $user, array $data): array
+    {
+        try {
+            $serializer = self::getSerializer();
+            try {
+                $publicKeyCredential = $serializer->deserialize(
+                    json_encode($data),
+                    PublicKeyCredential::class,
+                    'json'
+                );
+            } catch (Exception $e) {
+                return ['ret' => 0, 'msg' => $e->getMessage()];
+            }
+            if (! isset($publicKeyCredential->response) || ! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
+                return ['ret' => 0, 'msg' => '密钥类型错误'];
+            }
+            $redis = (new Cache())->initRedis();
+            $publicKeyCredentialCreationOptions = $serializer->deserialize(
+                $redis->get('webauthn_register_' . session_id()),
+                PublicKeyCredentialCreationOptions::class,
+                'json'
+            );
+
+            try {
+                $authenticatorAttestationResponseValidator = self::getAuthenticatorAttestationResponseValidator();
+                $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
+                    $publicKeyCredential->response,
+                    $publicKeyCredentialCreationOptions,
+                    Tools::getSiteDomain(),
+                );
+            } catch (Exception) {
+                return ['ret' => 0, 'msg' => '验证失败'];
+            }
+            // save public key credential source
+            $jsonStr = self::getSerializer()->serialize($publicKeyCredentialSource, 'json');
+            $jsonObject = json_decode($jsonStr);
+            $webauthn = new MFADevice();
+            $webauthn->userid = $user->id;
+            $webauthn->rawid = $jsonObject->publicKeyCredentialId;
+            $webauthn->body = $jsonStr;
+            $webauthn->created_at = date('Y-m-d H:i:s');
+            $webauthn->used_at = null;
+            $webauthn->name = $data['name'] === '' ? null : $data['name'];
+            $webauthn->type = 'passkey';
+            $webauthn->save();
+            $redis->del('webauthn_register_' . session_id());
+            return ['ret' => 1, 'msg' => '注册成功'];
+        } catch (Exception $e) {
+            return ['ret' => 0, 'msg' => '请求失败: ' . $e->getMessage()];
+        }
+    }
+
+    public static function getAuthenticatorAttestationResponseValidator(): AuthenticatorAttestationResponseValidator
+    {
+        $csmFactory = new CeremonyStepManagerFactory();
+        $creationCSM = $csmFactory->creationCeremony();
+        return AuthenticatorAttestationResponseValidator::create(
+            ceremonyStepManager: $creationCSM
+        );
+    }
+}

+ 8 - 0
src/Utils/Tools.php

@@ -362,4 +362,12 @@ final class Tools
 
         return true;
     }
+
+    /**
+     * 获取站点域名
+     */
+    public static function getSiteDomain(): string
+    {
+        return parse_url($_ENV['baseUrl'], PHP_URL_HOST);
+    }
 }

+ 0 - 48
tests/App/Services/MFATest.php

@@ -1,48 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\Services;
-
-use Exception;
-use PHPUnit\Framework\TestCase;
-use function strlen;
-
-class MFATest extends TestCase
-{
-    /**
-     * @covers App\Services\MFA::generateGaToken
-     * @throws Exception
-     */
-    public function testGenerateGaToken()
-    {
-        $token = MFA::generateGaToken();
-        $this->assertIsString($token);
-        $this->assertGreaterThan(0, strlen($token));
-        $this->assertEquals(16, strlen($token));
-    }
-
-    /**
-     * @covers App\Services\MFA::verifyGa
-     */
-    public function testVerifyGa()
-    {
-        $user = (object) ['ga_token' => 'SECRET'];
-        $this->assertFalse(MFA::verifyGa($user, '000000'));
-        $this->assertFalse(MFA::verifyGa($user, 'test'));
-        $this->assertFalse(MFA::verifyGa($user, '0'));
-    }
-
-    /**
-     * @covers App\Services\MFA::getGaUrl
-     */
-    public function testGetGaUrl()
-    {
-        $_ENV['appName'] = 'Test';
-        $user = (object) ['email' => '[email protected]', 'ga_token' => 'SECRET'];
-        $url = MFA::getGaUrl($user);
-        $this->assertStringContainsString('otpauth://totp/', $url);
-        $this->assertStringContainsString(rawurlencode('Test' . ' (' . $user->email . ')'), $url);
-        $this->assertStringContainsString('secret=' . $user->ga_token, $url);
-    }
-}

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff