Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
1d83b547
authored
Apr 22, 2026
by
Seefs
Committed by
GitHub
Apr 22, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: require proper verification for passkey changes (#4393)
parent
e729b221
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
214 additions
and
33 deletions
+214
-33
controller/passkey.go
+70
-0
controller/secure_verification.go
+7
-3
middleware/secure_verification.go
+12
-10
web/src/components/settings/PersonalSetting.jsx
+125
-20
No files found.
controller/passkey.go
View file @
1d83b547
...
...
@@ -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
}
controller/secure_verification.go
View file @
1d83b547
...
...
@@ -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
}
...
...
middleware/secure_verification.go
View file @
1d83b547
...
...
@@ -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
)
}
web/src/components/settings/PersonalSetting.jsx
View file @
1d83b547
...
...
@@ -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
>
);
};
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment