diff --git a/controller/passkey.go b/controller/passkey.go index d37fb9f7..79930fdf 100644 --- a/controller/passkey.go +++ b/controller/passkey.go @@ -36,6 +36,10 @@ func PasskeyRegisterBegin(c *gin.Context) { return } + if !requirePasskeyRegistrationVerification(c, user.Id) { + return + } + credential, err := model.GetPasskeyByUserID(user.Id) if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { common.ApiError(c, err) @@ -96,6 +100,10 @@ func PasskeyRegisterFinish(c *gin.Context) { return } + if !requirePasskeyRegistrationVerification(c, user.Id) { + return + } + wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { common.ApiError(c, err) @@ -151,6 +159,10 @@ func PasskeyDelete(c *gin.Context) { return } + if !requirePasskeyDeleteVerification(c, user.Id) { + return + } + if err := model.DeletePasskeyByUserID(user.Id); err != nil { common.ApiError(c, err) return @@ -474,6 +486,7 @@ func PasskeyVerifyFinish(c *gin.Context) { // Mark passkey as ready; /api/verify will convert this into the final secure verification session. session.Set(PasskeyReadySessionKey, time.Now().Unix()) session.Delete(SecureVerificationSessionKey) + session.Delete(secureVerificationMethodSessionKey) if err := session.Save(); err != nil { common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) return @@ -504,3 +517,60 @@ func getSessionUser(c *gin.Context) (*model.User, error) { } return user, nil } + +func requirePasskeyRegistrationVerification(c *gin.Context, userID int) bool { + twoFA, err := model.GetTwoFAByUserId(userID) + if err != nil { + common.ApiError(c, err) + return false + } + if twoFA == nil || !twoFA.IsEnabled { + return true + } + return requireSecureVerificationMethod(c, secureVerificationMethod2FA) +} + +func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool { + twoFA, err := model.GetTwoFAByUserId(userID) + if err != nil { + common.ApiError(c, err) + return false + } + if twoFA != nil && twoFA.IsEnabled { + return requireSecureVerificationMethod(c, secureVerificationMethod2FA) + } + + _, err = model.GetPasskeyByUserID(userID) + if err != nil { + if errors.Is(err, model.ErrPasskeyNotFound) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return false + } + common.ApiError(c, err) + return false + } + + return requireSecureVerificationMethod(c, secureVerificationMethodPasskey) +} + +func requireSecureVerificationMethod(c *gin.Context, method string) bool { + session := sessions.Default(c) + verifiedAt, ok := session.Get(SecureVerificationSessionKey).(int64) + if !ok || time.Now().Unix()-verifiedAt >= SecureVerificationTimeout { + session.Delete(SecureVerificationSessionKey) + session.Delete(secureVerificationMethodSessionKey) + _ = session.Save() + common.ApiErrorMsg(c, "请先完成安全验证") + return false + } + + if verifiedMethod, ok := session.Get(secureVerificationMethodSessionKey).(string); !ok || verifiedMethod != method { + common.ApiErrorMsg(c, "请先完成对应的安全验证") + return false + } + + return true +} diff --git a/controller/secure_verification.go b/controller/secure_verification.go index b229a66b..7640269e 100644 --- a/controller/secure_verification.go +++ b/controller/secure_verification.go @@ -13,7 +13,10 @@ import ( const ( // SecureVerificationSessionKey means the user has fully passed secure verification. - SecureVerificationSessionKey = "secure_verified_at" + SecureVerificationSessionKey = "secure_verified_at" + secureVerificationMethodSessionKey = "secure_verified_method" + secureVerificationMethod2FA = "2fa" + secureVerificationMethodPasskey = "passkey" // PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification. PasskeyReadySessionKey = "secure_passkey_ready_at" // SecureVerificationTimeout 验证有效期(秒) @@ -120,7 +123,7 @@ func UniversalVerify(c *gin.Context) { } // 验证成功,在 session 中记录时间戳 - now, err := setSecureVerificationSession(c) + now, err := setSecureVerificationSession(c, req.Method) if err != nil { common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) return @@ -139,11 +142,12 @@ func UniversalVerify(c *gin.Context) { }) } -func setSecureVerificationSession(c *gin.Context) (int64, error) { +func setSecureVerificationSession(c *gin.Context, method string) (int64, error) { session := sessions.Default(c) session.Delete(PasskeyReadySessionKey) now := time.Now().Unix() session.Set(SecureVerificationSessionKey, now) + session.Set(secureVerificationMethodSessionKey, method) if err := session.Save(); err != nil { return 0, err } diff --git a/middleware/secure_verification.go b/middleware/secure_verification.go index 19fae9a5..b218f6b1 100644 --- a/middleware/secure_verification.go +++ b/middleware/secure_verification.go @@ -10,7 +10,8 @@ import ( const ( // SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致) - SecureVerificationSessionKey = "secure_verified_at" + SecureVerificationSessionKey = "secure_verified_at" + secureVerificationMethodSessionKey = "secure_verified_method" // SecureVerificationTimeout 验证有效期(秒) SecureVerificationTimeout = 300 // 5分钟 ) @@ -48,8 +49,7 @@ func SecureVerificationRequired() gin.HandlerFunc { verifiedAt, ok := verifiedAtRaw.(int64) if !ok { // session 数据格式错误 - session.Delete(SecureVerificationSessionKey) - _ = session.Save() + clearSecureVerificationSession(session) c.JSON(http.StatusForbidden, gin.H{ "success": false, "message": "验证状态异常,请重新验证", @@ -63,8 +63,7 @@ func SecureVerificationRequired() gin.HandlerFunc { elapsed := time.Now().Unix() - verifiedAt if elapsed >= SecureVerificationTimeout { // 验证已过期,清除 session - session.Delete(SecureVerificationSessionKey) - _ = session.Save() + clearSecureVerificationSession(session) c.JSON(http.StatusForbidden, gin.H{ "success": false, "message": "验证已过期,请重新验证", @@ -74,11 +73,16 @@ func SecureVerificationRequired() gin.HandlerFunc { return } - // 验证有效,继续处理请求 c.Next() } } +func clearSecureVerificationSession(session sessions.Session) { + session.Delete(SecureVerificationSessionKey) + session.Delete(secureVerificationMethodSessionKey) + _ = session.Save() +} + // OptionalSecureVerification 可选的安全验证中间件 // 如果用户已验证,则在 context 中设置标记,但不阻止请求继续 // 用于某些需要区分是否已验证的场景 @@ -109,8 +113,7 @@ func OptionalSecureVerification() gin.HandlerFunc { elapsed := time.Now().Unix() - verifiedAt if elapsed >= SecureVerificationTimeout { - session.Delete(SecureVerificationSessionKey) - _ = session.Save() + clearSecureVerificationSession(session) c.Set("secure_verified", false) c.Next() return @@ -126,6 +129,5 @@ func OptionalSecureVerification() gin.HandlerFunc { // 用于用户登出或需要强制重新验证的场景 func ClearSecureVerification(c *gin.Context) { session := sessions.Default(c) - session.Delete(SecureVerificationSessionKey) - _ = session.Save() + clearSecureVerificationSession(session) } diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 4f9ce2d6..e735c877 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -45,6 +45,8 @@ import EmailBindModal from './personal/modals/EmailBindModal'; import WeChatBindModal from './personal/modals/WeChatBindModal'; import AccountDeleteModal from './personal/modals/AccountDeleteModal'; import ChangePasswordModal from './personal/modals/ChangePasswordModal'; +import SecureVerificationModal from '../common/modals/SecureVerificationModal'; +import { useSecureVerification } from '../../hooks/common/useSecureVerification'; const PersonalSetting = () => { const [userState, userDispatch] = useContext(UserContext); @@ -76,6 +78,10 @@ const PersonalSetting = () => { const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false); const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false); const [passkeySupported, setPasskeySupported] = useState(false); + const [ + passkeyRequiredVerificationMethod, + setPasskeyRequiredVerificationMethod, + ] = useState(null); const [notificationSettings, setNotificationSettings] = useState({ warningType: 'email', warningThreshold: 100000, @@ -91,6 +97,34 @@ const PersonalSetting = () => { recordIpLog: false, }); + const { + isModalVisible: isPasskeyVerificationModalVisible, + verificationMethods: passkeyVerificationMethods, + verificationState: passkeyVerificationState, + startVerification: startPasskeyVerification, + executeVerification: executePasskeyVerification, + cancelVerification: cancelPasskeyVerification, + setVerificationCode: setPasskeyVerificationCode, + switchVerificationMethod: switchPasskeyVerificationMethod, + checkVerificationMethods: checkPasskeyVerificationMethods, + } = useSecureVerification({ + onSuccess: () => { + setPasskeyRequiredVerificationMethod(null); + }, + }); + + const visiblePasskeyVerificationMethods = passkeyRequiredVerificationMethod + ? { + ...passkeyVerificationMethods, + has2FA: + passkeyRequiredVerificationMethod === '2fa' && + passkeyVerificationMethods.has2FA, + hasPasskey: + passkeyRequiredVerificationMethod === 'passkey' && + passkeyVerificationMethods.hasPasskey, + } + : passkeyVerificationMethods; + useEffect(() => { let saved = localStorage.getItem('status'); if (saved) { @@ -203,18 +237,57 @@ const PersonalSetting = () => { } }; - const handleRegisterPasskey = async () => { - if (!passkeySupported || !window.PublicKeyCredential) { + const startPasskeyManagementVerification = async (apiCall, options = {}) => { + const methods = await checkPasskeyVerificationMethods(); + const requiredMethod = methods.has2FA + ? '2fa' + : methods.hasPasskey + ? 'passkey' + : null; + + if (!requiredMethod) { + showError(t('您需要先启用两步验证或 Passkey 才能执行此操作')); + return; + } + + if (requiredMethod === 'passkey' && !methods.passkeySupported) { showInfo(t('当前设备不支持 Passkey')); return; } + + setPasskeyRequiredVerificationMethod(requiredMethod); + await startPasskeyVerification(apiCall, { + preferredMethod: requiredMethod, + title: t('安全验证'), + ...options, + }); + }; + + const startPasskeyRegistration = async () => { + const methods = await checkPasskeyVerificationMethods(); + if (!methods.has2FA) { + try { + await registerPasskey(); + } catch (error) { + showError(error.message || t('Passkey 注册失败,请重试')); + } + return; + } + + setPasskeyRequiredVerificationMethod('2fa'); + await startPasskeyVerification(registerPasskey, { + preferredMethod: '2fa', + title: t('安全验证'), + }); + }; + + const registerPasskey = async () => { setPasskeyRegisterLoading(true); try { const beginRes = await API.post('/api/user/passkey/register/begin'); const { success, message, data } = beginRes.data; if (!success) { - showError(message || t('无法发起 Passkey 注册')); - return; + throw new Error(message || t('无法发起 Passkey 注册')); } const publicKey = prepareCredentialCreationOptions( @@ -223,49 +296,69 @@ const PersonalSetting = () => { const credential = await navigator.credentials.create({ publicKey }); const payload = buildRegistrationResult(credential); if (!payload) { - showError(t('Passkey 注册失败,请重试')); - return; + throw new Error(t('Passkey 注册失败,请重试')); } const finishRes = await API.post( '/api/user/passkey/register/finish', payload, ); - if (finishRes.data.success) { - showSuccess(t('Passkey 注册成功')); - await loadPasskeyStatus(); - } else { - showError(finishRes.data.message || t('Passkey 注册失败,请重试')); + if (!finishRes.data.success) { + throw new Error( + finishRes.data.message || t('Passkey 注册失败,请重试'), + ); } + + showSuccess(t('Passkey 注册成功')); + await loadPasskeyStatus(); + return finishRes.data; } catch (error) { if (error?.name === 'AbortError') { showInfo(t('已取消 Passkey 注册')); - } else { - showError(t('Passkey 注册失败,请重试')); + return { cancelled: true }; } + throw new Error(error?.message || t('Passkey 注册失败,请重试')); } finally { setPasskeyRegisterLoading(false); } }; - const handleRemovePasskey = async () => { + const handleRegisterPasskey = async () => { + if (!passkeySupported || !window.PublicKeyCredential) { + showInfo(t('当前设备不支持 Passkey')); + return; + } + await startPasskeyRegistration(); + }; + + const removePasskey = async () => { setPasskeyDeleteLoading(true); try { const res = await API.delete('/api/user/passkey'); const { success, message } = res.data; - if (success) { - showSuccess(t('Passkey 已解绑')); - await loadPasskeyStatus(); - } else { - showError(message || t('操作失败,请重试')); + if (!success) { + throw new Error(message || t('操作失败,请重试')); } + + showSuccess(t('Passkey 已解绑')); + await loadPasskeyStatus(); + return res.data; } catch (error) { - showError(t('操作失败,请重试')); + throw new Error(error?.message || t('操作失败,请重试')); } finally { setPasskeyDeleteLoading(false); } }; + const handleRemovePasskey = async () => { + await startPasskeyManagementVerification(removePasskey); + }; + + const handlePasskeyVerificationCancel = () => { + setPasskeyRequiredVerificationMethod(null); + cancelPasskeyVerification(); + }; + const getUserData = async () => { let res = await API.get(`/api/user/self`); const { success, message, data } = res.data; @@ -556,6 +649,18 @@ const PersonalSetting = () => { turnstileSiteKey={turnstileSiteKey} setTurnstileToken={setTurnstileToken} /> + + ); };