From cc62979aa7638bae3b617b3e6f008433f15cd84b Mon Sep 17 00:00:00 2001 From: Zhixuan Jiang Date: Sun, 26 Jul 2026 09:50:28 -0400 Subject: [PATCH 1/5] feat: add passkey authentication --- backend/cmd/server/wire_gen.go | 9 +- backend/go.mod | 10 +- backend/go.sum | 18 + backend/internal/config/config.go | 55 +++ backend/internal/config/webauthn_test.go | 104 +++++ backend/internal/handler/auth_handler.go | 8 +- backend/internal/handler/dto/settings.go | 1 + backend/internal/handler/handler.go | 1 + backend/internal/handler/passkey_handler.go | 207 ++++++++++ backend/internal/handler/setting_handler.go | 1 + backend/internal/handler/wire.go | 3 + backend/internal/repository/passkey_repo.go | 239 +++++++++++ .../repository/passkey_session_store.go | 69 ++++ backend/internal/repository/wire.go | 2 + .../internal/server/middleware/audit_log.go | 2 + .../server/middleware/backend_mode_guard.go | 9 +- backend/internal/server/routes/auth.go | 6 + backend/internal/server/routes/user.go | 9 + backend/internal/service/passkey.go | 378 ++++++++++++++++++ backend/internal/service/passkey_test.go | 16 + backend/internal/service/setting_public.go | 3 + backend/internal/service/wire.go | 1 + .../migrations/191_passkey_credentials.sql | 22 + deploy/config.example.yaml | 16 + frontend/src/api/__tests__/passkey.spec.ts | 108 +++++ frontend/src/api/index.ts | 1 + frontend/src/api/passkey.ts | 167 ++++++++ .../user/profile/ProfilePasskeyCard.vue | 217 ++++++++++ frontend/src/i18n/locales/en/common.ts | 4 + frontend/src/i18n/locales/en/dashboard.ts | 23 ++ frontend/src/i18n/locales/zh/common.ts | 4 + frontend/src/i18n/locales/zh/dashboard.ts | 23 ++ frontend/src/stores/app.ts | 1 + frontend/src/stores/auth.ts | 14 +- frontend/src/types/index.ts | 1 + frontend/src/views/auth/LoginView.vue | 49 ++- frontend/src/views/user/ProfileView.vue | 4 + 37 files changed, 1797 insertions(+), 8 deletions(-) create mode 100644 backend/internal/config/webauthn_test.go create mode 100644 backend/internal/handler/passkey_handler.go create mode 100644 backend/internal/repository/passkey_repo.go create mode 100644 backend/internal/repository/passkey_session_store.go create mode 100644 backend/internal/service/passkey.go create mode 100644 backend/internal/service/passkey_test.go create mode 100644 backend/migrations/191_passkey_credentials.sql create mode 100644 frontend/src/api/__tests__/passkey.spec.ts create mode 100644 frontend/src/api/passkey.ts create mode 100644 frontend/src/components/user/profile/ProfilePasskeyCard.vue diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 43c108db4d..4de2d130da 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -46,8 +46,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { return nil, err } userRepository := repository.NewUserRepository(client, db) + passkeyRepository := repository.NewPasskeyRepository(db) redeemCodeRepository := repository.NewRedeemCodeRepository(client) redisClient := repository.ProvideRedis(configConfig) + passkeySessionStore := repository.NewPasskeySessionStore(redisClient) refreshTokenCache := repository.NewRefreshTokenCache(redisClient) settingRepository := repository.NewSettingRepository(client) groupRepository := repository.NewGroupRepository(client, db) @@ -79,6 +81,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { affiliateRepository := repository.NewAffiliateRepository(client, db) affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService) authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository) + passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository) + if err != nil { + return nil, err + } userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) redeemCache := repository.NewRedeemCache(redisClient) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService) @@ -92,6 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userAttributeValueRepository := repository.NewUserAttributeValueRepository(client) userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService) + passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService) userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) @@ -296,7 +303,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) diff --git a/backend/go.mod b/backend/go.mod index d9d5007d0f..4913f31c73 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -16,7 +16,8 @@ require ( github.com/coder/websocket v1.8.14 github.com/dgraph-io/ristretto v0.2.0 github.com/gin-gonic/gin v1.9.1 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/go-webauthn/webauthn v0.17.4 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 @@ -97,6 +98,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -106,9 +108,12 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.6 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect @@ -137,6 +142,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect @@ -158,10 +164,12 @@ require ( github.com/testcontainers/testcontainers-go v0.40.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect diff --git a/backend/go.sum b/backend/go.sum index bb33293b7b..c8af7cd422 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -128,6 +128,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -153,16 +155,26 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= @@ -267,6 +279,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -364,6 +378,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru4+3Q7Y= github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -374,6 +390,8 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs= github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a62c068f21..4ac0d57ef7 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -73,6 +73,7 @@ type Config struct { Ops OpsConfig `mapstructure:"ops"` JWT JWTConfig `mapstructure:"jwt"` Totp TotpConfig `mapstructure:"totp"` + WebAuthn WebAuthnConfig `mapstructure:"webauthn"` LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"` WeChat WeChatConnectConfig `mapstructure:"wechat_connect"` OIDC OIDCConnectConfig `mapstructure:"oidc_connect"` @@ -686,6 +687,16 @@ type CORSConfig struct { AllowCredentials bool `mapstructure:"allow_credentials"` } +// WebAuthnConfig configures this deployment as a WebAuthn relying party. +// RPID and RPOrigins are security boundaries and must never be inferred from +// untrusted request Host or Origin headers. +type WebAuthnConfig struct { + Enabled bool `mapstructure:"enabled"` + RPDisplayName string `mapstructure:"rp_display_name"` + RPID string `mapstructure:"rp_id"` + RPOrigins []string `mapstructure:"rp_origins"` +} + const MaxForwardedClientIPHeaders = 16 type ForwardedClientIPSettings struct { @@ -1872,6 +1883,13 @@ func setDefaults() { viper.SetDefault("cors.allowed_origins", []string{}) viper.SetDefault("cors.allow_credentials", true) + // WebAuthn / Passkeys are opt-in because every deployment must explicitly + // declare its relying-party domain and trusted browser origins. + viper.SetDefault("webauthn.enabled", false) + viper.SetDefault("webauthn.rp_display_name", "Sub2API") + viper.SetDefault("webauthn.rp_id", "") + viper.SetDefault("webauthn.rp_origins", []string{}) + // Security viper.SetDefault("security.url_allowlist.enabled", false) viper.SetDefault("security.url_allowlist.upstream_hosts", []string{ @@ -2588,6 +2606,43 @@ func (c *Config) Validate() error { } warnIfInsecureURL("server.frontend_url", c.Server.FrontendURL) } + if c.WebAuthn.Enabled { + c.WebAuthn.RPDisplayName = strings.TrimSpace(c.WebAuthn.RPDisplayName) + c.WebAuthn.RPID = strings.ToLower(strings.TrimSpace(c.WebAuthn.RPID)) + c.WebAuthn.RPOrigins = normalizeStringSlice(c.WebAuthn.RPOrigins) + if c.WebAuthn.RPDisplayName == "" { + return fmt.Errorf("webauthn.rp_display_name is required when passkeys are enabled") + } + if c.WebAuthn.RPID == "" { + return fmt.Errorf("webauthn.rp_id is required when passkeys are enabled") + } + if strings.Contains(c.WebAuthn.RPID, "://") || strings.ContainsAny(c.WebAuthn.RPID, "/:") { + return fmt.Errorf("webauthn.rp_id must be a domain without scheme, port, or path") + } + if len(c.WebAuthn.RPOrigins) == 0 { + return fmt.Errorf("webauthn.rp_origins must contain at least one origin when passkeys are enabled") + } + for i, origin := range c.WebAuthn.RPOrigins { + u, err := url.Parse(origin) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("webauthn.rp_origins contains invalid origin %q", origin) + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" { + return fmt.Errorf("webauthn.rp_origins entry %q must not include userinfo, path, query, or fragment", origin) + } + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + host := strings.ToLower(u.Hostname()) + localDevelopment := host == "localhost" || host == "127.0.0.1" || host == "::1" + if u.Scheme != "https" && !(u.Scheme == "http" && localDevelopment) { + return fmt.Errorf("webauthn.rp_origins entry %q must use HTTPS (HTTP is allowed only for localhost)", origin) + } + if host != c.WebAuthn.RPID && !strings.HasSuffix(host, "."+c.WebAuthn.RPID) { + return fmt.Errorf("webauthn.rp_origins entry %q is not within relying party ID %q", origin, c.WebAuthn.RPID) + } + c.WebAuthn.RPOrigins[i] = u.Scheme + "://" + u.Host + } + } if c.JWT.ExpireHour <= 0 { return fmt.Errorf("jwt.expire_hour must be positive") } diff --git a/backend/internal/config/webauthn_test.go b/backend/internal/config/webauthn_test.go new file mode 100644 index 0000000000..959aa3519c --- /dev/null +++ b/backend/internal/config/webauthn_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "strings" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestValidateWebAuthnConfig(t *testing.T) { + tests := []struct { + name string + configure func(*Config) + wantError string + }{ + { + name: "valid production origin", + configure: func(cfg *Config) { + cfg.WebAuthn = WebAuthnConfig{ + Enabled: true, + RPDisplayName: "Sub2API", + RPID: "sub2api.example.com", + RPOrigins: []string{"https://sub2api.example.com"}, + } + }, + }, + { + name: "valid localhost development origin", + configure: func(cfg *Config) { + cfg.WebAuthn = WebAuthnConfig{ + Enabled: true, + RPDisplayName: "Sub2API Dev", + RPID: "localhost", + RPOrigins: []string{"http://localhost:5173"}, + } + }, + }, + { + name: "missing relying party id", + configure: func(cfg *Config) { + cfg.WebAuthn = WebAuthnConfig{ + Enabled: true, + RPDisplayName: "Sub2API", + RPOrigins: []string{"https://sub2api.example.com"}, + } + }, + wantError: "webauthn.rp_id", + }, + { + name: "relying party id contains scheme", + configure: func(cfg *Config) { + cfg.WebAuthn = WebAuthnConfig{ + Enabled: true, + RPDisplayName: "Sub2API", + RPID: "https://sub2api.example.com", + RPOrigins: []string{"https://sub2api.example.com"}, + } + }, + wantError: "domain without scheme", + }, + { + name: "non-local insecure origin", + configure: func(cfg *Config) { + cfg.WebAuthn = WebAuthnConfig{ + Enabled: true, + RPDisplayName: "Sub2API", + RPID: "sub2api.example.com", + RPOrigins: []string{"http://sub2api.example.com"}, + } + }, + wantError: "must use HTTPS", + }, + { + name: "origin outside relying party id", + configure: func(cfg *Config) { + cfg.WebAuthn = WebAuthnConfig{ + Enabled: true, + RPDisplayName: "Sub2API", + RPID: "example.com", + RPOrigins: []string{"https://example.net"}, + } + }, + wantError: "not within relying party ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + t.Setenv("JWT_SECRET", strings.Repeat("x", 32)) + cfg, err := Load() + require.NoError(t, err) + tt.configure(cfg) + + err = cfg.Validate() + if tt.wantError == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantError) + } + }) + } +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 2467feaee8..7b38ee130e 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -98,16 +98,20 @@ func ensureLoginUserActive(user *service.User) error { // respondWithTokenPair 生成 Token 对并返回认证响应 // 如果 Token 对生成失败,回退到只返回 Access Token(向后兼容) func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) { + respondWithTokenPair(c, h.authService, user) +} + +func respondWithTokenPair(c *gin.Context, authService *service.AuthService, user *service.User) { if err := ensureLoginUserActive(user); err != nil { response.ErrorFrom(c, err) return } - tokenPair, err := h.authService.GenerateTokenPair(c.Request.Context(), user, "") + tokenPair, err := authService.GenerateTokenPair(c.Request.Context(), user, "") if err != nil { slog.Error("failed to generate token pair", "error", err, "user_id", user.ID) // 回退到只返回Access Token - token, tokenErr := h.authService.GenerateToken(c.Request.Context(), user) + token, tokenErr := authService.GenerateToken(c.Request.Context(), user) if tokenErr != nil { response.InternalError(c, "Failed to generate token") return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 181aa07a01..2931228dc5 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -320,6 +320,7 @@ type PublicSettings struct { PasswordResetEnabled bool `json:"password_reset_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + PasskeyEnabled bool `json:"passkey_enabled"` LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 13d3076b56..5db8584e4e 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -58,6 +58,7 @@ type Handlers struct { OpenAIGateway *OpenAIGatewayHandler Setting *SettingHandler Totp *TotpHandler + Passkey *PasskeyHandler Payment *PaymentHandler PaymentWebhook *PaymentWebhookHandler AvailableChannel *AvailableChannelHandler diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go new file mode 100644 index 0000000000..3bb4481b3b --- /dev/null +++ b/backend/internal/handler/passkey_handler.go @@ -0,0 +1,207 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type PasskeyHandler struct { + passkeys *service.PasskeyService + authService *service.AuthService + settingSvc *service.SettingService +} + +func NewPasskeyHandler( + passkeys *service.PasskeyService, + authService *service.AuthService, + settingService *service.SettingService, +) *PasskeyHandler { + return &PasskeyHandler{ + passkeys: passkeys, + authService: authService, + settingSvc: settingService, + } +} + +type passkeyOptionsResponse struct { + SessionToken string `json:"session_token"` + Options any `json:"options"` +} + +type passkeyFinishRequest struct { + SessionToken string `json:"session_token" binding:"required"` + Name string `json:"name,omitempty"` + Credential json.RawMessage `json:"credential" binding:"required"` +} + +type passkeyRenameRequest struct { + Name string `json:"name" binding:"required"` +} + +// BeginLogin starts a usernameless, discoverable-credential login ceremony. +func (h *PasskeyHandler) BeginLogin(c *gin.Context) { + assertion, token, err := h.passkeys.BeginLogin(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: assertion}) +} + +// FinishLogin validates a passkey assertion and creates a normal Sub2API token +// session. User verification is mandatory, so a successful passkey assertion +// already supplies phishing-resistant multi-factor authentication and does not +// enter the separate TOTP challenge flow. +func (h *PasskeyHandler) FinishLogin(c *gin.Context) { + req, ok := bindPasskeyFinishRequest(c) + if !ok { + return + } + credentialRequest := cloneRequestWithJSON(c.Request, req.Credential) + user, err := h.passkeys.FinishLogin(c.Request.Context(), req.SessionToken, credentialRequest) + if err != nil { + response.ErrorFrom(c, err) + return + } + if err = h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil { + response.ErrorFrom(c, err) + return + } + h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) + respondWithTokenPair(c, h.authService, user) +} + +func (h *PasskeyHandler) BeginRegistration(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: creation}) +} + +func (h *PasskeyHandler) FinishRegistration(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + req, valid := bindPasskeyFinishRequest(c) + if !valid { + return + } + credentialRequest := cloneRequestWithJSON(c.Request, req.Credential) + credential, err := h.passkeys.FinishRegistration( + c.Request.Context(), + subject.UserID, + req.SessionToken, + req.Name, + credentialRequest, + ) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, credential) +} + +func (h *PasskeyHandler) List(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + credentials, err := h.passkeys.List(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, credentials) +} + +func (h *PasskeyHandler) Rename(c *gin.Context) { + subject, credentialID, ok := passkeyMutationTarget(c) + if !ok { + return + } + var req passkeyRenameRequest + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" { + response.BadRequest(c, "Passkey name is required") + return + } + if err := h.passkeys.Rename(c.Request.Context(), subject.UserID, credentialID, req.Name); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"success": true}) +} + +func (h *PasskeyHandler) Delete(c *gin.Context) { + subject, credentialID, ok := passkeyMutationTarget(c) + if !ok { + return + } + if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"success": true}) +} + +func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error { + if err := ensureLoginUserActive(user); err != nil { + return err + } + if h.settingSvc == nil || !h.settingSvc.IsBackendModeEnabled(ctx) || user.IsAdmin() { + return nil + } + return infraerrors.Forbidden("BACKEND_MODE_ADMIN_ONLY", "Backend mode is active. Only admin login is allowed.") +} + +func bindPasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, bool) { + var req passkeyFinishRequest + if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 { + response.BadRequest(c, "Invalid passkey response") + return nil, false + } + return &req, true +} + +func cloneRequestWithJSON(original *http.Request, payload []byte) *http.Request { + request := original.Clone(original.Context()) + request.Body = io.NopCloser(bytes.NewReader(payload)) + request.ContentLength = int64(len(payload)) + request.Header = original.Header.Clone() + request.Header.Set("Content-Type", "application/json") + return request +} + +func passkeyMutationTarget(c *gin.Context) (middleware2.AuthSubject, int64, bool) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return middleware2.AuthSubject{}, 0, false + } + credentialID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || credentialID <= 0 { + response.BadRequest(c, "Invalid passkey ID") + return middleware2.AuthSubject{}, 0, false + } + return subject, credentialID, true +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index f6a5de4f1d..87fe0c2bcb 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -52,6 +52,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { PasswordResetEnabled: settings.PasswordResetEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, + PasskeyEnabled: settings.PasskeyEnabled, LoginAgreementEnabled: settings.LoginAgreementEnabled, LoginAgreementMode: settings.LoginAgreementMode, LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 073c19d375..7a4778a82f 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -179,6 +179,7 @@ func ProvideHandlers( openaiGatewayHandler *OpenAIGatewayHandler, settingHandler *SettingHandler, totpHandler *TotpHandler, + passkeyHandler *PasskeyHandler, paymentHandler *PaymentHandler, paymentWebhookHandler *PaymentWebhookHandler, availableChannelHandler *AvailableChannelHandler, @@ -201,6 +202,7 @@ func ProvideHandlers( OpenAIGateway: openaiGatewayHandler, Setting: settingHandler, Totp: totpHandler, + Passkey: passkeyHandler, Payment: paymentHandler, PaymentWebhook: paymentWebhookHandler, AvailableChannel: availableChannelHandler, @@ -223,6 +225,7 @@ var ProviderSet = wire.NewSet( ProvideGatewayHandler, ProvideOpenAIGatewayHandler, NewTotpHandler, + NewPasskeyHandler, ProvideSettingHandler, NewPaymentHandler, NewPaymentWebhookHandler, diff --git a/backend/internal/repository/passkey_repo.go b/backend/internal/repository/passkey_repo.go new file mode 100644 index 0000000000..f834b255dd --- /dev/null +++ b/backend/internal/repository/passkey_repo.go @@ -0,0 +1,239 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/lib/pq" +) + +type passkeyRepository struct { + db *sql.DB +} + +func NewPasskeyRepository(db *sql.DB) service.PasskeyRepository { + return &passkeyRepository{db: db} +} + +func (r *passkeyRepository) EnsureUserHandle( + ctx context.Context, + userID int64, + candidate []byte, +) ([]byte, error) { + if len(candidate) < 16 || len(candidate) > 64 { + return nil, fmt.Errorf("passkey user handle must contain 16-64 bytes") + } + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO passkey_user_handles (user_id, user_handle) + VALUES ($1, $2) + ON CONFLICT (user_id) DO NOTHING + `, userID, candidate); err != nil { + return nil, fmt.Errorf("ensure passkey user handle: %w", err) + } + return r.GetUserHandle(ctx, userID) +} + +func (r *passkeyRepository) GetUserHandle(ctx context.Context, userID int64) ([]byte, error) { + var handle []byte + err := r.db.QueryRowContext(ctx, ` + SELECT user_handle + FROM passkey_user_handles + WHERE user_id = $1 + `, userID).Scan(&handle) + if errors.Is(err, sql.ErrNoRows) { + return nil, service.ErrPasskeyNotFound + } + if err != nil { + return nil, fmt.Errorf("get passkey user handle: %w", err) + } + return handle, nil +} + +func (r *passkeyRepository) GetByCredentialID( + ctx context.Context, + credentialID []byte, +) (*service.PasskeyCredentialRecord, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data, + c.last_used_at, c.created_at, c.updated_at + FROM passkey_credentials c + JOIN passkey_user_handles h ON h.user_id = c.user_id + WHERE c.credential_id = $1 + `, credentialID) + record, err := scanPasskeyCredential(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, service.ErrPasskeyNotFound + } + if err != nil { + return nil, fmt.Errorf("get passkey credential: %w", err) + } + return record, nil +} + +func (r *passkeyRepository) ListByUserID( + ctx context.Context, + userID int64, +) ([]service.PasskeyCredentialRecord, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data, + c.last_used_at, c.created_at, c.updated_at + FROM passkey_credentials c + JOIN passkey_user_handles h ON h.user_id = c.user_id + WHERE c.user_id = $1 + ORDER BY c.created_at DESC, c.id DESC + `, userID) + if err != nil { + return nil, fmt.Errorf("list passkey credentials: %w", err) + } + defer rows.Close() + + records := make([]service.PasskeyCredentialRecord, 0) + for rows.Next() { + record, scanErr := scanPasskeyCredential(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan passkey credential: %w", scanErr) + } + records = append(records, *record) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("list passkey credentials: %w", err) + } + return records, nil +} + +func (r *passkeyRepository) Create( + ctx context.Context, + record *service.PasskeyCredentialRecord, +) (*service.PasskeyCredentialRecord, error) { + if record == nil || len(record.Credential.ID) == 0 { + return nil, fmt.Errorf("passkey credential is required") + } + credentialJSON, err := json.Marshal(record.Credential) + if err != nil { + return nil, fmt.Errorf("encode passkey credential: %w", err) + } + name := strings.TrimSpace(record.Name) + if name == "" { + name = "Passkey" + } + created := &service.PasskeyCredentialRecord{ + UserID: record.UserID, + UserHandle: append([]byte(nil), record.UserHandle...), + Name: name, + Credential: record.Credential, + } + err = r.db.QueryRowContext(ctx, ` + INSERT INTO passkey_credentials + (user_id, credential_id, name, credential_data) + VALUES ($1, $2, $3, $4::jsonb) + RETURNING id, name, created_at, updated_at + `, record.UserID, record.Credential.ID, name, string(credentialJSON)). + Scan(&created.ID, &created.Name, &created.CreatedAt, &created.UpdatedAt) + if err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" { + return nil, service.ErrPasskeyExists + } + return nil, fmt.Errorf("create passkey credential: %w", err) + } + return created, nil +} + +func (r *passkeyRepository) UpdateCredential( + ctx context.Context, + userID int64, + credential *webauthn.Credential, + usedAt time.Time, +) error { + if credential == nil || len(credential.ID) == 0 { + return fmt.Errorf("passkey credential is required") + } + credentialJSON, err := json.Marshal(credential) + if err != nil { + return fmt.Errorf("encode passkey credential: %w", err) + } + result, err := r.db.ExecContext(ctx, ` + UPDATE passkey_credentials + SET credential_data = $3::jsonb, last_used_at = $4, updated_at = NOW() + WHERE user_id = $1 AND credential_id = $2 + `, userID, credential.ID, string(credentialJSON), usedAt.UTC()) + if err != nil { + return fmt.Errorf("update passkey credential: %w", err) + } + return requirePasskeyAffected(result) +} + +func (r *passkeyRepository) Rename( + ctx context.Context, + userID, credentialID int64, + name string, +) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE passkey_credentials + SET name = $3, updated_at = NOW() + WHERE user_id = $1 AND id = $2 + `, userID, credentialID, name) + if err != nil { + return fmt.Errorf("rename passkey credential: %w", err) + } + return requirePasskeyAffected(result) +} + +func (r *passkeyRepository) Delete( + ctx context.Context, + userID, credentialID int64, +) error { + result, err := r.db.ExecContext(ctx, ` + DELETE FROM passkey_credentials + WHERE user_id = $1 AND id = $2 + `, userID, credentialID) + if err != nil { + return fmt.Errorf("delete passkey credential: %w", err) + } + return requirePasskeyAffected(result) +} + +type passkeyScanner interface { + Scan(dest ...any) error +} + +func scanPasskeyCredential(scanner passkeyScanner) (*service.PasskeyCredentialRecord, error) { + var ( + record service.PasskeyCredentialRecord + credentialJSON []byte + ) + if err := scanner.Scan( + &record.ID, + &record.UserID, + &record.UserHandle, + &record.Name, + &credentialJSON, + &record.LastUsedAt, + &record.CreatedAt, + &record.UpdatedAt, + ); err != nil { + return nil, err + } + if err := json.Unmarshal(credentialJSON, &record.Credential); err != nil { + return nil, fmt.Errorf("decode passkey credential: %w", err) + } + return &record, nil +} + +func requirePasskeyAffected(result sql.Result) error { + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return service.ErrPasskeyNotFound + } + return nil +} diff --git a/backend/internal/repository/passkey_session_store.go b/backend/internal/repository/passkey_session_store.go new file mode 100644 index 0000000000..04120206b6 --- /dev/null +++ b/backend/internal/repository/passkey_session_store.go @@ -0,0 +1,69 @@ +package repository + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/redis/go-redis/v9" +) + +const passkeySessionPrefix = "passkey:session:" + +type passkeySessionStore struct { + redis *redis.Client +} + +func NewPasskeySessionStore(redisClient *redis.Client) service.PasskeySessionStore { + return &passkeySessionStore{redis: redisClient} +} + +func (s *passkeySessionStore) Store( + ctx context.Context, + session *service.PasskeySession, + ttl time.Duration, +) (string, error) { + if session == nil || ttl <= 0 { + return "", fmt.Errorf("invalid passkey session") + } + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return "", fmt.Errorf("generate passkey session token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(random) + payload, err := json.Marshal(session) + if err != nil { + return "", fmt.Errorf("encode passkey session: %w", err) + } + if err = s.redis.Set(ctx, passkeySessionPrefix+token, payload, ttl).Err(); err != nil { + return "", fmt.Errorf("store passkey session: %w", err) + } + return token, nil +} + +func (s *passkeySessionStore) Consume( + ctx context.Context, + token string, +) (*service.PasskeySession, error) { + token = strings.TrimSpace(token) + if token == "" || len(token) > 128 { + return nil, service.ErrPasskeySession + } + payload, err := s.redis.GetDel(ctx, passkeySessionPrefix+token).Bytes() + if err == redis.Nil { + return nil, service.ErrPasskeySession + } + if err != nil { + return nil, fmt.Errorf("consume passkey session: %w", err) + } + var session service.PasskeySession + if err = json.Unmarshal(payload, &session); err != nil { + return nil, service.ErrPasskeySession + } + return &session, nil +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 66588299ba..782b1b9ce5 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -88,6 +88,8 @@ var ProviderSet = wire.NewSet( NewSettingRepository, NewOpsRepository, NewAuditLogRepository, + NewPasskeyRepository, + NewPasskeySessionStore, NewUserSubscriptionRepository, NewUserAttributeDefinitionRepository, NewUserAttributeValueRepository, diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index 98cfff1b44..aa74888e9a 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -146,6 +146,8 @@ var auditActionOverrides = map[string]string{ // auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。 // 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。 var auditBodyOmittedRoutes = map[string]struct{}{ + "POST /api/v1/auth/passkey/login/finish": {}, + "POST /api/v1/user/passkeys/register/finish": {}, "POST /api/v1/admin/accounts/import/codex-session": {}, "PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session": {}, "PUT /api/v1/admin/prompt-audit/config": {}, diff --git a/backend/internal/server/middleware/backend_mode_guard.go b/backend/internal/server/middleware/backend_mode_guard.go index 15fa72a82b..53334b68ac 100644 --- a/backend/internal/server/middleware/backend_mode_guard.go +++ b/backend/internal/server/middleware/backend_mode_guard.go @@ -29,7 +29,14 @@ func BackendModeUserGuard(settingService *service.SettingService) gin.HandlerFun func backendModeAllowsAuthPath(path string) bool { path = strings.ToLower(strings.TrimSpace(path)) - for _, suffix := range []string{"/auth/login", "/auth/login/2fa", "/auth/logout", "/auth/refresh"} { + for _, suffix := range []string{ + "/auth/login", + "/auth/login/2fa", + "/auth/passkey/login/begin", + "/auth/passkey/login/finish", + "/auth/logout", + "/auth/refresh", + } { if strings.HasSuffix(path, suffix) { return true } diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index a379edd233..46b987da95 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -40,6 +40,12 @@ func RegisterAuthRoutes( auth.POST("/login/2fa", rateLimiter.LimitWithOptions("auth-login-2fa", 20, time.Minute, middleware.RateLimitOptions{ FailureMode: middleware.RateLimitFailClose, }), h.Auth.Login2FA) + auth.POST("/passkey/login/begin", rateLimiter.LimitWithOptions("passkey-login-begin", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Passkey.BeginLogin) + auth.POST("/passkey/login/finish", rateLimiter.LimitWithOptions("passkey-login-finish", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Passkey.FinishLogin) auth.POST("/send-verify-code", rateLimiter.LimitWithOptions("auth-send-verify-code", 5, time.Minute, middleware.RateLimitOptions{ FailureMode: middleware.RateLimitFailClose, }), h.Auth.SendVerifyCode) diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index f01627b523..c99b987c24 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -58,6 +58,15 @@ func RegisterUserRoutes( // 敏感操作二次验证:授予当前会话一段时间的 step-up 权限 totp.POST("/step-up", h.Totp.StepUp) } + + passkeys := user.Group("/passkeys") + { + passkeys.GET("", h.Passkey.List) + passkeys.POST("/register/begin", h.Passkey.BeginRegistration) + passkeys.POST("/register/finish", h.Passkey.FinishRegistration) + passkeys.PATCH("/:id", h.Passkey.Rename) + passkeys.DELETE("/:id", h.Passkey.Delete) + } } // API Key管理 diff --git a/backend/internal/service/passkey.go b/backend/internal/service/passkey.go new file mode 100644 index 0000000000..70e6ca741e --- /dev/null +++ b/backend/internal/service/passkey.go @@ -0,0 +1,378 @@ +package service + +import ( + "bytes" + "context" + "crypto/rand" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +const ( + passkeySessionRegistration = "registration" + passkeySessionLogin = "login" + passkeySessionTTL = 5 * time.Minute + defaultPasskeyName = "Passkey" + maxPasskeyNameLength = 100 +) + +var ( + ErrPasskeysDisabled = infraerrors.Forbidden("PASSKEY_DISABLED", "passkey authentication is not enabled") + ErrPasskeyNotFound = infraerrors.NotFound("PASSKEY_NOT_FOUND", "passkey not found") + ErrPasskeyExists = infraerrors.Conflict("PASSKEY_ALREADY_EXISTS", "this passkey is already registered") + ErrPasskeySession = infraerrors.BadRequest("PASSKEY_SESSION_INVALID", "passkey session is invalid or expired") + ErrPasskeyVerify = infraerrors.Unauthorized("PASSKEY_VERIFICATION_FAILED", "passkey verification failed") +) + +// PasskeyCredentialRecord is the persistence representation used by the +// WebAuthn service. Credential contains the complete WebAuthn credential record +// so future library versions can continue to validate and update it. +type PasskeyCredentialRecord struct { + ID int64 + UserID int64 + UserHandle []byte + Name string + Credential webauthn.Credential + LastUsedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type PasskeyRepository interface { + EnsureUserHandle(ctx context.Context, userID int64, candidate []byte) ([]byte, error) + GetUserHandle(ctx context.Context, userID int64) ([]byte, error) + GetByCredentialID(ctx context.Context, credentialID []byte) (*PasskeyCredentialRecord, error) + ListByUserID(ctx context.Context, userID int64) ([]PasskeyCredentialRecord, error) + Create(ctx context.Context, record *PasskeyCredentialRecord) (*PasskeyCredentialRecord, error) + UpdateCredential(ctx context.Context, userID int64, credential *webauthn.Credential, usedAt time.Time) error + Rename(ctx context.Context, userID, credentialID int64, name string) error + Delete(ctx context.Context, userID, credentialID int64) error +} + +type PasskeySession struct { + Kind string `json:"kind"` + UserID int64 `json:"user_id,omitempty"` + WebAuthn webauthn.SessionData `json:"webauthn"` +} + +type PasskeySessionStore interface { + Store(ctx context.Context, session *PasskeySession, ttl time.Duration) (string, error) + Consume(ctx context.Context, token string) (*PasskeySession, error) +} + +type PasskeyCredentialSummary struct { + ID int64 `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + Backup bool `json:"backup"` +} + +type passkeyUser struct { + account *User + handle []byte + credentials []webauthn.Credential +} + +func (u *passkeyUser) WebAuthnID() []byte { + return u.handle +} + +func (u *passkeyUser) WebAuthnName() string { + return u.account.Email +} + +func (u *passkeyUser) WebAuthnDisplayName() string { + if name := strings.TrimSpace(u.account.Username); name != "" { + return name + } + return u.account.Email +} + +func (u *passkeyUser) WebAuthnCredentials() []webauthn.Credential { + return u.credentials +} + +type PasskeyService struct { + enabled bool + webAuthn *webauthn.WebAuthn + repo PasskeyRepository + sessions PasskeySessionStore + userRepo UserRepository +} + +func NewPasskeyService( + cfg *config.Config, + repo PasskeyRepository, + sessions PasskeySessionStore, + userRepo UserRepository, +) (*PasskeyService, error) { + s := &PasskeyService{ + repo: repo, + sessions: sessions, + userRepo: userRepo, + } + if cfg == nil || !cfg.WebAuthn.Enabled { + return s, nil + } + + instance, err := webauthn.New(&webauthn.Config{ + RPDisplayName: cfg.WebAuthn.RPDisplayName, + RPID: cfg.WebAuthn.RPID, + RPOrigins: cfg.WebAuthn.RPOrigins, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + UserVerification: protocol.VerificationRequired, + }, + }) + if err != nil { + return nil, fmt.Errorf("initialize WebAuthn: %w", err) + } + s.enabled = true + s.webAuthn = instance + return s, nil +} + +func (s *PasskeyService) Enabled() bool { + return s != nil && s.enabled && s.webAuthn != nil +} + +func (s *PasskeyService) requireEnabled() error { + if !s.Enabled() { + return ErrPasskeysDisabled + } + return nil +} + +func (s *PasskeyService) BeginRegistration( + ctx context.Context, + userID int64, +) (creation *protocol.CredentialCreation, sessionToken string, err error) { + if err = s.requireEnabled(); err != nil { + return nil, "", err + } + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, "", err + } + if !user.IsActive() { + return nil, "", ErrUserNotActive + } + + candidate := make([]byte, 32) + if _, err = rand.Read(candidate); err != nil { + return nil, "", fmt.Errorf("generate passkey user handle: %w", err) + } + handle, err := s.repo.EnsureUserHandle(ctx, userID, candidate) + if err != nil { + return nil, "", err + } + waUser, err := s.loadWebAuthnUser(ctx, user, handle) + if err != nil { + return nil, "", err + } + + creation, session, err := s.webAuthn.BeginRegistration( + waUser, + webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), + webauthn.WithExclusions(webauthn.Credentials(waUser.credentials).CredentialDescriptors()), + webauthn.WithExtensions(protocol.AuthenticationExtensions{"credProps": true}), + ) + if err != nil { + return nil, "", fmt.Errorf("begin passkey registration: %w", err) + } + sessionToken, err = s.sessions.Store(ctx, &PasskeySession{ + Kind: passkeySessionRegistration, + UserID: userID, + WebAuthn: *session, + }, passkeySessionTTL) + if err != nil { + return nil, "", err + } + return creation, sessionToken, nil +} + +func (s *PasskeyService) FinishRegistration( + ctx context.Context, + userID int64, + sessionToken, name string, + request *http.Request, +) (*PasskeyCredentialSummary, error) { + if err := s.requireEnabled(); err != nil { + return nil, err + } + session, err := s.sessions.Consume(ctx, sessionToken) + if err != nil { + return nil, err + } + if session == nil || session.Kind != passkeySessionRegistration || session.UserID != userID { + return nil, ErrPasskeySession + } + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + handle, err := s.repo.GetUserHandle(ctx, userID) + if err != nil { + return nil, err + } + waUser, err := s.loadWebAuthnUser(ctx, user, handle) + if err != nil { + return nil, err + } + credential, err := s.webAuthn.FinishRegistration(waUser, session.WebAuthn, request) + if err != nil { + return nil, ErrPasskeyVerify + } + + record, err := s.repo.Create(ctx, &PasskeyCredentialRecord{ + UserID: userID, + UserHandle: handle, + Name: normalizePasskeyName(name), + Credential: *credential, + }) + if err != nil { + return nil, err + } + return passkeySummary(record), nil +} + +func (s *PasskeyService) BeginLogin( + ctx context.Context, +) (assertion *protocol.CredentialAssertion, sessionToken string, err error) { + if err = s.requireEnabled(); err != nil { + return nil, "", err + } + assertion, session, err := s.webAuthn.BeginDiscoverableLogin( + webauthn.WithUserVerification(protocol.VerificationRequired), + ) + if err != nil { + return nil, "", fmt.Errorf("begin passkey login: %w", err) + } + sessionToken, err = s.sessions.Store(ctx, &PasskeySession{ + Kind: passkeySessionLogin, + WebAuthn: *session, + }, passkeySessionTTL) + if err != nil { + return nil, "", err + } + return assertion, sessionToken, nil +} + +func (s *PasskeyService) FinishLogin( + ctx context.Context, + sessionToken string, + request *http.Request, +) (*User, error) { + if err := s.requireEnabled(); err != nil { + return nil, err + } + session, err := s.sessions.Consume(ctx, sessionToken) + if err != nil { + return nil, err + } + if session == nil || session.Kind != passkeySessionLogin { + return nil, ErrPasskeySession + } + + handler := func(rawID, userHandle []byte) (webauthn.User, error) { + record, lookupErr := s.repo.GetByCredentialID(ctx, rawID) + if lookupErr != nil || record == nil || !bytes.Equal(record.UserHandle, userHandle) { + return nil, ErrPasskeyVerify + } + account, lookupErr := s.userRepo.GetByID(ctx, record.UserID) + if lookupErr != nil || account == nil || !account.IsActive() { + return nil, ErrPasskeyVerify + } + return s.loadWebAuthnUser(ctx, account, record.UserHandle) + } + + validatedUser, credential, err := s.webAuthn.FinishPasskeyLogin(handler, session.WebAuthn, request) + if err != nil { + return nil, ErrPasskeyVerify + } + waUser, ok := validatedUser.(*passkeyUser) + if !ok || waUser.account == nil { + return nil, ErrPasskeyVerify + } + if err = s.repo.UpdateCredential(ctx, waUser.account.ID, credential, time.Now().UTC()); err != nil { + return nil, err + } + return waUser.account, nil +} + +func (s *PasskeyService) List(ctx context.Context, userID int64) ([]PasskeyCredentialSummary, error) { + if err := s.requireEnabled(); err != nil { + return nil, err + } + records, err := s.repo.ListByUserID(ctx, userID) + if err != nil { + return nil, err + } + result := make([]PasskeyCredentialSummary, 0, len(records)) + for i := range records { + result = append(result, *passkeySummary(&records[i])) + } + return result, nil +} + +func (s *PasskeyService) Rename(ctx context.Context, userID, credentialID int64, name string) error { + if err := s.requireEnabled(); err != nil { + return err + } + return s.repo.Rename(ctx, userID, credentialID, normalizePasskeyName(name)) +} + +func (s *PasskeyService) Delete(ctx context.Context, userID, credentialID int64) error { + if err := s.requireEnabled(); err != nil { + return err + } + return s.repo.Delete(ctx, userID, credentialID) +} + +func (s *PasskeyService) loadWebAuthnUser( + ctx context.Context, + user *User, + handle []byte, +) (*passkeyUser, error) { + records, err := s.repo.ListByUserID(ctx, user.ID) + if err != nil { + return nil, err + } + credentials := make([]webauthn.Credential, 0, len(records)) + for i := range records { + credentials = append(credentials, records[i].Credential) + } + return &passkeyUser{account: user, handle: handle, credentials: credentials}, nil +} + +func normalizePasskeyName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return defaultPasskeyName + } + runes := []rune(name) + if len(runes) > maxPasskeyNameLength { + name = string(runes[:maxPasskeyNameLength]) + } + return name +} + +func passkeySummary(record *PasskeyCredentialRecord) *PasskeyCredentialSummary { + return &PasskeyCredentialSummary{ + ID: record.ID, + Name: record.Name, + CreatedAt: record.CreatedAt, + LastUsedAt: record.LastUsedAt, + Backup: record.Credential.Flags.BackupEligible, + } +} + +var _ webauthn.User = (*passkeyUser)(nil) diff --git a/backend/internal/service/passkey_test.go b/backend/internal/service/passkey_test.go new file mode 100644 index 0000000000..0abb9de15a --- /dev/null +++ b/backend/internal/service/passkey_test.go @@ -0,0 +1,16 @@ +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizePasskeyName(t *testing.T) { + require.Equal(t, defaultPasskeyName, normalizePasskeyName(" ")) + require.Equal(t, "Laptop", normalizePasskeyName(" Laptop ")) + + longName := strings.Repeat("密", maxPasskeyNameLength+10) + require.Len(t, []rune(normalizePasskeyName(longName)), maxPasskeyNameLength) +} diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 5bcc9e4c02..4ee4964b6e 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -289,6 +289,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PasswordResetEnabled: passwordResetEnabled, InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + PasskeyEnabled: s.cfg != nil && s.cfg.WebAuthn.Enabled, LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), LoginAgreementUpdatedAt: loginAgreementUpdatedAt, @@ -446,6 +447,7 @@ type PublicSettingsInjectionPayload struct { PasswordResetEnabled bool `json:"password_reset_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` + PasskeyEnabled bool `json:"passkey_enabled"` LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` @@ -515,6 +517,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any PasswordResetEnabled: settings.PasswordResetEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, + PasskeyEnabled: settings.PasskeyEnabled, LoginAgreementEnabled: settings.LoginAgreementEnabled, LoginAgreementMode: settings.LoginAgreementMode, LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index e1210208cb..2a07c73ea5 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -676,6 +676,7 @@ func ProvideAPIKeyService( var ProviderSet = wire.NewSet( // Core services NewAuthService, + NewPasskeyService, NewUserService, ProvideAPIKeyService, ProvideAPIKeyAuthCacheInvalidator, diff --git a/backend/migrations/191_passkey_credentials.sql b/backend/migrations/191_passkey_credentials.sql new file mode 100644 index 0000000000..522b16b5bb --- /dev/null +++ b/backend/migrations/191_passkey_credentials.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS passkey_user_handles ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + user_handle BYTEA NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS passkey_credentials ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id BYTEA NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL DEFAULT 'Passkey', + credential_data JSONB NOT NULL, + last_used_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS passkey_credentials_user_id_idx + ON passkey_credentials (user_id); + +CREATE INDEX IF NOT EXISTS passkey_credentials_last_used_at_idx + ON passkey_credentials (last_used_at); diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 535a599845..6d2a798d96 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -72,6 +72,22 @@ server: # 每个流的最大上传缓冲区(字节,默认 512KB) max_upload_buffer_per_stream: 524288 +# ============================================================================= +# WebAuthn / Passkey Configuration +# WebAuthn / Passkey 配置 +# ============================================================================= +# Passkeys require HTTPS in production. The RP ID is the domain only (no scheme +# or port), while RP origins are exact browser origins. +# Passkey 在生产环境要求 HTTPS。RP ID 只填写域名(不含协议和端口),RP origins +# 填写浏览器访问使用的完整 Origin。 +webauthn: + enabled: false + rp_display_name: "Sub2API" + # Example / 示例: "sub2api.example.com" + rp_id: "" + # Example / 示例: ["https://sub2api.example.com"] + rp_origins: [] + # ============================================================================= # Run Mode Configuration # 运行模式配置 diff --git a/frontend/src/api/__tests__/passkey.spec.ts b/frontend/src/api/__tests__/passkey.spec.ts new file mode 100644 index 0000000000..40e058aa35 --- /dev/null +++ b/frontend/src/api/__tests__/passkey.spec.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { get, post, patch, remove, credentialGet } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + remove: vi.fn(), + credentialGet: vi.fn() +})) + +vi.mock('@/api/client', () => ({ + apiClient: { + get, + post, + patch, + delete: remove + } +})) + +import { passkeyAPI } from '@/api/passkey' + +class FakePublicKeyCredential { + id = 'credential-id' + rawId = Uint8Array.from([1, 2, 3]).buffer + type = 'public-key' + authenticatorAttachment = 'platform' + response = { + authenticatorData: Uint8Array.from([4, 5]).buffer, + clientDataJSON: Uint8Array.from([6, 7]).buffer, + signature: Uint8Array.from([8, 9]).buffer, + userHandle: Uint8Array.from([10, 11]).buffer + } + + getClientExtensionResults(): AuthenticationExtensionsClientOutputs { + return {} + } +} + +describe('passkey api', () => { + beforeEach(() => { + get.mockReset() + post.mockReset() + patch.mockReset() + remove.mockReset() + credentialGet.mockReset() + + vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential) + Object.defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: FakePublicKeyCredential + }) + Object.defineProperty(navigator, 'credentials', { + configurable: true, + value: { get: credentialGet } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('converts assertion options and response bytes to WebAuthn JSON', async () => { + post + .mockResolvedValueOnce({ + data: { + session_token: 'one-time-session', + options: { + publicKey: { + challenge: 'AQID', + rpId: 'sub2api.example.com', + userVerification: 'required' + } + } + } + }) + .mockResolvedValueOnce({ + data: { + access_token: 'access', + token_type: 'Bearer', + user: { id: 1 } + } + }) + credentialGet.mockResolvedValue(new FakePublicKeyCredential()) + + await passkeyAPI.login() + + const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions + expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3]) + expect(request.publicKey!.userVerification).toBe('required') + + expect(post).toHaveBeenNthCalledWith(2, '/auth/passkey/login/finish', { + session_token: 'one-time-session', + credential: { + id: 'credential-id', + rawId: 'AQID', + type: 'public-key', + authenticatorAttachment: 'platform', + clientExtensionResults: {}, + response: { + authenticatorData: 'BAU', + clientDataJSON: 'Bgc', + signature: 'CAk', + userHandle: 'Cgs' + } + } + }) + }) +}) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 71fa27e2a7..80871cb1e3 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -19,6 +19,7 @@ export { userGroupsAPI } from './groups' export { userChannelsAPI } from './channels' export * as batchImageAPI from './batchImage' export { totpAPI } from './totp' +export { passkeyAPI, type PasskeyCredentialSummary } from './passkey' export { default as announcementsAPI } from './announcements' export { channelMonitorUserAPI } from './channelMonitor' diff --git a/frontend/src/api/passkey.ts b/frontend/src/api/passkey.ts new file mode 100644 index 0000000000..c082f3d98a --- /dev/null +++ b/frontend/src/api/passkey.ts @@ -0,0 +1,167 @@ +import { apiClient } from './client' +import type { AuthResponse } from '@/types' + +export interface PasskeyCredentialSummary { + id: number + name: string + created_at: string + last_used_at?: string + backup: boolean +} + +interface CeremonyOptionsResponse { + session_token: string + options: { + publicKey: Record + } +} + +function requirePasskeySupport(): void { + if (!window.PublicKeyCredential || !navigator.credentials) { + throw new Error('Passkeys are not supported by this browser') + } +} + +function base64URLToBuffer(value: string): ArrayBuffer { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4) + const binary = atob(padded) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + return bytes.buffer +} + +function bufferToBase64URL(value: ArrayBuffer | null): string | null { + if (value === null) return null + const bytes = new Uint8Array(value) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +function creationOptionsFromJSON( + value: Record +): PublicKeyCredentialCreationOptions { + const options = { ...value } as Record + options.challenge = base64URLToBuffer(String(options.challenge)) + + const user = { ...(options.user as Record) } + user.id = base64URLToBuffer(String(user.id)) + options.user = user + + if (Array.isArray(options.excludeCredentials)) { + options.excludeCredentials = options.excludeCredentials.map((descriptor) => ({ + ...(descriptor as Record), + id: base64URLToBuffer(String((descriptor as Record).id)) + })) + } + return options as unknown as PublicKeyCredentialCreationOptions +} + +function requestOptionsFromJSON( + value: Record +): PublicKeyCredentialRequestOptions { + const options = { ...value } as Record + options.challenge = base64URLToBuffer(String(options.challenge)) + if (Array.isArray(options.allowCredentials)) { + options.allowCredentials = options.allowCredentials.map((descriptor) => ({ + ...(descriptor as Record), + id: base64URLToBuffer(String((descriptor as Record).id)) + })) + } + return options as unknown as PublicKeyCredentialRequestOptions +} + +function serializeRegistrationCredential(credential: PublicKeyCredential): Record { + const response = credential.response as AuthenticatorAttestationResponse + return { + id: credential.id, + rawId: bufferToBase64URL(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + attestationObject: bufferToBase64URL(response.attestationObject), + clientDataJSON: bufferToBase64URL(response.clientDataJSON), + transports: typeof response.getTransports === 'function' ? response.getTransports() : [] + } + } +} + +function serializeAssertionCredential(credential: PublicKeyCredential): Record { + const response = credential.response as AuthenticatorAssertionResponse + return { + id: credential.id, + rawId: bufferToBase64URL(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + authenticatorData: bufferToBase64URL(response.authenticatorData), + clientDataJSON: bufferToBase64URL(response.clientDataJSON), + signature: bufferToBase64URL(response.signature), + userHandle: bufferToBase64URL(response.userHandle) + } + } +} + +async function login(): Promise { + requirePasskeySupport() + const { data: begin } = await apiClient.post( + '/auth/passkey/login/begin' + ) + const credential = await navigator.credentials.get({ + publicKey: requestOptionsFromJSON(begin.options.publicKey) + }) + if (!(credential instanceof PublicKeyCredential)) { + throw new Error('Passkey sign-in was cancelled') + } + const { data } = await apiClient.post('/auth/passkey/login/finish', { + session_token: begin.session_token, + credential: serializeAssertionCredential(credential) + }) + return data +} + +async function register(name: string): Promise { + requirePasskeySupport() + const { data: begin } = await apiClient.post( + '/user/passkeys/register/begin' + ) + const credential = await navigator.credentials.create({ + publicKey: creationOptionsFromJSON(begin.options.publicKey) + }) + if (!(credential instanceof PublicKeyCredential)) { + throw new Error('Passkey creation was cancelled') + } + const { data } = await apiClient.post( + '/user/passkeys/register/finish', + { + session_token: begin.session_token, + name, + credential: serializeRegistrationCredential(credential) + } + ) + return data +} + +async function list(): Promise { + const { data } = await apiClient.get('/user/passkeys') + return data +} + +async function rename(id: number, name: string): Promise { + await apiClient.patch(`/user/passkeys/${id}`, { name }) +} + +async function remove(id: number): Promise { + await apiClient.delete(`/user/passkeys/${id}`) +} + +export const passkeyAPI = { + isSupported: () => Boolean(window.PublicKeyCredential && navigator.credentials), + login, + register, + list, + rename, + remove +} diff --git a/frontend/src/components/user/profile/ProfilePasskeyCard.vue b/frontend/src/components/user/profile/ProfilePasskeyCard.vue new file mode 100644 index 0000000000..cefed6d6e6 --- /dev/null +++ b/frontend/src/components/user/profile/ProfilePasskeyCard.vue @@ -0,0 +1,217 @@ + + + diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 367b2aa35e..28af7ac31b 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -205,6 +205,10 @@ export default { signInToAccount: 'Sign in to your account to continue', signIn: 'Sign In', signingIn: 'Signing in...', + passkeySignIn: 'Sign in with a passkey', + passkeySigningIn: 'Waiting for passkey...', + passkeyCancelled: 'Passkey sign-in was cancelled.', + passkeyFailed: 'Passkey sign-in failed. Please try again.', createAccount: 'Create Account', signUpToStart: 'Sign up to start using {siteName}', signUp: 'Sign up', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 870489ea2c..1812da6125 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -690,6 +690,29 @@ export default { codeSent: 'Verification code sent to your email', sendCodeFailed: 'Failed to send verification code' }, + passkey: { + title: 'Passkeys', + description: 'Use Face ID, Touch ID, Windows Hello, or a security key to sign in without a password.', + add: 'Add passkey', + continue: 'Create passkey', + name: 'Passkey name', + namePlaceholder: 'For example, MacBook Touch ID', + empty: 'No passkeys are registered yet.', + synced: 'Synced', + createdAt: 'Created {date}', + lastUsed: 'Last used {date}', + featureDisabled: 'Passkeys have not been configured by the administrator.', + unsupported: 'This browser or device does not support passkeys.', + loadFailed: 'Failed to load passkeys.', + added: 'Passkey added.', + addFailed: 'Failed to add passkey.', + renamePrompt: 'Enter a new name for this passkey', + renamed: 'Passkey renamed.', + renameFailed: 'Failed to rename passkey.', + deleteConfirm: 'Delete “{name}”? You will no longer be able to sign in with it.', + deleted: 'Passkey deleted.', + deleteFailed: 'Failed to delete passkey.' + }, balanceNotify: { title: 'Balance Low Notification', description: 'Send email alert when account balance falls below threshold', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 78c929e55c..725c139482 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -205,6 +205,10 @@ export default { signInToAccount: '登录您的账户以继续', signIn: '登录', signingIn: '登录中...', + passkeySignIn: '使用 Passkey 登录', + passkeySigningIn: '正在等待 Passkey...', + passkeyCancelled: '已取消 Passkey 登录。', + passkeyFailed: 'Passkey 登录失败,请重试。', createAccount: '创建账户', signUpToStart: '注册以开始使用 {siteName}', signUp: '注册', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 7d129df336..a96e2a0c5b 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -695,6 +695,29 @@ export default { codeSent: '验证码已发送到您的邮箱', sendCodeFailed: '发送验证码失败' }, + passkey: { + title: 'Passkey', + description: '使用面容 ID、触控 ID、Windows Hello 或安全密钥免密码登录。', + add: '添加 Passkey', + continue: '创建 Passkey', + name: 'Passkey 名称', + namePlaceholder: '例如:MacBook 触控 ID', + empty: '尚未添加任何 Passkey。', + synced: '已同步', + createdAt: '创建于 {date}', + lastUsed: '上次使用 {date}', + featureDisabled: '管理员尚未配置 Passkey 功能。', + unsupported: '当前浏览器或设备不支持 Passkey。', + loadFailed: '加载 Passkey 失败。', + added: 'Passkey 已添加。', + addFailed: '添加 Passkey 失败。', + renamePrompt: '请输入新的 Passkey 名称', + renamed: 'Passkey 已重命名。', + renameFailed: '重命名 Passkey 失败。', + deleteConfirm: '删除“{name}”?删除后将无法再使用它登录。', + deleted: 'Passkey 已删除。', + deleteFailed: '删除 Passkey 失败。' + }, balanceNotify: { title: '余额不足提醒', description: '当账户余额低于阈值时发送邮件提醒', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 51fff5c0a6..514d0dd8f7 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -358,6 +358,7 @@ export const useAppStore = defineStore('app', () => { github_oauth_enabled: false, google_oauth_enabled: false, backend_mode_enabled: false, + passkey_enabled: false, version: siteVersion.value, balance_low_notify_enabled: false, account_quota_notify_enabled: false, diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 1346262ee8..c0f3477d3b 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -5,7 +5,7 @@ import { defineStore } from 'pinia' import { ref, computed, readonly } from 'vue' -import { authAPI, isTotp2FARequired, type LoginResponse } from '@/api' +import { authAPI, isTotp2FARequired, passkeyAPI, type LoginResponse } from '@/api' import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' const AUTH_TOKEN_KEY = 'auth_token' @@ -275,6 +275,17 @@ export const useAuthStore = defineStore('auth', () => { } } + async function loginWithPasskey(): Promise { + try { + const response = await passkeyAPI.login() + setAuthFromResponse(response) + return user.value! + } catch (error) { + clearAuth({ preservePendingAuthSession: pendingAuthSession.value !== null }) + throw error + } + } + /** * Set auth state from an AuthResponse * Internal helper function @@ -486,6 +497,7 @@ export const useAuthStore = defineStore('auth', () => { // Actions login, + loginWithPasskey, login2FA, register, setToken, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 98baf3c4ef..c23534e635 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -201,6 +201,7 @@ export interface PublicSettings { login_agreement_revision?: string login_agreement_documents?: LoginAgreementDocument[] turnstile_enabled: boolean + passkey_enabled?: boolean turnstile_site_key: string site_name: string site_logo: string diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index be74f6186a..75168dfcd5 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -131,7 +131,7 @@ @open="showAgreementModal = true" /> -
+
@@ -140,6 +140,17 @@
+ + (false) +const passkeyLoading = ref(false) const errorMessage = ref('') const showPassword = ref(false) const publicSettingsLoaded = ref(false) @@ -245,6 +257,7 @@ const oidcOAuthProviderName = ref('OIDC') const githubOAuthEnabled = ref(false) const googleOAuthEnabled = ref(false) const passwordResetEnabled = ref(false) +const passkeyEnabled = ref(false) const loginAgreementEnabled = ref(false) const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal') const loginAgreementUpdatedAt = ref('') @@ -283,7 +296,11 @@ const agreementGateActive = computed( ) const authActionDisabled = computed( - () => isLoading.value || !publicSettingsLoaded.value || agreementGateActive.value + () => isLoading.value || passkeyLoading.value || !publicSettingsLoaded.value || agreementGateActive.value +) + +const showPasskeyLogin = computed( + () => passkeyEnabled.value && typeof window.PublicKeyCredential !== 'undefined' ) const showOAuthLogin = computed( @@ -328,6 +345,7 @@ onMounted(async () => { googleOAuthEnabled.value = settings.google_oauth_enabled backendModeEnabled.value = settings.backend_mode_enabled passwordResetEnabled.value = settings.password_reset_enabled + passkeyEnabled.value = settings.passkey_enabled === true applyLoginAgreementSettings(settings) } catch (error) { console.error('Failed to load public settings:', error) @@ -516,6 +534,33 @@ async function handleLogin(): Promise { } } +async function handlePasskeyLogin(): Promise { + if (agreementGateActive.value) { + appStore.showWarning(t('legal.loginAgreementPrompt.loginRequiredWarning')) + if (loginAgreementMode.value !== 'checkbox') { + showAgreementModal.value = true + } + return + } + + passkeyLoading.value = true + try { + await authStore.loginWithPasskey() + clearAllAffiliateReferralCodes() + appStore.showSuccess(t('auth.loginSuccess')) + const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard' + await router.push(redirectTo) + } catch (error: unknown) { + const fallback = error instanceof DOMException && error.name === 'NotAllowedError' + ? t('auth.passkeyCancelled') + : t('auth.passkeyFailed') + errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', fallback) + appStore.showError(errorMessage.value) + } finally { + passkeyLoading.value = false + } +} + // ==================== 2FA Handlers ==================== async function handle2FAVerify(code: string): Promise { diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue index a6481cee13..e26b354e11 100644 --- a/frontend/src/views/user/ProfileView.vue +++ b/frontend/src/views/user/ProfileView.vue @@ -44,6 +44,7 @@ /> +
@@ -57,6 +58,7 @@ import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNo import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue' import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue' import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue' +import ProfilePasskeyCard from '@/components/user/profile/ProfilePasskeyCard.vue' import { isWeChatWebOAuthEnabled } from '@/api/auth' import { useAppStore } from '@/stores/app' import { useAuthStore } from '@/stores/auth' @@ -76,6 +78,7 @@ const wechatOAuthOpenEnabled = ref(undefined) const wechatOAuthMPEnabled = ref(undefined) const oidcOAuthEnabled = ref(false) const oidcOAuthProviderName = ref('OIDC') +const passkeyEnabled = ref(false) onMounted(async () => { const profileRefresh = authStore.refreshUser().catch((error) => { @@ -101,6 +104,7 @@ onMounted(async () => { : undefined oidcOAuthEnabled.value = settings.oidc_oauth_enabled ?? false oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC' + passkeyEnabled.value = settings.passkey_enabled === true }) .catch((error) => { console.error('Failed to load settings:', error) From 4158e73b3f025a078c624a71eba15ce24d36cc52 Mon Sep 17 00:00:00 2001 From: Zhixuan Jiang Date: Sun, 26 Jul 2026 10:10:57 -0400 Subject: [PATCH 2/5] fix: harden passkey deployment readiness --- backend/internal/handler/passkey_handler.go | 5 ++++ .../internal/handler/passkey_handler_test.go | 27 +++++++++++++++++++ .../internal/server/middleware/audit_log.go | 1 + .../server/middleware/audit_log_test.go | 6 +++++ backend/internal/service/audit_log.go | 1 + backend/internal/service/passkey.go | 2 +- backend/internal/service/passkey_test.go | 13 +++++++++ backend/internal/service/settings_view.go | 1 + 8 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/passkey_handler_test.go diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index 3bb4481b3b..9ffc920e0d 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -49,6 +49,8 @@ type passkeyRenameRequest struct { Name string `json:"name" binding:"required"` } +const passkeyFinishBodyMaxBytes = 64 * 1024 + // BeginLogin starts a usernameless, discoverable-credential login ceremony. func (h *PasskeyHandler) BeginLogin(c *gin.Context) { assertion, token, err := h.passkeys.BeginLogin(c.Request.Context()) @@ -78,6 +80,8 @@ func (h *PasskeyHandler) FinishLogin(c *gin.Context) { response.ErrorFrom(c, err) return } + middleware2.SetAuditActor(c, user.ID, user.Email) + c.Set("auth_method", service.AuditAuthMethodPasskey) h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) respondWithTokenPair(c, h.authService, user) } @@ -175,6 +179,7 @@ func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user * } func bindPasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, bool) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, passkeyFinishBodyMaxBytes) var req passkeyFinishRequest if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 { response.BadRequest(c, "Invalid passkey response") diff --git a/backend/internal/handler/passkey_handler_test.go b/backend/internal/handler/passkey_handler_test.go new file mode 100644 index 0000000000..0197ffa05d --- /dev/null +++ b/backend/internal/handler/passkey_handler_test.go @@ -0,0 +1,27 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/passkey/login/finish", + strings.NewReader(`{"credential":"`+strings.Repeat("x", passkeyFinishBodyMaxBytes)+`"}`), + ) + context.Request.Header.Set("Content-Type", "application/json") + + _, ok := bindPasskeyFinishRequest(context) + require.False(t, ok) + require.Equal(t, http.StatusBadRequest, recorder.Code) +} diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index aa74888e9a..30f399eea3 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -124,6 +124,7 @@ var auditSensitiveReads = map[string]string{ var auditActionOverrides = map[string]string{ "POST /api/v1/auth/login": service.AuditActionLogin, "POST /api/v1/auth/login/2fa": service.AuditActionLogin2FA, + "POST /api/v1/auth/passkey/login/finish": service.AuditActionLogin, "POST /api/v1/auth/register": service.AuditActionRegister, "POST /api/v1/auth/refresh": service.AuditActionTokenRefresh, "POST /api/v1/user/totp/step-up": service.AuditActionStepUpVerify, diff --git a/backend/internal/server/middleware/audit_log_test.go b/backend/internal/server/middleware/audit_log_test.go index 4892fc6d0b..3ff14cd914 100644 --- a/backend/internal/server/middleware/audit_log_test.go +++ b/backend/internal/server/middleware/audit_log_test.go @@ -146,6 +146,12 @@ func TestPromptAuditMutationAuditRoutesHaveStableActionsAndOmitBodies(t *testing } } +func TestPasskeyLoginAuditUsesCanonicalLoginActionAndOmitsCredentialBody(t *testing.T) { + route := "POST /api/v1/auth/passkey/login/finish" + require.Equal(t, service.AuditActionLogin, auditActionOverrides[route]) + require.Contains(t, auditBodyOmittedRoutes, route) +} + // Ollama 会话保存的请求体整体就是浏览器 Cookie 明文,键级脱敏清单曾漏掉裸键 // "session",必须走整体不入库路径,防止会话凭证长期留存在 audit_logs。 func TestOllamaCloudUsageSessionRouteOmitsAuditBody(t *testing.T) { diff --git a/backend/internal/service/audit_log.go b/backend/internal/service/audit_log.go index 02b85682d7..46a59013e0 100644 --- a/backend/internal/service/audit_log.go +++ b/backend/internal/service/audit_log.go @@ -19,6 +19,7 @@ const ( // AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。 AuditAuthMethodJWT = "jwt" AuditAuthMethodAdminAPIKey = "admin_api_key" + AuditAuthMethodPasskey = "passkey" // auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。 auditRequestBodyMaxBytes = 16 * 1024 diff --git a/backend/internal/service/passkey.go b/backend/internal/service/passkey.go index 70e6ca741e..7458d2f7b1 100644 --- a/backend/internal/service/passkey.go +++ b/backend/internal/service/passkey.go @@ -371,7 +371,7 @@ func passkeySummary(record *PasskeyCredentialRecord) *PasskeyCredentialSummary { Name: record.Name, CreatedAt: record.CreatedAt, LastUsedAt: record.LastUsedAt, - Backup: record.Credential.Flags.BackupEligible, + Backup: record.Credential.Flags.BackupState, } } diff --git a/backend/internal/service/passkey_test.go b/backend/internal/service/passkey_test.go index 0abb9de15a..c5f8ff3cf0 100644 --- a/backend/internal/service/passkey_test.go +++ b/backend/internal/service/passkey_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/go-webauthn/webauthn/webauthn" "github.com/stretchr/testify/require" ) @@ -14,3 +15,15 @@ func TestNormalizePasskeyName(t *testing.T) { longName := strings.Repeat("密", maxPasskeyNameLength+10) require.Len(t, []rune(normalizePasskeyName(longName)), maxPasskeyNameLength) } + +func TestPasskeySummaryReportsCurrentBackupState(t *testing.T) { + record := &PasskeyCredentialRecord{ + Credential: webauthn.Credential{ + Flags: webauthn.CredentialFlags{BackupEligible: true}, + }, + } + require.False(t, passkeySummary(record).Backup) + + record.Credential.Flags.BackupState = true + require.True(t, passkeySummary(record).Backup) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index c775e2ee77..e4d9096607 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -284,6 +284,7 @@ type PublicSettings struct { PasswordResetEnabled bool InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool LoginAgreementEnabled bool LoginAgreementMode string LoginAgreementUpdatedAt string From 357c5b917b18dc6ffcf16eb7e0aa14ee6af26abc Mon Sep 17 00:00:00 2001 From: Zhixuan Jiang Date: Sun, 26 Jul 2026 11:07:12 -0400 Subject: [PATCH 3/5] feat: add passkey sign-in settings control --- .../internal/handler/admin/setting_handler.go | 5 ++ .../handler/admin/setting_handler_audit.go | 3 + .../handler/admin/setting_handler_update.go | 18 +++++ backend/internal/handler/dto/settings.go | 10 ++- backend/internal/handler/passkey_handler.go | 28 +++++++ .../internal/handler/passkey_handler_test.go | 74 +++++++++++++++++++ backend/internal/service/domain_constants.go | 3 +- backend/internal/service/setting_features.go | 41 ++++++++++ backend/internal/service/setting_parse.go | 1 + backend/internal/service/setting_public.go | 3 +- .../service/setting_service_update_test.go | 29 ++++++++ backend/internal/service/setting_update.go | 1 + backend/internal/service/settings_view.go | 1 + frontend/src/api/admin/settings.ts | 5 ++ .../user/profile/ProfilePasskeyCard.vue | 13 ++-- .../src/i18n/locales/en/admin/settings.ts | 7 ++ .../src/i18n/locales/zh/admin/settings.ts | 7 ++ frontend/src/views/admin/SettingsView.vue | 61 +++++++++++++++ .../admin/__tests__/SettingsView.spec.ts | 45 +++++++++++ 19 files changed, 343 insertions(+), 12 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 8c33b783b1..34591c4ee5 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -123,6 +123,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { if paymentCfg == nil { paymentCfg = &service.PaymentConfig{} } + passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration() payload := dto.SystemSettings{ RegistrationEnabled: settings.RegistrationEnabled, @@ -134,6 +135,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + PasskeyEnabled: settings.PasskeyEnabled, + PasskeyConfigured: passkeyConfigured, + PasskeyRPID: passkeyRPID, + PasskeyRPOrigins: passkeyRPOrigins, SessionBindingEnabled: settings.SessionBindingEnabled, StepUpEnabled: settings.StepUpEnabled, AuditLogRetentionDays: settings.AuditLogRetentionDays, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index c8b8bf1b24..7e59baf3f4 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -56,6 +56,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.TotpEnabled != after.TotpEnabled { changed = append(changed, "totp_enabled") } + if before.PasskeyEnabled != after.PasskeyEnabled { + changed = append(changed, "passkey_enabled") + } if before.SessionBindingEnabled != after.SessionBindingEnabled { changed = append(changed, "session_binding_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index f852bb7e6f..a517ddfc2b 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -28,6 +28,7 @@ type UpdateSettingsRequest struct { FrontendURL string `json:"frontend_url"` InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值) SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值) StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值) AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 @@ -403,6 +404,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if req.StepUpEnabled != nil { stepUpEnabled = *req.StepUpEnabled } + passkeyEnabled := previousSettings.PasskeyEnabled + if req.PasskeyEnabled != nil { + passkeyEnabled = *req.PasskeyEnabled + } + if passkeyEnabled { + configured, _, _ := h.settingService.PasskeyConfiguration() + if !configured { + response.BadRequest(c, "Passkey sign-in requires a valid WebAuthn RP ID and allowed HTTPS origins in the deployment configuration") + return + } + } forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...) if req.ForwardedClientIPHeaders != nil { forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...) @@ -1253,6 +1265,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { FrontendURL: req.FrontendURL, InvitationCodeEnabled: req.InvitationCodeEnabled, TotpEnabled: req.TotpEnabled, + PasskeyEnabled: passkeyEnabled, SessionBindingEnabled: sessionBindingEnabled, StepUpEnabled: stepUpEnabled, AuditLogRetentionDays: req.AuditLogRetentionDays, @@ -1776,6 +1789,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if updatedPaymentCfg == nil { updatedPaymentCfg = &service.PaymentConfig{} } + passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration() payload := dto.SystemSettings{ RegistrationEnabled: updatedSettings.RegistrationEnabled, @@ -1787,6 +1801,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, TotpEnabled: updatedSettings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + PasskeyEnabled: updatedSettings.PasskeyEnabled, + PasskeyConfigured: passkeyConfigured, + PasskeyRPID: passkeyRPID, + PasskeyRPOrigins: passkeyRPOrigins, SessionBindingEnabled: updatedSettings.SessionBindingEnabled, StepUpEnabled: updatedSettings.StepUpEnabled, AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 2931228dc5..3afe48064b 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -36,9 +36,13 @@ type SystemSettings struct { InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 - SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 - StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA - AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 + PasskeyEnabled bool `json:"passkey_enabled"` + PasskeyConfigured bool `json:"passkey_configured"` + PasskeyRPID string `json:"passkey_rp_id"` + PasskeyRPOrigins []string `json:"passkey_rp_origins"` + SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 + StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA + AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index 9ffc920e0d..c5521ebdd1 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -53,6 +53,9 @@ const passkeyFinishBodyMaxBytes = 64 * 1024 // BeginLogin starts a usernameless, discoverable-credential login ceremony. func (h *PasskeyHandler) BeginLogin(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } assertion, token, err := h.passkeys.BeginLogin(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -66,6 +69,9 @@ func (h *PasskeyHandler) BeginLogin(c *gin.Context) { // already supplies phishing-resistant multi-factor authentication and does not // enter the separate TOTP challenge flow. func (h *PasskeyHandler) FinishLogin(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } req, ok := bindPasskeyFinishRequest(c) if !ok { return @@ -87,6 +93,9 @@ func (h *PasskeyHandler) FinishLogin(c *gin.Context) { } func (h *PasskeyHandler) BeginRegistration(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") @@ -101,6 +110,9 @@ func (h *PasskeyHandler) BeginRegistration(c *gin.Context) { } func (h *PasskeyHandler) FinishRegistration(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") @@ -168,6 +180,22 @@ func (h *PasskeyHandler) Delete(c *gin.Context) { response.Success(c, gin.H{"success": true}) } +func (h *PasskeyHandler) requirePasskeysEnabled(c *gin.Context) bool { + if h.settingSvc == nil { + response.ErrorFrom(c, service.ErrPasskeysDisabled) + return false + } + enabled, err := h.settingSvc.PasskeyEnabled(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return false + } + if !enabled { + response.ErrorFrom(c, service.ErrPasskeysDisabled) + } + return enabled +} + func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error { if err := ensureLoginUserActive(user); err != nil { return err diff --git a/backend/internal/handler/passkey_handler_test.go b/backend/internal/handler/passkey_handler_test.go index 0197ffa05d..0d3b57fae4 100644 --- a/backend/internal/handler/passkey_handler_test.go +++ b/backend/internal/handler/passkey_handler_test.go @@ -1,15 +1,42 @@ package handler import ( + "context" + "errors" "net/http" "net/http/httptest" "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +type passkeySwitchSettingRepo struct { + value string + err error +} + +func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) { + return nil, service.ErrSettingNotFound +} +func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, error) { + return r.value, r.err +} +func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil } +func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error { + return nil +} +func (r *passkeySwitchSettingRepo) GetAll(context.Context) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *passkeySwitchSettingRepo) Delete(context.Context, string) error { return nil } + func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() @@ -25,3 +52,50 @@ func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) { require.False(t, ok) require.Equal(t, http.StatusBadRequest, recorder.Code) } + +func TestPasskeyBeginLoginRejectsDisabledAdminSwitch(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &passkeySwitchSettingRepo{value: "false"} + settings := service.NewSettingService(repo, &config.Config{ + WebAuthn: config.WebAuthnConfig{Enabled: true}, + }) + handler := NewPasskeyHandler(nil, nil, settings) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Contains(t, recorder.Body.String(), "PASSKEY_DISABLED") +} + +func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + settings := service.NewSettingService( + &passkeySwitchSettingRepo{err: errors.New("database unavailable")}, + &config.Config{WebAuthn: config.WebAuthnConfig{Enabled: true}}, + ) + handler := NewPasskeyHandler(nil, nil, settings) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusInternalServerError, recorder.Code) + require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED") +} + +func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := NewPasskeyHandler(nil, nil, nil) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/passkeys", nil) + + handler.List(ginContext) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED") +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 755790f0ef..a42f535697 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -170,7 +170,8 @@ const ( settingKeyForwardedClientIPModeV2 = "forwarded_client_ip_mode_v2_migrated" // TOTP 双因素认证设置 - SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 + SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 + SettingKeyPasskeyEnabled = "passkey_enabled" // 是否启用 Passkey 登录(仍要求有效的 WebAuthn 部署配置) // 会话安全设置 SettingKeySessionBindingEnabled = "session_binding_enabled" // 会话 IP/UA 绑定(变更即失效),默认关闭 diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 52ba3670ff..c2beefe76a 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -173,6 +173,47 @@ func (s *SettingService) IsTotpEnabled(ctx context.Context) bool { return value == "true" } +// PasskeyEnabled reports the effective runtime switch. WebAuthn deployment +// configuration remains the security boundary; the database setting can only +// disable a valid configured relying party, never replace or weaken it. +func (s *SettingService) PasskeyEnabled(ctx context.Context) (bool, error) { + if !s.passkeyConfigured() { + return false, nil + } + value, err := s.settingRepo.GetValue(ctx, SettingKeyPasskeyEnabled) + if errors.Is(err, ErrSettingNotFound) { + return true, nil // preserve enabled deployments when upgrading from config-only releases + } + if err != nil { + return false, fmt.Errorf("read passkey setting: %w", err) + } + return value == "true", nil +} + +// PasskeyConfiguration returns non-secret relying-party configuration for the +// admin status UI. Enabled configurations have already passed Config.Validate. +func (s *SettingService) PasskeyConfiguration() (configured bool, rpID string, origins []string) { + if s == nil || s.cfg == nil { + return false, "", []string{} + } + origins = append([]string{}, s.cfg.WebAuthn.RPOrigins...) + return s.cfg.WebAuthn.Enabled, + strings.TrimSpace(s.cfg.WebAuthn.RPID), + origins +} + +func (s *SettingService) passkeyConfigured() bool { + return s != nil && s.cfg != nil && s.cfg.WebAuthn.Enabled +} + +func (s *SettingService) passkeySettingEnabled(settings map[string]string) bool { + value, ok := settings[SettingKeyPasskeyEnabled] + if !ok { + return s.passkeyConfigured() + } + return value == "true" +} + // IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置 // 只有手动配置了密钥才允许在管理后台启用 TOTP 功能 func (s *SettingService) IsTotpEncryptionKeyConfigured() bool { diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index adc1195c04..40e528291e 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -299,6 +299,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin FrontendURL: settings[SettingKeyFrontendURL], InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + PasskeyEnabled: s.passkeySettingEnabled(settings), SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] == "true", // 默认关闭 StepUpEnabled: settings[SettingKeyStepUpEnabled] == "true", // 默认关闭 AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 4ee4964b6e..0a13f8b18c 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -164,6 +164,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyPasswordResetEnabled, SettingKeyInvitationCodeEnabled, SettingKeyTotpEnabled, + SettingKeyPasskeyEnabled, SettingKeyLoginAgreementEnabled, SettingKeyLoginAgreementMode, SettingKeyLoginAgreementUpdatedAt, @@ -289,7 +290,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PasswordResetEnabled: passwordResetEnabled, InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - PasskeyEnabled: s.cfg != nil && s.cfg.WebAuthn.Enabled, + PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings), LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), LoginAgreementUpdatedAt: loginAgreementUpdatedAt, diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 3a4050f5b2..e3cbddc09f 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -850,3 +850,32 @@ func TestSettingService_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource( require.Equal(t, "INVALID_PAYMENT_VISIBLE_METHOD_SOURCE", infraerrors.Reason(err)) require.Nil(t, repo.updates) } + +func TestSettingService_PasskeySwitchPersistsAndDefaultsToConfigured(t *testing.T) { + cfg := &config.Config{WebAuthn: config.WebAuthnConfig{ + Enabled: true, + RPID: "sub3.nebula-spaces.com", + RPOrigins: []string{"https://sub3.nebula-spaces.com"}, + }} + runtimeRepo := &forwardedIPMigrationRepoStub{values: map[string]string{}} + runtimeService := NewSettingService(runtimeRepo, cfg) + + enabled, err := runtimeService.PasskeyEnabled(context.Background()) + require.NoError(t, err) + require.True(t, enabled) + + updateRepo := &settingUpdateRepoStub{} + updateService := NewSettingService(updateRepo, cfg) + require.NoError(t, updateService.UpdateSettings(context.Background(), &SystemSettings{ + PasskeyEnabled: false, + })) + require.Equal(t, "false", updateRepo.updates[SettingKeyPasskeyEnabled]) + + runtimeRepo.values[SettingKeyPasskeyEnabled] = "false" + enabled, err = runtimeService.PasskeyEnabled(context.Background()) + require.NoError(t, err) + require.False(t, enabled) + publicSettings, err := runtimeService.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, publicSettings.PasskeyEnabled) +} diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 7449a3de69..ab26fc494a 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -127,6 +127,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyFrontendURL] = settings.FrontendURL updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) + updates[SettingKeyPasskeyEnabled] = strconv.FormatBool(settings.PasskeyEnabled) updates[SettingKeySessionBindingEnabled] = strconv.FormatBool(settings.SessionBindingEnabled) updates[SettingKeyStepUpEnabled] = strconv.FormatBool(settings.StepUpEnabled) updates[SettingKeyAuditLogRetentionDays] = strconv.Itoa(settings.AuditLogRetentionDays) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index e4d9096607..bc88e49118 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -20,6 +20,7 @@ type SystemSettings struct { FrontendURL string InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool // Passkey 登录 SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效) StepUpEnabled bool // 敏感操作 step-up 2FA 门控 AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 8861ce56bd..f48b4b7587 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -366,6 +366,10 @@ export interface SystemSettings { invitation_code_enabled: boolean; totp_enabled: boolean; // TOTP 双因素认证 totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置 + passkey_enabled: boolean; + passkey_configured: boolean; + passkey_rp_id: string; + passkey_rp_origins: string[]; session_binding_enabled: boolean; // 会话 IP/UA 绑定 step_up_enabled: boolean; // 敏感操作 step-up 2FA audit_log_retention_days: number; // 审计日志保留天数 @@ -674,6 +678,7 @@ export interface UpdateSettingsRequest { frontend_url?: string; invitation_code_enabled?: boolean; totp_enabled?: boolean; // TOTP 双因素认证 + passkey_enabled?: boolean; session_binding_enabled?: boolean; // 会话 IP/UA 绑定 step_up_enabled?: boolean; // 敏感操作 step-up 2FA audit_log_retention_days?: number; // 审计日志保留天数 diff --git a/frontend/src/components/user/profile/ProfilePasskeyCard.vue b/frontend/src/components/user/profile/ProfilePasskeyCard.vue index cefed6d6e6..8bdcc1c6a9 100644 --- a/frontend/src/components/user/profile/ProfilePasskeyCard.vue +++ b/frontend/src/components/user/profile/ProfilePasskeyCard.vue @@ -21,15 +21,15 @@
-
+
{{ t('profile.passkey.featureDisabled') }}
-
+
{{ t('profile.passkey.unsupported') }}
-
+
@@ -135,7 +135,6 @@ const newName = ref('') const credentials = ref([]) async function loadCredentials(): Promise { - if (!supported) return loading.value = true try { credentials.value = await passkeyAPI.list() @@ -209,8 +208,8 @@ function formatDate(value: string): string { watch( () => props.enabled, - (enabled) => { - if (enabled) void loadCredentials() + () => { + void loadCredentials() }, { immediate: true } ) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 8bc1a39cfc..262f45c9dd 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -127,6 +127,13 @@ export default { 'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32' }, security: { + passkey: 'Passkey Sign-in', + passkeyHint: 'Allow passwordless sign-in and user-managed passkeys when the relying party configuration is valid.', + passkeyConfigured: 'WebAuthn relying party configuration is valid.', + passkeyNotConfigured: 'Configure a valid RP ID and allowed HTTPS origins before enabling passkey sign-in.', + passkeyRPID: 'RP ID', + passkeyOrigins: 'Allowed HTTPS origins', + passkeyValueNotConfigured: 'Not configured', stepUp: 'Step-up 2FA for Sensitive Operations', stepUpHint: 'When enabled, sensitive operations (account/proxy export, backup creation and download, S3 config changes, promoting admins) require a recent TOTP verification (valid for 15 minutes). Your own account must have 2FA enabled before turning this on; turning it off also requires step-up verification.', stepUpEnableRequiresTotp: 'Enable 2FA (TOTP) for your own account in Profile before turning on step-up verification.', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index dccf073c14..d9c5c702de 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -127,6 +127,13 @@ export default { '请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。' }, security: { + passkey: 'Passkey 登录', + passkeyHint: '当依赖方配置有效时,允许无密码登录及用户自行管理 Passkey。', + passkeyConfigured: 'WebAuthn 依赖方配置有效。', + passkeyNotConfigured: '请先配置有效的 RP ID 与允许的 HTTPS 来源,再启用 Passkey 登录。', + passkeyRPID: 'RP ID', + passkeyOrigins: '允许的 HTTPS 来源', + passkeyValueNotConfigured: '未配置', stepUp: '敏感操作二次验证 (step-up 2FA)', stepUpHint: '开启后,账号/代理导出、备份创建与下载、S3 配置修改、提升管理员等敏感操作需要先完成 TOTP 二次验证(15 分钟内有效)。开启前需本人已启用 2FA;关闭该开关本身也需要二次验证。', stepUpEnableRequiresTotp: '开启敏感操作二次验证前,请先在个人资料中为当前账号启用 2FA (TOTP)。', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 120a631740..6e82b60034 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1578,6 +1578,62 @@ />
+ +
+
+
+ +

+ {{ t("admin.settings.security.passkeyHint") }} +

+
+ +
+
+

+ {{ + form.passkey_configured + ? t("admin.settings.security.passkeyConfigured") + : t("admin.settings.security.passkeyNotConfigured") + }} +

+

+ {{ t("admin.settings.security.passkeyRPID") }}: + {{ + form.passkey_rp_id || + t("admin.settings.security.passkeyValueNotConfigured") + }} +

+

+ {{ t("admin.settings.security.passkeyOrigins") }}: + {{ + form.passkey_rp_origins.length > 0 + ? form.passkey_rp_origins.join(", ") + : t( + "admin.settings.security.passkeyValueNotConfigured", + ) + }} +

+
+
+
({ password_reset_enabled: false, totp_enabled: false, totp_encryption_key_configured: false, + passkey_enabled: false, + passkey_configured: false, + passkey_rp_id: "", + passkey_rp_origins: [], session_binding_enabled: false, step_up_enabled: false, audit_log_retention_days: 180, @@ -10010,6 +10070,7 @@ async function saveSettings() { invitation_code_enabled: form.invitation_code_enabled, password_reset_enabled: form.password_reset_enabled, totp_enabled: form.totp_enabled, + passkey_enabled: form.passkey_enabled, session_binding_enabled: form.session_binding_enabled, step_up_enabled: form.step_up_enabled, // 清空数字框时 v-model.number 会得到空串,后端 int 字段解析空串会 400 拒绝整次保存; diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 76ecc6c6b5..e87db335c9 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -350,6 +350,10 @@ const baseSettingsResponse = { password_reset_enabled: false, totp_enabled: false, totp_encryption_key_configured: false, + passkey_enabled: true, + passkey_configured: true, + passkey_rp_id: "sub3.nebula-spaces.com", + passkey_rp_origins: ["https://sub3.nebula-spaces.com"], default_balance: 0, default_concurrency: 1, default_subscriptions: [], @@ -672,6 +676,47 @@ describe("admin SettingsView payment visible method controls", () => { expect(wrapper.text()).not.toContain("支付来源"); }); + it("shows valid passkey RP configuration and persists the sign-in toggle", async () => { + const wrapper = mountView(); + + await flushPromises(); + await openSecurityTab(wrapper); + + const settings = wrapper.get('[data-testid="passkey-settings"]'); + const toggle = settings.get('[data-testid="passkey-toggle"]'); + expect(toggle.attributes("disabled")).toBeUndefined(); + expect(settings.text()).toContain("sub3.nebula-spaces.com"); + expect(settings.text()).toContain("https://sub3.nebula-spaces.com"); + + await toggle.setValue(false); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ passkey_enabled: false }), + ); + }); + + it("disables passkey sign-in when the RP configuration is unavailable", async () => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + passkey_enabled: false, + passkey_configured: false, + passkey_rp_id: "", + passkey_rp_origins: [], + }); + const wrapper = mountView(); + + await flushPromises(); + await openSecurityTab(wrapper); + + const settings = wrapper.get('[data-testid="passkey-settings"]'); + expect(settings.get('[data-testid="passkey-toggle"]').attributes("disabled")).toBeDefined(); + expect(settings.get('[data-testid="passkey-config-status"]').text()).toContain( + "admin.settings.security.passkeyNotConfigured", + ); + }); + it("loads, edits, validates, and saves forwarded client-IP headers", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, From 97f44b21bb762bb8938afa5410734cc3828fba2b Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 28 Jul 2026 11:45:18 +0800 Subject: [PATCH 4/5] fix: keep passkey switch coupled to WebAuthn config and fix CI issues - parseSettings now reports passkey_enabled=false whenever the WebAuthn deployment config is absent: a stale "true" row left behind after the config is removed previously made the admin update gate reject every settings save while the UI toggle was disabled, leaving no recovery path from the admin panel. Added a regression test. - update the admin settings API contract goldens with the new passkey_enabled/passkey_configured/passkey_rp_id/passkey_rp_origins fields. - errcheck: check rows.Close in passkey repository (repo convention). - staticcheck QF1001: apply De Morgan's law in WebAuthn origin scheme validation. --- backend/internal/config/config.go | 2 +- backend/internal/repository/passkey_repo.go | 2 +- backend/internal/server/api_contract_test.go | 8 ++++++++ backend/internal/service/setting_features.go | 10 ++++++++-- .../service/setting_service_update_test.go | 14 ++++++++++++++ 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4ac0d57ef7..a97299aea9 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2634,7 +2634,7 @@ func (c *Config) Validate() error { u.Host = strings.ToLower(u.Host) host := strings.ToLower(u.Hostname()) localDevelopment := host == "localhost" || host == "127.0.0.1" || host == "::1" - if u.Scheme != "https" && !(u.Scheme == "http" && localDevelopment) { + if u.Scheme != "https" && (u.Scheme != "http" || !localDevelopment) { return fmt.Errorf("webauthn.rp_origins entry %q must use HTTPS (HTTP is allowed only for localhost)", origin) } if host != c.WebAuthn.RPID && !strings.HasSuffix(host, "."+c.WebAuthn.RPID) { diff --git a/backend/internal/repository/passkey_repo.go b/backend/internal/repository/passkey_repo.go index f834b255dd..d46124e778 100644 --- a/backend/internal/repository/passkey_repo.go +++ b/backend/internal/repository/passkey_repo.go @@ -92,7 +92,7 @@ func (r *passkeyRepository) ListByUserID( if err != nil { return nil, fmt.Errorf("list passkey credentials: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() records := make([]service.PasskeyCredentialRecord, 0) for rows.Next() { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 06c738cd3c..bbbc25dfd6 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -711,6 +711,10 @@ func TestAPIContracts(t *testing.T) { "frontend_url": "", "totp_enabled": false, "totp_encryption_key_configured": false, + "passkey_enabled": false, + "passkey_configured": false, + "passkey_rp_id": "", + "passkey_rp_origins": [], "session_binding_enabled": false, "step_up_enabled": false, "audit_log_retention_days": 180, @@ -1028,6 +1032,10 @@ func TestAPIContracts(t *testing.T) { "invitation_code_enabled": false, "totp_enabled": false, "totp_encryption_key_configured": false, + "passkey_enabled": false, + "passkey_configured": false, + "passkey_rp_id": "", + "passkey_rp_origins": [], "session_binding_enabled": false, "step_up_enabled": false, "audit_log_retention_days": 180, diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index c2beefe76a..50a3fbcfd8 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -182,7 +182,7 @@ func (s *SettingService) PasskeyEnabled(ctx context.Context) (bool, error) { } value, err := s.settingRepo.GetValue(ctx, SettingKeyPasskeyEnabled) if errors.Is(err, ErrSettingNotFound) { - return true, nil // preserve enabled deployments when upgrading from config-only releases + return true, nil // configured deployments default to enabled until the admin persists the switch } if err != nil { return false, fmt.Errorf("read passkey setting: %w", err) @@ -206,10 +206,16 @@ func (s *SettingService) passkeyConfigured() bool { return s != nil && s.cfg != nil && s.cfg.WebAuthn.Enabled } +// passkeySettingEnabled must stay ANDed with passkeyConfigured: a stale +// "true" row after the WebAuthn config is removed would otherwise make the +// admin update gate reject every settings save while the UI toggle is locked. func (s *SettingService) passkeySettingEnabled(settings map[string]string) bool { + if !s.passkeyConfigured() { + return false + } value, ok := settings[SettingKeyPasskeyEnabled] if !ok { - return s.passkeyConfigured() + return true } return value == "true" } diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index e3cbddc09f..b689f56781 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -879,3 +879,17 @@ func TestSettingService_PasskeySwitchPersistsAndDefaultsToConfigured(t *testing. require.NoError(t, err) require.False(t, publicSettings.PasskeyEnabled) } + +// 移除 WebAuthn 配置后,残留的 passkey_enabled="true" 不得再让 GetAllSettings +// 报告开关开启:admin 更新门控以此为准,一旦误报为 true 会拒绝所有设置保存, +// 而此时前端开关处于禁用态,管理员无法在 UI 里自救。 +func TestSettingService_StalePasskeyTrueWithoutConfigReportsDisabled(t *testing.T) { + repo := &settingGetAllRepoStub{values: map[string]string{ + SettingKeyPasskeyEnabled: "true", + }} + service := NewSettingService(repo, &config.Config{}) + + settings, err := service.GetAllSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.PasskeyEnabled) +} From 38ef8dc0696f90f4d1416b1f08d543c064cf05df Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 28 Jul 2026 14:12:46 +0800 Subject: [PATCH 5/5] 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. --- backend/internal/handler/passkey_handler.go | 18 ++- backend/internal/service/passkey.go | 27 +++- backend/internal/service/passkey_test.go | 83 ++++++++++++ frontend/src/api/__tests__/passkey.spec.ts | 67 +++++++++- frontend/src/api/passkey.ts | 9 +- .../user/profile/ProfilePasskeyCard.vue | 125 +++++++++++++++--- frontend/src/i18n/locales/en/dashboard.ts | 2 + frontend/src/i18n/locales/zh/dashboard.ts | 2 + 8 files changed, 302 insertions(+), 31 deletions(-) diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index c5521ebdd1..8ddb16e4b3 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -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 } diff --git a/backend/internal/service/passkey.go b/backend/internal/service/passkey.go index 7458d2f7b1..a8776a831a 100644 --- a/backend/internal/service/passkey.go +++ b/backend/internal/service/passkey.go @@ -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) } diff --git a/backend/internal/service/passkey_test.go b/backend/internal/service/passkey_test.go index c5f8ff3cf0..53f4093228 100644 --- a/backend/internal/service/passkey_test.go +++ b/backend/internal/service/passkey_test.go @@ -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) +} diff --git a/frontend/src/api/__tests__/passkey.spec.ts b/frontend/src/api/__tests__/passkey.spec.ts index 40e058aa35..fe32c1c7cd 100644 --- a/frontend/src/api/__tests__/passkey.spec.ts +++ b/frontend/src/api/__tests__/passkey.spec.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { get, post, patch, remove, credentialGet } = vi.hoisted(() => ({ +const { get, post, patch, remove, credentialGet, credentialCreate } = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), patch: vi.fn(), remove: vi.fn(), - credentialGet: vi.fn() + credentialGet: vi.fn(), + credentialCreate: vi.fn() })) vi.mock('@/api/client', () => ({ @@ -24,7 +25,7 @@ class FakePublicKeyCredential { rawId = Uint8Array.from([1, 2, 3]).buffer type = 'public-key' authenticatorAttachment = 'platform' - response = { + response: Record = { authenticatorData: Uint8Array.from([4, 5]).buffer, clientDataJSON: Uint8Array.from([6, 7]).buffer, signature: Uint8Array.from([8, 9]).buffer, @@ -36,6 +37,17 @@ class FakePublicKeyCredential { } } +class FakeRegistrationCredential extends FakePublicKeyCredential { + constructor() { + super() + this.response = { + attestationObject: Uint8Array.from([12, 13]).buffer, + clientDataJSON: Uint8Array.from([6, 7]).buffer, + getTransports: () => ['internal'] + } + } +} + describe('passkey api', () => { beforeEach(() => { get.mockReset() @@ -43,6 +55,7 @@ describe('passkey api', () => { patch.mockReset() remove.mockReset() credentialGet.mockReset() + credentialCreate.mockReset() vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential) Object.defineProperty(window, 'PublicKeyCredential', { @@ -51,7 +64,7 @@ describe('passkey api', () => { }) Object.defineProperty(navigator, 'credentials', { configurable: true, - value: { get: credentialGet } + value: { get: credentialGet, create: credentialCreate } }) }) @@ -105,4 +118,50 @@ describe('passkey api', () => { } }) }) + + it('sends the account password when beginning registration', async () => { + post + .mockResolvedValueOnce({ + data: { + session_token: 'register-session', + options: { + publicKey: { + challenge: 'AQID', + user: { id: 'BAU', name: 'user@example.com', displayName: 'user' } + } + } + } + }) + .mockResolvedValueOnce({ + data: { id: 3, name: 'Laptop', created_at: '2026-07-28T00:00:00Z', backup: false } + }) + credentialCreate.mockResolvedValue(new FakeRegistrationCredential()) + + await passkeyAPI.register('Laptop', 'hunter2') + + expect(post).toHaveBeenNthCalledWith(1, '/user/passkeys/register/begin', { + password: 'hunter2' + }) + expect(post).toHaveBeenNthCalledWith(2, '/user/passkeys/register/finish', { + session_token: 'register-session', + name: 'Laptop', + credential: expect.objectContaining({ + response: { + attestationObject: 'DA0', + clientDataJSON: 'Bgc', + transports: ['internal'] + } + }) + }) + }) + + it('sends the account password when revoking a credential', async () => { + remove.mockResolvedValue({ data: null }) + + await passkeyAPI.remove(12, 'hunter2') + + expect(remove).toHaveBeenCalledWith('/user/passkeys/12', { + data: { password: 'hunter2' } + }) + }) }) diff --git a/frontend/src/api/passkey.ts b/frontend/src/api/passkey.ts index c082f3d98a..fdfe128a21 100644 --- a/frontend/src/api/passkey.ts +++ b/frontend/src/api/passkey.ts @@ -122,10 +122,11 @@ async function login(): Promise { return data } -async function register(name: string): Promise { +async function register(name: string, password: string): Promise { requirePasskeySupport() const { data: begin } = await apiClient.post( - '/user/passkeys/register/begin' + '/user/passkeys/register/begin', + { password } ) const credential = await navigator.credentials.create({ publicKey: creationOptionsFromJSON(begin.options.publicKey) @@ -153,8 +154,8 @@ async function rename(id: number, name: string): Promise { await apiClient.patch(`/user/passkeys/${id}`, { name }) } -async function remove(id: number): Promise { - await apiClient.delete(`/user/passkeys/${id}`) +async function remove(id: number, password: string): Promise { + await apiClient.delete(`/user/passkeys/${id}`, { data: { password } }) } export const passkeyAPI = { diff --git a/frontend/src/components/user/profile/ProfilePasskeyCard.vue b/frontend/src/components/user/profile/ProfilePasskeyCard.vue index 8bdcc1c6a9..0b60113d89 100644 --- a/frontend/src/components/user/profile/ProfilePasskeyCard.vue +++ b/frontend/src/components/user/profile/ProfilePasskeyCard.vue @@ -30,25 +30,40 @@
-
- - +
+
+ + +
+
+ + +
-
+
-
@@ -113,6 +128,51 @@
+ + +
+
+
+
+

+ {{ t('profile.passkey.deleteTitle') }} +

+

+ {{ t('profile.passkey.deleteConfirm', { name: deleteTarget.name }) }} +

+ +
+ + +
+
+ + +
+ +
+
+
@@ -132,8 +192,18 @@ const loading = ref(false) const busy = ref(false) const showAddForm = ref(false) const newName = ref('') +const newPassword = ref('') +const deleteTarget = ref(null) +const deletePassword = ref('') const credentials = ref([]) +// apiClient 拦截器把错误规范化为 { code, reason, message }; +// 透出后端消息(如密码错误),否则回退到通用文案。 +function extractErrorMessage(error: unknown, fallback: string): string { + const message = (error as { message?: string }).message + return typeof message === 'string' && message.length > 0 ? message : fallback +} + async function loadCredentials(): Promise { loading.value = true try { @@ -149,15 +219,16 @@ async function loadCredentials(): Promise { } async function addPasskey(): Promise { + if (newPassword.value.length === 0) return busy.value = true try { - await passkeyAPI.register(newName.value.trim()) + await passkeyAPI.register(newName.value.trim(), newPassword.value) appStore.showSuccess(t('profile.passkey.added')) cancelAdd() await loadCredentials() } catch (error) { if (!(error instanceof DOMException && error.name === 'NotAllowedError')) { - appStore.showError(t('profile.passkey.addFailed')) + appStore.showError(extractErrorMessage(error, t('profile.passkey.addFailed'))) } } finally { busy.value = false @@ -167,6 +238,7 @@ async function addPasskey(): Promise { function cancelAdd(): void { showAddForm.value = false newName.value = '' + newPassword.value = '' } async function renamePasskey(credential: PasskeyCredentialSummary): Promise { @@ -184,15 +256,28 @@ async function renamePasskey(credential: PasskeyCredentialSummary): Promise { - if (!window.confirm(t('profile.passkey.deleteConfirm', { name: credential.name }))) return +function deletePasskey(credential: PasskeyCredentialSummary): void { + deleteTarget.value = credential + deletePassword.value = '' +} + +function closeDeleteDialog(): void { + deleteTarget.value = null + deletePassword.value = '' +} + +async function confirmDelete(): Promise { + const credential = deleteTarget.value + if (!credential || deletePassword.value.length === 0) return busy.value = true try { - await passkeyAPI.remove(credential.id) + await passkeyAPI.remove(credential.id, deletePassword.value) credentials.value = credentials.value.filter((item) => item.id !== credential.id) appStore.showSuccess(t('profile.passkey.deleted')) - } catch { - appStore.showError(t('profile.passkey.deleteFailed')) + closeDeleteDialog() + } catch (error) { + // 密码错误等失败保持对话框打开,允许重试 + appStore.showError(extractErrorMessage(error, t('profile.passkey.deleteFailed'))) } finally { busy.value = false } diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 1812da6125..e462eff4f1 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -697,6 +697,7 @@ export default { continue: 'Create passkey', name: 'Passkey name', namePlaceholder: 'For example, MacBook Touch ID', + passwordPlaceholder: 'Enter your current password to confirm', empty: 'No passkeys are registered yet.', synced: 'Synced', createdAt: 'Created {date}', @@ -709,6 +710,7 @@ export default { renamePrompt: 'Enter a new name for this passkey', renamed: 'Passkey renamed.', renameFailed: 'Failed to rename passkey.', + deleteTitle: 'Delete passkey', deleteConfirm: 'Delete “{name}”? You will no longer be able to sign in with it.', deleted: 'Passkey deleted.', deleteFailed: 'Failed to delete passkey.' diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index a96e2a0c5b..60448420ea 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -702,6 +702,7 @@ export default { continue: '创建 Passkey', name: 'Passkey 名称', namePlaceholder: '例如:MacBook 触控 ID', + passwordPlaceholder: '输入当前登录密码以确认', empty: '尚未添加任何 Passkey。', synced: '已同步', createdAt: '创建于 {date}', @@ -714,6 +715,7 @@ export default { renamePrompt: '请输入新的 Passkey 名称', renamed: 'Passkey 已重命名。', renameFailed: '重命名 Passkey 失败。', + deleteTitle: '删除 Passkey', deleteConfirm: '删除“{name}”?删除后将无法再使用它登录。', deleted: 'Passkey 已删除。', deleteFailed: '删除 Passkey 失败。'