fix: require proper verification for passkey changes (#4393)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user