diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 8ab1443a2a..5d101e4458 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 49a5d8da14..e57756f77a 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 0218ed057d..896cfa3be6 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= @@ -261,6 +273,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= @@ -356,6 +370,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= @@ -366,6 +382,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 cbb85ca439..46d5c60429 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 { @@ -1875,6 +1886,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{ @@ -2593,6 +2611,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/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 ec910b5256..3e48bd0d85 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -30,6 +30,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"` // 审计日志保留天数 @@ -463,6 +464,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)...) @@ -1313,6 +1325,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, @@ -1836,6 +1849,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, @@ -1847,6 +1861,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/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 25418b72cc..a16ca9d32c 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"` @@ -320,6 +324,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..8ddb16e4b3 --- /dev/null +++ b/backend/internal/handler/passkey_handler.go @@ -0,0 +1,254 @@ +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"` +} + +// 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. +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) + 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) { + if !h.requirePasskeysEnabled(c) { + return + } + 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 + } + 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) +} + +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") + return + } + creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID, bindPasskeyPassword(c)) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: creation}) +} + +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") + 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, bindPasskeyPassword(c)); err != nil { + response.ErrorFrom(c, err) + return + } + 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 + } + 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) { + 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") + 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/passkey_handler_test.go b/backend/internal/handler/passkey_handler_test.go new file mode 100644 index 0000000000..0d3b57fae4 --- /dev/null +++ b/backend/internal/handler/passkey_handler_test.go @@ -0,0 +1,101 @@ +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() + 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) +} + +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/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..d46124e778 --- /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 func() { _ = 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/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/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index 98cfff1b44..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, @@ -146,6 +147,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/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/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 fa29fa67d1..2b4b9bd31d 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -41,6 +41,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 ff562e1dc1..bcb2a8c917 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -61,6 +61,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/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/domain_constants.go b/backend/internal/service/domain_constants.go index f8a7359aec..24700c4c73 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/passkey.go b/backend/internal/service/passkey.go new file mode 100644 index 0000000000..a8776a831a --- /dev/null +++ b/backend/internal/service/passkey.go @@ -0,0 +1,403 @@ +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 +} + +// 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 + } + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, "", err + } + 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 { + 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, 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) +} + +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.BackupState, + } +} + +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..53f4093228 --- /dev/null +++ b/backend/internal/service/passkey_test.go @@ -0,0 +1,112 @@ +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" +) + +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) +} + +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) +} + +// 桩仅实现测试所需方法;未桩方法调用即 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/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 52ba3670ff..50a3fbcfd8 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -173,6 +173,53 @@ 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 // configured deployments default to enabled until the admin persists the switch + } + 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 +} + +// 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 true + } + 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 5bcc9e4c02..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,6 +290,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PasswordResetEnabled: passwordResetEnabled, InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings), LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), LoginAgreementUpdatedAt: loginAgreementUpdatedAt, @@ -446,6 +448,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 +518,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/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 3a4050f5b2..b689f56781 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -850,3 +850,46 @@ 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) +} + +// 移除 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) +} diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 2364b89484..2196b8a08f 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -174,6 +174,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 c775e2ee77..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 永久保留) @@ -284,6 +285,7 @@ type PublicSettings struct { PasswordResetEnabled bool InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool LoginAgreementEnabled bool LoginAgreementMode string LoginAgreementUpdatedAt string 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 f737ce5bba..e030ae3649 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..fe32c1c7cd --- /dev/null +++ b/frontend/src/api/__tests__/passkey.spec.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { get, post, patch, remove, credentialGet, credentialCreate } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + remove: vi.fn(), + credentialGet: vi.fn(), + credentialCreate: 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: Record = { + 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 {} + } +} + +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() + post.mockReset() + patch.mockReset() + remove.mockReset() + credentialGet.mockReset() + credentialCreate.mockReset() + + vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential) + Object.defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: FakePublicKeyCredential + }) + Object.defineProperty(navigator, 'credentials', { + configurable: true, + value: { get: credentialGet, create: credentialCreate } + }) + }) + + 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' + } + } + }) + }) + + 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/admin/settings.ts b/frontend/src/api/admin/settings.ts index a5b92f4e33..c6f88d290e 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/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..fdfe128a21 --- /dev/null +++ b/frontend/src/api/passkey.ts @@ -0,0 +1,168 @@ +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, password: string): Promise { + requirePasskeySupport() + const { data: begin } = await apiClient.post( + '/user/passkeys/register/begin', + { password } + ) + 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, password: string): Promise { + await apiClient.delete(`/user/passkeys/${id}`, { data: { password } }) +} + +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..0b60113d89 --- /dev/null +++ b/frontend/src/components/user/profile/ProfilePasskeyCard.vue @@ -0,0 +1,301 @@ + + + diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 0fba4cfb7a..7b21a23af2 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/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..e462eff4f1 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -690,6 +690,31 @@ 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', + passwordPlaceholder: 'Enter your current password to confirm', + 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.', + 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.' + }, balanceNotify: { title: 'Balance Low Notification', description: 'Send email alert when account balance falls below threshold', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 199d7a5be2..217b127752 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/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..60448420ea 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -695,6 +695,31 @@ export default { codeSent: '验证码已发送到您的邮箱', sendCodeFailed: '发送验证码失败' }, + passkey: { + title: 'Passkey', + description: '使用面容 ID、触控 ID、Windows Hello 或安全密钥免密码登录。', + add: '添加 Passkey', + continue: '创建 Passkey', + name: 'Passkey 名称', + namePlaceholder: '例如:MacBook 触控 ID', + passwordPlaceholder: '输入当前登录密码以确认', + empty: '尚未添加任何 Passkey。', + synced: '已同步', + createdAt: '创建于 {date}', + lastUsed: '上次使用 {date}', + featureDisabled: '管理员尚未配置 Passkey 功能。', + unsupported: '当前浏览器或设备不支持 Passkey。', + loadFailed: '加载 Passkey 失败。', + added: 'Passkey 已添加。', + addFailed: '添加 Passkey 失败。', + renamePrompt: '请输入新的 Passkey 名称', + renamed: 'Passkey 已重命名。', + renameFailed: '重命名 Passkey 失败。', + deleteTitle: '删除 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/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index ebb3a3a198..0245b74f93 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, @@ -10212,6 +10272,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 f00e2f9ab7..3222314683 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -362,6 +362,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: [], @@ -722,6 +726,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, 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)