mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 09:43:12 +08:00
Merge pull request #4920 from alexj11324/feat/passkey-auth
feat: add passkey authentication
This commit is contained in:
@@ -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)
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -58,6 +58,7 @@ type Handlers struct {
|
||||
OpenAIGateway *OpenAIGatewayHandler
|
||||
Setting *SettingHandler
|
||||
Totp *TotpHandler
|
||||
Passkey *PasskeyHandler
|
||||
Payment *PaymentHandler
|
||||
PaymentWebhook *PaymentWebhookHandler
|
||||
AvailableChannel *AvailableChannelHandler
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -88,6 +88,8 @@ var ProviderSet = wire.NewSet(
|
||||
NewSettingRepository,
|
||||
NewOpsRepository,
|
||||
NewAuditLogRepository,
|
||||
NewPasskeyRepository,
|
||||
NewPasskeySessionStore,
|
||||
NewUserSubscriptionRepository,
|
||||
NewUserAttributeDefinitionRepository,
|
||||
NewUserAttributeValueRepository,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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管理
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
// AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。
|
||||
AuditAuthMethodJWT = "jwt"
|
||||
AuditAuthMethodAdminAPIKey = "admin_api_key"
|
||||
AuditAuthMethodPasskey = "passkey"
|
||||
|
||||
// auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。
|
||||
auditRequestBodyMaxBytes = 16 * 1024
|
||||
|
||||
@@ -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 绑定(变更即失效),默认关闭
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -676,6 +676,7 @@ func ProvideAPIKeyService(
|
||||
var ProviderSet = wire.NewSet(
|
||||
// Core services
|
||||
NewAuthService,
|
||||
NewPasskeyService,
|
||||
NewUserService,
|
||||
ProvideAPIKeyService,
|
||||
ProvideAPIKeyAuthCacheInvalidator,
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
# 运行模式配置
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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; // 审计日志保留天数
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
): PublicKeyCredentialCreationOptions {
|
||||
const options = { ...value } as Record<string, unknown>
|
||||
options.challenge = base64URLToBuffer(String(options.challenge))
|
||||
|
||||
const user = { ...(options.user as Record<string, unknown>) }
|
||||
user.id = base64URLToBuffer(String(user.id))
|
||||
options.user = user
|
||||
|
||||
if (Array.isArray(options.excludeCredentials)) {
|
||||
options.excludeCredentials = options.excludeCredentials.map((descriptor) => ({
|
||||
...(descriptor as Record<string, unknown>),
|
||||
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
|
||||
}))
|
||||
}
|
||||
return options as unknown as PublicKeyCredentialCreationOptions
|
||||
}
|
||||
|
||||
function requestOptionsFromJSON(
|
||||
value: Record<string, unknown>
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
const options = { ...value } as Record<string, unknown>
|
||||
options.challenge = base64URLToBuffer(String(options.challenge))
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
options.allowCredentials = options.allowCredentials.map((descriptor) => ({
|
||||
...(descriptor as Record<string, unknown>),
|
||||
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
|
||||
}))
|
||||
}
|
||||
return options as unknown as PublicKeyCredentialRequestOptions
|
||||
}
|
||||
|
||||
function serializeRegistrationCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<AuthResponse> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
|
||||
'/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<AuthResponse>('/auth/passkey/login/finish', {
|
||||
session_token: begin.session_token,
|
||||
credential: serializeAssertionCredential(credential)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
async function register(name: string, password: string): Promise<PasskeyCredentialSummary> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
|
||||
'/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<PasskeyCredentialSummary>(
|
||||
'/user/passkeys/register/finish',
|
||||
{
|
||||
session_token: begin.session_token,
|
||||
name,
|
||||
credential: serializeRegistrationCredential(credential)
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
async function list(): Promise<PasskeyCredentialSummary[]> {
|
||||
const { data } = await apiClient.get<PasskeyCredentialSummary[]>('/user/passkeys')
|
||||
return data
|
||||
}
|
||||
|
||||
async function rename(id: number, name: string): Promise<void> {
|
||||
await apiClient.patch(`/user/passkeys/${id}`, { name })
|
||||
}
|
||||
|
||||
async function remove(id: number, password: string): Promise<void> {
|
||||
await apiClient.delete(`/user/passkeys/${id}`, { data: { password } })
|
||||
}
|
||||
|
||||
export const passkeyAPI = {
|
||||
isSupported: () => Boolean(window.PublicKeyCredential && navigator.credentials),
|
||||
login,
|
||||
register,
|
||||
list,
|
||||
rename,
|
||||
remove
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-start justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.passkey.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="enabled && supported && !showAddForm"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="busy"
|
||||
@click="showAddForm = true"
|
||||
>
|
||||
{{ t('profile.passkey.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-6">
|
||||
<div v-if="!enabled" class="mb-5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.featureDisabled') }}
|
||||
</div>
|
||||
<div v-if="enabled && !supported" class="mb-5 text-sm text-amber-600 dark:text-amber-400">
|
||||
{{ t('profile.passkey.unsupported') }}
|
||||
</div>
|
||||
<div>
|
||||
<form
|
||||
v-if="enabled && supported && showAddForm"
|
||||
class="mb-5 flex flex-col gap-3 rounded-lg border border-gray-200 p-4 dark:border-dark-700"
|
||||
@submit.prevent="addPasskey"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="passkey-name" class="input-label">{{ t('profile.passkey.name') }}</label>
|
||||
<input
|
||||
id="passkey-name"
|
||||
v-model="newName"
|
||||
class="input"
|
||||
maxlength="100"
|
||||
:placeholder="t('profile.passkey.namePlaceholder')"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="passkey-add-password" class="input-label">{{
|
||||
t('profile.currentPassword')
|
||||
}}</label>
|
||||
<input
|
||||
id="passkey-add-password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.passkey.passwordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" :disabled="busy" @click="cancelAdd">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="busy || newPassword.length === 0">
|
||||
{{ busy ? t('common.processing') : t('profile.passkey.continue') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-6">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="credentials.length === 0"
|
||||
class="rounded-lg border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400"
|
||||
>
|
||||
{{ t('profile.passkey.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<div
|
||||
v-for="credential in credentials"
|
||||
:key="credential.id"
|
||||
class="flex items-center justify-between gap-4 py-4 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="key" size="md" class="shrink-0 text-primary-500" />
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
{{ credential.name }}
|
||||
</p>
|
||||
<span
|
||||
v-if="credential.backup"
|
||||
class="rounded-full bg-green-50 px-2 py-0.5 text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300"
|
||||
>
|
||||
{{ t('profile.passkey.synced') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.createdAt', { date: formatDate(credential.created_at) }) }}
|
||||
<template v-if="credential.last_used_at">
|
||||
· {{ t('profile.passkey.lastUsed', { date: formatDate(credential.last_used_at) }) }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="busy"
|
||||
@click="renamePasskey(credential)"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30"
|
||||
:disabled="busy"
|
||||
@click="deletePasskey(credential)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认:吊销凭据需验证当前密码,防止被窃会话静默移除 Passkey -->
|
||||
<div v-if="deleteTarget" class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="closeDeleteDialog"></div>
|
||||
<div
|
||||
class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.passkey.deleteTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.deleteConfirm', { name: deleteTarget.name }) }}
|
||||
</p>
|
||||
<form class="mt-4 space-y-4" @submit.prevent="confirmDelete">
|
||||
<div>
|
||||
<label for="passkey-delete-password" class="input-label">{{
|
||||
t('profile.currentPassword')
|
||||
}}</label>
|
||||
<input
|
||||
id="passkey-delete-password"
|
||||
v-model="deletePassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.passkey.passwordPlaceholder')"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" :disabled="busy" @click="closeDeleteDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-danger"
|
||||
:disabled="busy || deletePassword.length === 0"
|
||||
>
|
||||
{{ busy ? t('common.processing') : t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { passkeyAPI, type PasskeyCredentialSummary } from '@/api'
|
||||
import { Icon } from '@/components/icons'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const props = defineProps<{ enabled: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const supported = passkeyAPI.isSupported()
|
||||
const loading = ref(false)
|
||||
const busy = ref(false)
|
||||
const showAddForm = ref(false)
|
||||
const newName = ref('')
|
||||
const newPassword = ref('')
|
||||
const deleteTarget = ref<PasskeyCredentialSummary | null>(null)
|
||||
const deletePassword = ref('')
|
||||
const credentials = ref<PasskeyCredentialSummary[]>([])
|
||||
|
||||
// apiClient 拦截器把错误规范化为 { code, reason, message };
|
||||
// 透出后端消息(如密码错误),否则回退到通用文案。
|
||||
function extractErrorMessage(error: unknown, fallback: string): string {
|
||||
const message = (error as { message?: string }).message
|
||||
return typeof message === 'string' && message.length > 0 ? message : fallback
|
||||
}
|
||||
|
||||
async function loadCredentials(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
credentials.value = await passkeyAPI.list()
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code
|
||||
if (code !== 'PASSKEY_DISABLED') {
|
||||
appStore.showError(t('profile.passkey.loadFailed'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addPasskey(): Promise<void> {
|
||||
if (newPassword.value.length === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
await passkeyAPI.register(newName.value.trim(), newPassword.value)
|
||||
appStore.showSuccess(t('profile.passkey.added'))
|
||||
cancelAdd()
|
||||
await loadCredentials()
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'NotAllowedError')) {
|
||||
appStore.showError(extractErrorMessage(error, t('profile.passkey.addFailed')))
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAdd(): void {
|
||||
showAddForm.value = false
|
||||
newName.value = ''
|
||||
newPassword.value = ''
|
||||
}
|
||||
|
||||
async function renamePasskey(credential: PasskeyCredentialSummary): Promise<void> {
|
||||
const name = window.prompt(t('profile.passkey.renamePrompt'), credential.name)?.trim()
|
||||
if (!name || name === credential.name) return
|
||||
busy.value = true
|
||||
try {
|
||||
await passkeyAPI.rename(credential.id, name)
|
||||
credential.name = name
|
||||
appStore.showSuccess(t('profile.passkey.renamed'))
|
||||
} catch {
|
||||
appStore.showError(t('profile.passkey.renameFailed'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function deletePasskey(credential: PasskeyCredentialSummary): void {
|
||||
deleteTarget.value = credential
|
||||
deletePassword.value = ''
|
||||
}
|
||||
|
||||
function closeDeleteDialog(): void {
|
||||
deleteTarget.value = null
|
||||
deletePassword.value = ''
|
||||
}
|
||||
|
||||
async function confirmDelete(): Promise<void> {
|
||||
const credential = deleteTarget.value
|
||||
if (!credential || deletePassword.value.length === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
await passkeyAPI.remove(credential.id, deletePassword.value)
|
||||
credentials.value = credentials.value.filter((item) => item.id !== credential.id)
|
||||
appStore.showSuccess(t('profile.passkey.deleted'))
|
||||
closeDeleteDialog()
|
||||
} catch (error) {
|
||||
// 密码错误等失败保持对话框打开,允许重试
|
||||
appStore.showError(extractErrorMessage(error, t('profile.passkey.deleteFailed')))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.enabled,
|
||||
() => {
|
||||
void loadCredentials()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)。',
|
||||
|
||||
@@ -205,6 +205,10 @@ export default {
|
||||
signInToAccount: '登录您的账户以继续',
|
||||
signIn: '登录',
|
||||
signingIn: '登录中...',
|
||||
passkeySignIn: '使用 Passkey 登录',
|
||||
passkeySigningIn: '正在等待 Passkey...',
|
||||
passkeyCancelled: '已取消 Passkey 登录。',
|
||||
passkeyFailed: 'Passkey 登录失败,请重试。',
|
||||
createAccount: '创建账户',
|
||||
signUpToStart: '注册以开始使用 {siteName}',
|
||||
signUp: '注册',
|
||||
|
||||
@@ -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: '当账户余额低于阈值时发送邮件提醒',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<User> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1578,6 +1578,62 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Passkey sign-in -->
|
||||
<div
|
||||
class="border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
data-testid="passkey-settings"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.security.passkey")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.security.passkeyHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.passkey_enabled"
|
||||
data-testid="passkey-toggle"
|
||||
:disabled="!form.passkey_configured"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-lg border px-3 py-2 text-sm"
|
||||
:class="
|
||||
form.passkey_configured
|
||||
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300'
|
||||
"
|
||||
data-testid="passkey-config-status"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{{
|
||||
form.passkey_configured
|
||||
? t("admin.settings.security.passkeyConfigured")
|
||||
: t("admin.settings.security.passkeyNotConfigured")
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-1 break-all">
|
||||
{{ t("admin.settings.security.passkeyRPID") }}:
|
||||
{{
|
||||
form.passkey_rp_id ||
|
||||
t("admin.settings.security.passkeyValueNotConfigured")
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-1 break-all">
|
||||
{{ t("admin.settings.security.passkeyOrigins") }}:
|
||||
{{
|
||||
form.passkey_rp_origins.length > 0
|
||||
? form.passkey_rp_origins.join(", ")
|
||||
: t(
|
||||
"admin.settings.security.passkeyValueNotConfigured",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 敏感操作 step-up 2FA -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
@@ -8699,6 +8755,10 @@ const form = reactive<SettingsForm>({
|
||||
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 拒绝整次保存;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
@open="showAgreementModal = true"
|
||||
/>
|
||||
|
||||
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
|
||||
<div v-if="showPasskeyLogin || showOAuthLogin" class="space-y-3 pt-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
@@ -140,6 +140,17 @@
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showPasskeyLogin"
|
||||
type="button"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="authActionDisabled"
|
||||
@click="handlePasskeyLogin"
|
||||
>
|
||||
<Icon name="key" size="md" class="mr-2" />
|
||||
{{ passkeyLoading ? t('auth.passkeySigningIn') : t('auth.passkeySignIn') }}
|
||||
</button>
|
||||
|
||||
<EmailOAuthButtons
|
||||
:disabled="authActionDisabled"
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
@@ -229,6 +240,7 @@ const appStore = useAppStore()
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const passkeyLoading = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
const showPassword = ref<boolean>(false)
|
||||
const publicSettingsLoaded = ref<boolean>(false)
|
||||
@@ -245,6 +257,7 @@ const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const githubOAuthEnabled = ref<boolean>(false)
|
||||
const googleOAuthEnabled = ref<boolean>(false)
|
||||
const passwordResetEnabled = ref<boolean>(false)
|
||||
const passkeyEnabled = ref<boolean>(false)
|
||||
const loginAgreementEnabled = ref<boolean>(false)
|
||||
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
|
||||
const loginAgreementUpdatedAt = ref<string>('')
|
||||
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin(): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
/>
|
||||
|
||||
<ProfileTotpCard />
|
||||
<ProfilePasskeyCard :enabled="passkeyEnabled" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -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<boolean | undefined>(undefined)
|
||||
const wechatOAuthMPEnabled = ref<boolean | undefined>(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)
|
||||
|
||||
Reference in New Issue
Block a user