Merge pull request #4920 from alexj11324/feat/passkey-auth

feat: add passkey authentication
This commit is contained in:
Wesley Liddick
2026-07-28 14:58:37 +08:00
committed by GitHub
55 changed files with 2485 additions and 12 deletions
+8 -1
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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=
+55
View File
@@ -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")
}
+104
View File
@@ -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,
+6 -2
View File
@@ -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
+8 -3
View File
@@ -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"`
+1
View File
@@ -58,6 +58,7 @@ type Handlers struct {
OpenAIGateway *OpenAIGatewayHandler
Setting *SettingHandler
Totp *TotpHandler
Passkey *PasskeyHandler
Payment *PaymentHandler
PaymentWebhook *PaymentWebhookHandler
AvailableChannel *AvailableChannelHandler
+254
View File
@@ -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,
+3
View File
@@ -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,
+239
View File
@@ -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
}
+2
View File
@@ -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
}
+6
View File
@@ -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)
+9
View File
@@ -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管理
+1
View File
@@ -19,6 +19,7 @@ const (
// AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。
AuditAuthMethodJWT = "jwt"
AuditAuthMethodAdminAPIKey = "admin_api_key"
AuditAuthMethodPasskey = "passkey"
// auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。
auditRequestBodyMaxBytes = 16 * 1024
+2 -1
View File
@@ -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 绑定(变更即失效),默认关闭
+403
View File
@@ -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)
+112
View File
@@ -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
+1
View File
@@ -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);
+16
View File
@@ -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
# 运行模式配置
+167
View File
@@ -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' }
})
})
})
+5
View File
@@ -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; // 审计日志保留天数
+1
View File
@@ -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'
+168
View File
@@ -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.',
+4
View File
@@ -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',
+25
View File
@@ -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)。',
+4
View File
@@ -205,6 +205,10 @@ export default {
signInToAccount: '登录您的账户以继续',
signIn: '登录',
signingIn: '登录中...',
passkeySignIn: '使用 Passkey 登录',
passkeySigningIn: '正在等待 Passkey...',
passkeyCancelled: '已取消 Passkey 登录。',
passkeyFailed: 'Passkey 登录失败,请重试。',
createAccount: '创建账户',
signUpToStart: '注册以开始使用 {siteName}',
signUp: '注册',
+25
View File
@@ -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: '当账户余额低于阈值时发送邮件提醒',
+1
View File
@@ -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,
+13 -1
View File
@@ -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,
+1
View File
@@ -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
+61
View File
@@ -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,
+47 -2
View File
@@ -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> {
+4
View File
@@ -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)