Jelajahi Sumber

fix: require proper verification for passkey changes (#4393)

Seefs 1 bulan lalu
induk
melakukan
1d83b5472a

+ 70 - 0
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
+}

+ 7 - 3
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
 	}

+ 12 - 10
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)
 }

+ 125 - 20
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}
       />
+
+      <SecureVerificationModal
+        visible={isPasskeyVerificationModalVisible}
+        verificationMethods={visiblePasskeyVerificationMethods}
+        verificationState={passkeyVerificationState}
+        onVerify={executePasskeyVerification}
+        onCancel={handlePasskeyVerificationCancel}
+        onCodeChange={setPasskeyVerificationCode}
+        onMethodSwitch={switchPasskeyVerificationMethod}
+        title={passkeyVerificationState.title}
+        description={passkeyVerificationState.description}
+      />
     </div>
   );
 };