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

This commit is contained in:
Seefs
2026-04-22 22:55:06 +08:00
committed by GitHub
parent e729b22197
commit 1d83b5472a
4 changed files with 214 additions and 33 deletions
+70
View File
@@ -36,6 +36,10 @@ func PasskeyRegisterBegin(c *gin.Context) {
return return
} }
if !requirePasskeyRegistrationVerification(c, user.Id) {
return
}
credential, err := model.GetPasskeyByUserID(user.Id) credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
common.ApiError(c, err) common.ApiError(c, err)
@@ -96,6 +100,10 @@ func PasskeyRegisterFinish(c *gin.Context) {
return return
} }
if !requirePasskeyRegistrationVerification(c, user.Id) {
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request) wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
@@ -151,6 +159,10 @@ func PasskeyDelete(c *gin.Context) {
return return
} }
if !requirePasskeyDeleteVerification(c, user.Id) {
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil { if err := model.DeletePasskeyByUserID(user.Id); err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return 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. // Mark passkey as ready; /api/verify will convert this into the final secure verification session.
session.Set(PasskeyReadySessionKey, time.Now().Unix()) session.Set(PasskeyReadySessionKey, time.Now().Unix())
session.Delete(SecureVerificationSessionKey) session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
if err := session.Save(); err != nil { if err := session.Save(); err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return return
@@ -504,3 +517,60 @@ func getSessionUser(c *gin.Context) (*model.User, error) {
} }
return user, nil 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
View File
@@ -13,7 +13,10 @@ import (
const ( const (
// SecureVerificationSessionKey means the user has fully passed secure verification. // 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 means WebAuthn finished and /api/verify can finalize step-up verification.
PasskeyReadySessionKey = "secure_passkey_ready_at" PasskeyReadySessionKey = "secure_passkey_ready_at"
// SecureVerificationTimeout 验证有效期(秒) // SecureVerificationTimeout 验证有效期(秒)
@@ -120,7 +123,7 @@ func UniversalVerify(c *gin.Context) {
} }
// 验证成功,在 session 中记录时间戳 // 验证成功,在 session 中记录时间戳
now, err := setSecureVerificationSession(c) now, err := setSecureVerificationSession(c, req.Method)
if err != nil { if err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return 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 := sessions.Default(c)
session.Delete(PasskeyReadySessionKey) session.Delete(PasskeyReadySessionKey)
now := time.Now().Unix() now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now) session.Set(SecureVerificationSessionKey, now)
session.Set(secureVerificationMethodSessionKey, method)
if err := session.Save(); err != nil { if err := session.Save(); err != nil {
return 0, err return 0, err
} }
+12 -10
View File
@@ -10,7 +10,8 @@ import (
const ( const (
// SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致) // SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致)
SecureVerificationSessionKey = "secure_verified_at" SecureVerificationSessionKey = "secure_verified_at"
secureVerificationMethodSessionKey = "secure_verified_method"
// SecureVerificationTimeout 验证有效期(秒) // SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟 SecureVerificationTimeout = 300 // 5分钟
) )
@@ -48,8 +49,7 @@ func SecureVerificationRequired() gin.HandlerFunc {
verifiedAt, ok := verifiedAtRaw.(int64) verifiedAt, ok := verifiedAtRaw.(int64)
if !ok { if !ok {
// session 数据格式错误 // session 数据格式错误
session.Delete(SecureVerificationSessionKey) clearSecureVerificationSession(session)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"success": false, "success": false,
"message": "验证状态异常,请重新验证", "message": "验证状态异常,请重新验证",
@@ -63,8 +63,7 @@ func SecureVerificationRequired() gin.HandlerFunc {
elapsed := time.Now().Unix() - verifiedAt elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout { if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session // 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey) clearSecureVerificationSession(session)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"success": false, "success": false,
"message": "验证已过期,请重新验证", "message": "验证已过期,请重新验证",
@@ -74,11 +73,16 @@ func SecureVerificationRequired() gin.HandlerFunc {
return return
} }
// 验证有效,继续处理请求
c.Next() c.Next()
} }
} }
func clearSecureVerificationSession(session sessions.Session) {
session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
_ = session.Save()
}
// OptionalSecureVerification 可选的安全验证中间件 // OptionalSecureVerification 可选的安全验证中间件
// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续 // 如果用户已验证,则在 context 中设置标记,但不阻止请求继续
// 用于某些需要区分是否已验证的场景 // 用于某些需要区分是否已验证的场景
@@ -109,8 +113,7 @@ func OptionalSecureVerification() gin.HandlerFunc {
elapsed := time.Now().Unix() - verifiedAt elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout { if elapsed >= SecureVerificationTimeout {
session.Delete(SecureVerificationSessionKey) clearSecureVerificationSession(session)
_ = session.Save()
c.Set("secure_verified", false) c.Set("secure_verified", false)
c.Next() c.Next()
return return
@@ -126,6 +129,5 @@ func OptionalSecureVerification() gin.HandlerFunc {
// 用于用户登出或需要强制重新验证的场景 // 用于用户登出或需要强制重新验证的场景
func ClearSecureVerification(c *gin.Context) { func ClearSecureVerification(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
session.Delete(SecureVerificationSessionKey) clearSecureVerificationSession(session)
_ = session.Save()
} }
+125 -20
View File
@@ -45,6 +45,8 @@ import EmailBindModal from './personal/modals/EmailBindModal';
import WeChatBindModal from './personal/modals/WeChatBindModal'; import WeChatBindModal from './personal/modals/WeChatBindModal';
import AccountDeleteModal from './personal/modals/AccountDeleteModal'; import AccountDeleteModal from './personal/modals/AccountDeleteModal';
import ChangePasswordModal from './personal/modals/ChangePasswordModal'; import ChangePasswordModal from './personal/modals/ChangePasswordModal';
import SecureVerificationModal from '../common/modals/SecureVerificationModal';
import { useSecureVerification } from '../../hooks/common/useSecureVerification';
const PersonalSetting = () => { const PersonalSetting = () => {
const [userState, userDispatch] = useContext(UserContext); const [userState, userDispatch] = useContext(UserContext);
@@ -76,6 +78,10 @@ const PersonalSetting = () => {
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false); const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false); const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false); const [passkeySupported, setPasskeySupported] = useState(false);
const [
passkeyRequiredVerificationMethod,
setPasskeyRequiredVerificationMethod,
] = useState(null);
const [notificationSettings, setNotificationSettings] = useState({ const [notificationSettings, setNotificationSettings] = useState({
warningType: 'email', warningType: 'email',
warningThreshold: 100000, warningThreshold: 100000,
@@ -91,6 +97,34 @@ const PersonalSetting = () => {
recordIpLog: false, 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(() => { useEffect(() => {
let saved = localStorage.getItem('status'); let saved = localStorage.getItem('status');
if (saved) { if (saved) {
@@ -203,18 +237,57 @@ const PersonalSetting = () => {
} }
}; };
const handleRegisterPasskey = async () => { const startPasskeyManagementVerification = async (apiCall, options = {}) => {
if (!passkeySupported || !window.PublicKeyCredential) { 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')); showInfo(t('当前设备不支持 Passkey'));
return; 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); setPasskeyRegisterLoading(true);
try { try {
const beginRes = await API.post('/api/user/passkey/register/begin'); const beginRes = await API.post('/api/user/passkey/register/begin');
const { success, message, data } = beginRes.data; const { success, message, data } = beginRes.data;
if (!success) { if (!success) {
showError(message || t('无法发起 Passkey 注册')); throw new Error(message || t('无法发起 Passkey 注册'));
return;
} }
const publicKey = prepareCredentialCreationOptions( const publicKey = prepareCredentialCreationOptions(
@@ -223,49 +296,69 @@ const PersonalSetting = () => {
const credential = await navigator.credentials.create({ publicKey }); const credential = await navigator.credentials.create({ publicKey });
const payload = buildRegistrationResult(credential); const payload = buildRegistrationResult(credential);
if (!payload) { if (!payload) {
showError(t('Passkey 注册失败,请重试')); throw new Error(t('Passkey 注册失败,请重试'));
return;
} }
const finishRes = await API.post( const finishRes = await API.post(
'/api/user/passkey/register/finish', '/api/user/passkey/register/finish',
payload, payload,
); );
if (finishRes.data.success) { if (!finishRes.data.success) {
showSuccess(t('Passkey 注册成功')); throw new Error(
await loadPasskeyStatus(); finishRes.data.message || t('Passkey 注册失败,请重试'),
} else { );
showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
} }
showSuccess(t('Passkey 注册成功'));
await loadPasskeyStatus();
return finishRes.data;
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') { if (error?.name === 'AbortError') {
showInfo(t('已取消 Passkey 注册')); showInfo(t('已取消 Passkey 注册'));
} else { return { cancelled: true };
showError(t('Passkey 注册失败,请重试'));
} }
throw new Error(error?.message || t('Passkey 注册失败,请重试'));
} finally { } finally {
setPasskeyRegisterLoading(false); setPasskeyRegisterLoading(false);
} }
}; };
const handleRemovePasskey = async () => { const handleRegisterPasskey = async () => {
if (!passkeySupported || !window.PublicKeyCredential) {
showInfo(t('当前设备不支持 Passkey'));
return;
}
await startPasskeyRegistration();
};
const removePasskey = async () => {
setPasskeyDeleteLoading(true); setPasskeyDeleteLoading(true);
try { try {
const res = await API.delete('/api/user/passkey'); const res = await API.delete('/api/user/passkey');
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (!success) {
showSuccess(t('Passkey 已解绑')); throw new Error(message || t('操作失败,请重试'));
await loadPasskeyStatus();
} else {
showError(message || t('操作失败,请重试'));
} }
showSuccess(t('Passkey 已解绑'));
await loadPasskeyStatus();
return res.data;
} catch (error) { } catch (error) {
showError(t('操作失败,请重试')); throw new Error(error?.message || t('操作失败,请重试'));
} finally { } finally {
setPasskeyDeleteLoading(false); setPasskeyDeleteLoading(false);
} }
}; };
const handleRemovePasskey = async () => {
await startPasskeyManagementVerification(removePasskey);
};
const handlePasskeyVerificationCancel = () => {
setPasskeyRequiredVerificationMethod(null);
cancelPasskeyVerification();
};
const getUserData = async () => { const getUserData = async () => {
let res = await API.get(`/api/user/self`); let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data; const { success, message, data } = res.data;
@@ -556,6 +649,18 @@ const PersonalSetting = () => {
turnstileSiteKey={turnstileSiteKey} turnstileSiteKey={turnstileSiteKey}
setTurnstileToken={setTurnstileToken} setTurnstileToken={setTurnstileToken}
/> />
<SecureVerificationModal
visible={isPasskeyVerificationModalVisible}
verificationMethods={visiblePasskeyVerificationMethods}
verificationState={passkeyVerificationState}
onVerify={executePasskeyVerification}
onCancel={handlePasskeyVerificationCancel}
onCodeChange={setPasskeyVerificationCode}
onMethodSwitch={switchPasskeyVerificationMethod}
title={passkeyVerificationState.title}
description={passkeyVerificationState.description}
/>
</div> </div>
); );
}; };