feat: require account password for passkey enrollment and revocation

A hijacked session must not be able to silently add a passkey as a
persistent backdoor or remove the victim's credentials. Registration
(begin) and deletion now verify the account password server-side,
reusing the existing PASSWORD_REQUIRED / PASSWORD_INCORRECT errors.

The password is used instead of TOTP step-up so the guard also protects
deployments that never configured a TOTP encryption key. The password
key in both request bodies is covered by the audit middleware's
key-substring redaction, so no credential material reaches audit_logs.

Frontend: the add-passkey form gains a current-password field, and the
delete confirmation is now a dialog with a password input (replacing
window.confirm), mirroring the TOTP disable dialog. Backend error
messages (e.g. wrong password) are surfaced instead of the generic
failure toast. Rename remains password-free as it is cosmetic.
This commit is contained in:
shaw committed 2026-07-28 14:12:46 +08:00
1 parent 97f44b21bb
commit 38ef8dc069
8 files changed
+302 -31

No files matched your search

+16 -2
View File
@@ -49,6 +49,20 @@ type passkeyRenameRequest struct {
Name string `json:"name" binding:"required"`
}
// passkeyPasswordRequest carries the account password that gates passkey
// enrollment and revocation. Binding errors are tolerated: a missing or
// malformed body yields an empty password, which the service rejects with a
// precise PASSWORD_REQUIRED error.
type passkeyPasswordRequest struct {
Password string `json:"password"`
}
func bindPasskeyPassword(c *gin.Context) string {
var req passkeyPasswordRequest
_ = c.ShouldBindJSON(&req)
return req.Password
}
const passkeyFinishBodyMaxBytes = 64 * 1024
// BeginLogin starts a usernameless, discoverable-credential login ceremony.
@@ -101,7 +115,7 @@ func (h *PasskeyHandler) BeginRegistration(c *gin.Context) {
response.Unauthorized(c, "User not authenticated")
return
}
creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID)
creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID, bindPasskeyPassword(c))
if err != nil {
response.ErrorFrom(c, err)
return
@@ -173,7 +187,7 @@ func (h *PasskeyHandler) Delete(c *gin.Context) {
if !ok {
return
}
if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID); err != nil {
if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID, bindPasskeyPassword(c)); err != nil {
response.ErrorFrom(c, err)
return
}
+26 -1
View File
@@ -151,9 +151,24 @@ func (s *PasskeyService) requireEnabled() error {
return nil
}
// verifyPasskeyPassword gates credential enrollment and revocation with the
// account password so a hijacked session cannot silently add or remove
// passkeys. The password is used instead of TOTP step-up so the guard also
// works on deployments without a TOTP encryption key configured.
func verifyPasskeyPassword(user *User, password string) error {
if password == "" {
return ErrPasswordRequired
}
if user == nil || !user.CheckPassword(password) {
return ErrPasswordIncorrect
}
return nil
}
func (s *PasskeyService) BeginRegistration(
ctx context.Context,
userID int64,
password string,
) (creation *protocol.CredentialCreation, sessionToken string, err error) {
if err = s.requireEnabled(); err != nil {
return nil, "", err
@@ -165,6 +180,9 @@ func (s *PasskeyService) BeginRegistration(
if !user.IsActive() {
return nil, "", ErrUserNotActive
}
if err = verifyPasskeyPassword(user, password); err != nil {
return nil, "", err
}
candidate := make([]byte, 32)
if _, err = rand.Read(candidate); err != nil {
@@ -330,10 +348,17 @@ func (s *PasskeyService) Rename(ctx context.Context, userID, credentialID int64,
return s.repo.Rename(ctx, userID, credentialID, normalizePasskeyName(name))
}
func (s *PasskeyService) Delete(ctx context.Context, userID, credentialID int64) error {
func (s *PasskeyService) Delete(ctx context.Context, userID, credentialID int64, password string) error {
if err := s.requireEnabled(); err != nil {
return err
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
if err = verifyPasskeyPassword(user, password); err != nil {
return err
}
return s.repo.Delete(ctx, userID, credentialID)
}
+83
View File
@@ -1,9 +1,12 @@
package service
import (
"context"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/stretchr/testify/require"
)
@@ -27,3 +30,83 @@ func TestPasskeySummaryReportsCurrentBackupState(t *testing.T) {
record.Credential.Flags.BackupState = true
require.True(t, passkeySummary(record).Backup)
}
// 桩仅实现测试所需方法;未桩方法调用即 panic(嵌入 nil 接口)。
type passkeyPwUserRepoStub struct {
UserRepository
user *User
}
func (s *passkeyPwUserRepoStub) GetByID(context.Context, int64) (*User, error) {
return s.user, nil
}
type passkeyPwRepoStub struct {
PasskeyRepository
handleCalled bool
deleteCalled bool
}
func (s *passkeyPwRepoStub) EnsureUserHandle(_ context.Context, _ int64, candidate []byte) ([]byte, error) {
s.handleCalled = true
return candidate, nil
}
func (s *passkeyPwRepoStub) ListByUserID(context.Context, int64) ([]PasskeyCredentialRecord, error) {
return nil, nil
}
func (s *passkeyPwRepoStub) Delete(context.Context, int64, int64) error {
s.deleteCalled = true
return nil
}
type passkeyPwSessionStoreStub struct {
PasskeySessionStore
}
func (s *passkeyPwSessionStoreStub) Store(context.Context, *PasskeySession, time.Duration) (string, error) {
return "session-token", nil
}
func newPasskeyPwService(t *testing.T, user *User) (*PasskeyService, *passkeyPwRepoStub) {
t.Helper()
repo := &passkeyPwRepoStub{}
svc, err := NewPasskeyService(&config.Config{WebAuthn: config.WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPID: "sub2api.example.com",
RPOrigins: []string{"https://sub2api.example.com"},
}}, repo, &passkeyPwSessionStoreStub{}, &passkeyPwUserRepoStub{user: user})
require.NoError(t, err)
return svc, repo
}
// 注册与吊销必须验证账号密码:被窃会话不得静默添加/移除凭据。
// 用密码而非 TOTP step-up,保证未配置 TOTP 加密密钥的部署同样受保护。
func TestPasskeyEnrollmentAndRevocationRequireAccountPassword(t *testing.T) {
user := &User{ID: 7, Email: "user@example.com", Status: StatusActive}
require.NoError(t, user.SetPassword("correct-password"))
svc, repo := newPasskeyPwService(t, user)
_, _, err := svc.BeginRegistration(context.Background(), user.ID, "")
require.ErrorIs(t, err, ErrPasswordRequired)
_, _, err = svc.BeginRegistration(context.Background(), user.ID, "wrong-password")
require.ErrorIs(t, err, ErrPasswordIncorrect)
require.False(t, repo.handleCalled)
creation, token, err := svc.BeginRegistration(context.Background(), user.ID, "correct-password")
require.NoError(t, err)
require.NotNil(t, creation)
require.Equal(t, "session-token", token)
require.True(t, repo.handleCalled)
err = svc.Delete(context.Background(), user.ID, 1, "")
require.ErrorIs(t, err, ErrPasswordRequired)
err = svc.Delete(context.Background(), user.ID, 1, "wrong-password")
require.ErrorIs(t, err, ErrPasswordIncorrect)
require.False(t, repo.deleteCalled)
require.NoError(t, svc.Delete(context.Background(), user.ID, 1, "correct-password"))
require.True(t, repo.deleteCalled)
}