mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
feat: add passkey authentication
This commit is contained in:
@@ -46,8 +46,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
return nil, err
|
||||
}
|
||||
userRepository := repository.NewUserRepository(client, db)
|
||||
passkeyRepository := repository.NewPasskeyRepository(db)
|
||||
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
||||
redisClient := repository.ProvideRedis(configConfig)
|
||||
passkeySessionStore := repository.NewPasskeySessionStore(redisClient)
|
||||
refreshTokenCache := repository.NewRefreshTokenCache(redisClient)
|
||||
settingRepository := repository.NewSettingRepository(client)
|
||||
groupRepository := repository.NewGroupRepository(client, db)
|
||||
@@ -79,6 +81,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
affiliateRepository := repository.NewAffiliateRepository(client, db)
|
||||
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
|
||||
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
|
||||
@@ -92,6 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
|
||||
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
|
||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService)
|
||||
passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService)
|
||||
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||
@@ -296,7 +303,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
|
||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||
|
||||
+9
-1
@@ -16,7 +16,8 @@ require (
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/dgraph-io/ristretto v0.2.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
@@ -97,6 +98,7 @@ require (
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
@@ -106,9 +108,12 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
|
||||
@@ -137,6 +142,7 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
@@ -158,10 +164,12 @@ require (
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zclconf/go-cty v1.14.4 // indirect
|
||||
|
||||
@@ -128,6 +128,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
@@ -153,16 +155,26 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
|
||||
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
@@ -267,6 +279,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -364,6 +378,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru4+3Q7Y=
|
||||
github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -374,6 +390,8 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
|
||||
@@ -73,6 +73,7 @@ type Config struct {
|
||||
Ops OpsConfig `mapstructure:"ops"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Totp TotpConfig `mapstructure:"totp"`
|
||||
WebAuthn WebAuthnConfig `mapstructure:"webauthn"`
|
||||
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
|
||||
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
|
||||
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
|
||||
@@ -686,6 +687,16 @@ type CORSConfig struct {
|
||||
AllowCredentials bool `mapstructure:"allow_credentials"`
|
||||
}
|
||||
|
||||
// WebAuthnConfig configures this deployment as a WebAuthn relying party.
|
||||
// RPID and RPOrigins are security boundaries and must never be inferred from
|
||||
// untrusted request Host or Origin headers.
|
||||
type WebAuthnConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
RPDisplayName string `mapstructure:"rp_display_name"`
|
||||
RPID string `mapstructure:"rp_id"`
|
||||
RPOrigins []string `mapstructure:"rp_origins"`
|
||||
}
|
||||
|
||||
const MaxForwardedClientIPHeaders = 16
|
||||
|
||||
type ForwardedClientIPSettings struct {
|
||||
@@ -1872,6 +1883,13 @@ func setDefaults() {
|
||||
viper.SetDefault("cors.allowed_origins", []string{})
|
||||
viper.SetDefault("cors.allow_credentials", true)
|
||||
|
||||
// WebAuthn / Passkeys are opt-in because every deployment must explicitly
|
||||
// declare its relying-party domain and trusted browser origins.
|
||||
viper.SetDefault("webauthn.enabled", false)
|
||||
viper.SetDefault("webauthn.rp_display_name", "Sub2API")
|
||||
viper.SetDefault("webauthn.rp_id", "")
|
||||
viper.SetDefault("webauthn.rp_origins", []string{})
|
||||
|
||||
// Security
|
||||
viper.SetDefault("security.url_allowlist.enabled", false)
|
||||
viper.SetDefault("security.url_allowlist.upstream_hosts", []string{
|
||||
@@ -2588,6 +2606,43 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
warnIfInsecureURL("server.frontend_url", c.Server.FrontendURL)
|
||||
}
|
||||
if c.WebAuthn.Enabled {
|
||||
c.WebAuthn.RPDisplayName = strings.TrimSpace(c.WebAuthn.RPDisplayName)
|
||||
c.WebAuthn.RPID = strings.ToLower(strings.TrimSpace(c.WebAuthn.RPID))
|
||||
c.WebAuthn.RPOrigins = normalizeStringSlice(c.WebAuthn.RPOrigins)
|
||||
if c.WebAuthn.RPDisplayName == "" {
|
||||
return fmt.Errorf("webauthn.rp_display_name is required when passkeys are enabled")
|
||||
}
|
||||
if c.WebAuthn.RPID == "" {
|
||||
return fmt.Errorf("webauthn.rp_id is required when passkeys are enabled")
|
||||
}
|
||||
if strings.Contains(c.WebAuthn.RPID, "://") || strings.ContainsAny(c.WebAuthn.RPID, "/:") {
|
||||
return fmt.Errorf("webauthn.rp_id must be a domain without scheme, port, or path")
|
||||
}
|
||||
if len(c.WebAuthn.RPOrigins) == 0 {
|
||||
return fmt.Errorf("webauthn.rp_origins must contain at least one origin when passkeys are enabled")
|
||||
}
|
||||
for i, origin := range c.WebAuthn.RPOrigins {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("webauthn.rp_origins contains invalid origin %q", origin)
|
||||
}
|
||||
if u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||
return fmt.Errorf("webauthn.rp_origins entry %q must not include userinfo, path, query, or fragment", origin)
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
host := strings.ToLower(u.Hostname())
|
||||
localDevelopment := host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
if u.Scheme != "https" && !(u.Scheme == "http" && localDevelopment) {
|
||||
return fmt.Errorf("webauthn.rp_origins entry %q must use HTTPS (HTTP is allowed only for localhost)", origin)
|
||||
}
|
||||
if host != c.WebAuthn.RPID && !strings.HasSuffix(host, "."+c.WebAuthn.RPID) {
|
||||
return fmt.Errorf("webauthn.rp_origins entry %q is not within relying party ID %q", origin, c.WebAuthn.RPID)
|
||||
}
|
||||
c.WebAuthn.RPOrigins[i] = u.Scheme + "://" + u.Host
|
||||
}
|
||||
}
|
||||
if c.JWT.ExpireHour <= 0 {
|
||||
return fmt.Errorf("jwt.expire_hour must be positive")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -320,6 +320,7 @@ type PublicSettings struct {
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
PasskeyEnabled bool `json:"passkey_enabled"`
|
||||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||
|
||||
@@ -58,6 +58,7 @@ type Handlers struct {
|
||||
OpenAIGateway *OpenAIGatewayHandler
|
||||
Setting *SettingHandler
|
||||
Totp *TotpHandler
|
||||
Passkey *PasskeyHandler
|
||||
Payment *PaymentHandler
|
||||
PaymentWebhook *PaymentWebhookHandler
|
||||
AvailableChannel *AvailableChannelHandler
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PasskeyHandler struct {
|
||||
passkeys *service.PasskeyService
|
||||
authService *service.AuthService
|
||||
settingSvc *service.SettingService
|
||||
}
|
||||
|
||||
func NewPasskeyHandler(
|
||||
passkeys *service.PasskeyService,
|
||||
authService *service.AuthService,
|
||||
settingService *service.SettingService,
|
||||
) *PasskeyHandler {
|
||||
return &PasskeyHandler{
|
||||
passkeys: passkeys,
|
||||
authService: authService,
|
||||
settingSvc: settingService,
|
||||
}
|
||||
}
|
||||
|
||||
type passkeyOptionsResponse struct {
|
||||
SessionToken string `json:"session_token"`
|
||||
Options any `json:"options"`
|
||||
}
|
||||
|
||||
type passkeyFinishRequest struct {
|
||||
SessionToken string `json:"session_token" binding:"required"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Credential json.RawMessage `json:"credential" binding:"required"`
|
||||
}
|
||||
|
||||
type passkeyRenameRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
// BeginLogin starts a usernameless, discoverable-credential login ceremony.
|
||||
func (h *PasskeyHandler) BeginLogin(c *gin.Context) {
|
||||
assertion, token, err := h.passkeys.BeginLogin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: assertion})
|
||||
}
|
||||
|
||||
// FinishLogin validates a passkey assertion and creates a normal Sub2API token
|
||||
// session. User verification is mandatory, so a successful passkey assertion
|
||||
// already supplies phishing-resistant multi-factor authentication and does not
|
||||
// enter the separate TOTP challenge flow.
|
||||
func (h *PasskeyHandler) FinishLogin(c *gin.Context) {
|
||||
req, ok := bindPasskeyFinishRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
|
||||
user, err := h.passkeys.FinishLogin(c.Request.Context(), req.SessionToken, credentialRequest)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err = h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||||
respondWithTokenPair(c, h.authService, user)
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) BeginRegistration(c *gin.Context) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: creation})
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) FinishRegistration(c *gin.Context) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
req, valid := bindPasskeyFinishRequest(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
|
||||
credential, err := h.passkeys.FinishRegistration(
|
||||
c.Request.Context(),
|
||||
subject.UserID,
|
||||
req.SessionToken,
|
||||
req.Name,
|
||||
credentialRequest,
|
||||
)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, credential)
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) List(c *gin.Context) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
credentials, err := h.passkeys.List(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, credentials)
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) Rename(c *gin.Context) {
|
||||
subject, credentialID, ok := passkeyMutationTarget(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req passkeyRenameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
response.BadRequest(c, "Passkey name is required")
|
||||
return
|
||||
}
|
||||
if err := h.passkeys.Rename(c.Request.Context(), subject.UserID, credentialID, req.Name); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) Delete(c *gin.Context) {
|
||||
subject, credentialID, ok := passkeyMutationTarget(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error {
|
||||
if err := ensureLoginUserActive(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.settingSvc == nil || !h.settingSvc.IsBackendModeEnabled(ctx) || user.IsAdmin() {
|
||||
return nil
|
||||
}
|
||||
return infraerrors.Forbidden("BACKEND_MODE_ADMIN_ONLY", "Backend mode is active. Only admin login is allowed.")
|
||||
}
|
||||
|
||||
func bindPasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, bool) {
|
||||
var req passkeyFinishRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||
response.BadRequest(c, "Invalid passkey response")
|
||||
return nil, false
|
||||
}
|
||||
return &req, true
|
||||
}
|
||||
|
||||
func cloneRequestWithJSON(original *http.Request, payload []byte) *http.Request {
|
||||
request := original.Clone(original.Context())
|
||||
request.Body = io.NopCloser(bytes.NewReader(payload))
|
||||
request.ContentLength = int64(len(payload))
|
||||
request.Header = original.Header.Clone()
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request
|
||||
}
|
||||
|
||||
func passkeyMutationTarget(c *gin.Context) (middleware2.AuthSubject, int64, bool) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return middleware2.AuthSubject{}, 0, false
|
||||
}
|
||||
credentialID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || credentialID <= 0 {
|
||||
response.BadRequest(c, "Invalid passkey ID")
|
||||
return middleware2.AuthSubject{}, 0, false
|
||||
}
|
||||
return subject, credentialID, true
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
PasskeyEnabled: settings.PasskeyEnabled,
|
||||
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||
LoginAgreementMode: settings.LoginAgreementMode,
|
||||
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||
|
||||
@@ -179,6 +179,7 @@ func ProvideHandlers(
|
||||
openaiGatewayHandler *OpenAIGatewayHandler,
|
||||
settingHandler *SettingHandler,
|
||||
totpHandler *TotpHandler,
|
||||
passkeyHandler *PasskeyHandler,
|
||||
paymentHandler *PaymentHandler,
|
||||
paymentWebhookHandler *PaymentWebhookHandler,
|
||||
availableChannelHandler *AvailableChannelHandler,
|
||||
@@ -201,6 +202,7 @@ func ProvideHandlers(
|
||||
OpenAIGateway: openaiGatewayHandler,
|
||||
Setting: settingHandler,
|
||||
Totp: totpHandler,
|
||||
Passkey: passkeyHandler,
|
||||
Payment: paymentHandler,
|
||||
PaymentWebhook: paymentWebhookHandler,
|
||||
AvailableChannel: availableChannelHandler,
|
||||
@@ -223,6 +225,7 @@ var ProviderSet = wire.NewSet(
|
||||
ProvideGatewayHandler,
|
||||
ProvideOpenAIGatewayHandler,
|
||||
NewTotpHandler,
|
||||
NewPasskeyHandler,
|
||||
ProvideSettingHandler,
|
||||
NewPaymentHandler,
|
||||
NewPaymentWebhookHandler,
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type passkeyRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPasskeyRepository(db *sql.DB) service.PasskeyRepository {
|
||||
return &passkeyRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) EnsureUserHandle(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
candidate []byte,
|
||||
) ([]byte, error) {
|
||||
if len(candidate) < 16 || len(candidate) > 64 {
|
||||
return nil, fmt.Errorf("passkey user handle must contain 16-64 bytes")
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO passkey_user_handles (user_id, user_handle)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
`, userID, candidate); err != nil {
|
||||
return nil, fmt.Errorf("ensure passkey user handle: %w", err)
|
||||
}
|
||||
return r.GetUserHandle(ctx, userID)
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) GetUserHandle(ctx context.Context, userID int64) ([]byte, error) {
|
||||
var handle []byte
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT user_handle
|
||||
FROM passkey_user_handles
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&handle)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, service.ErrPasskeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get passkey user handle: %w", err)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) GetByCredentialID(
|
||||
ctx context.Context,
|
||||
credentialID []byte,
|
||||
) (*service.PasskeyCredentialRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
|
||||
c.last_used_at, c.created_at, c.updated_at
|
||||
FROM passkey_credentials c
|
||||
JOIN passkey_user_handles h ON h.user_id = c.user_id
|
||||
WHERE c.credential_id = $1
|
||||
`, credentialID)
|
||||
record, err := scanPasskeyCredential(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, service.ErrPasskeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get passkey credential: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) ListByUserID(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
) ([]service.PasskeyCredentialRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
|
||||
c.last_used_at, c.created_at, c.updated_at
|
||||
FROM passkey_credentials c
|
||||
JOIN passkey_user_handles h ON h.user_id = c.user_id
|
||||
WHERE c.user_id = $1
|
||||
ORDER BY c.created_at DESC, c.id DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list passkey credentials: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]service.PasskeyCredentialRecord, 0)
|
||||
for rows.Next() {
|
||||
record, scanErr := scanPasskeyCredential(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scan passkey credential: %w", scanErr)
|
||||
}
|
||||
records = append(records, *record)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list passkey credentials: %w", err)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) Create(
|
||||
ctx context.Context,
|
||||
record *service.PasskeyCredentialRecord,
|
||||
) (*service.PasskeyCredentialRecord, error) {
|
||||
if record == nil || len(record.Credential.ID) == 0 {
|
||||
return nil, fmt.Errorf("passkey credential is required")
|
||||
}
|
||||
credentialJSON, err := json.Marshal(record.Credential)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode passkey credential: %w", err)
|
||||
}
|
||||
name := strings.TrimSpace(record.Name)
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
created := &service.PasskeyCredentialRecord{
|
||||
UserID: record.UserID,
|
||||
UserHandle: append([]byte(nil), record.UserHandle...),
|
||||
Name: name,
|
||||
Credential: record.Credential,
|
||||
}
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO passkey_credentials
|
||||
(user_id, credential_id, name, credential_data)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
RETURNING id, name, created_at, updated_at
|
||||
`, record.UserID, record.Credential.ID, name, string(credentialJSON)).
|
||||
Scan(&created.ID, &created.Name, &created.CreatedAt, &created.UpdatedAt)
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
|
||||
return nil, service.ErrPasskeyExists
|
||||
}
|
||||
return nil, fmt.Errorf("create passkey credential: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) UpdateCredential(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
credential *webauthn.Credential,
|
||||
usedAt time.Time,
|
||||
) error {
|
||||
if credential == nil || len(credential.ID) == 0 {
|
||||
return fmt.Errorf("passkey credential is required")
|
||||
}
|
||||
credentialJSON, err := json.Marshal(credential)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode passkey credential: %w", err)
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE passkey_credentials
|
||||
SET credential_data = $3::jsonb, last_used_at = $4, updated_at = NOW()
|
||||
WHERE user_id = $1 AND credential_id = $2
|
||||
`, userID, credential.ID, string(credentialJSON), usedAt.UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("update passkey credential: %w", err)
|
||||
}
|
||||
return requirePasskeyAffected(result)
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) Rename(
|
||||
ctx context.Context,
|
||||
userID, credentialID int64,
|
||||
name string,
|
||||
) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE passkey_credentials
|
||||
SET name = $3, updated_at = NOW()
|
||||
WHERE user_id = $1 AND id = $2
|
||||
`, userID, credentialID, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename passkey credential: %w", err)
|
||||
}
|
||||
return requirePasskeyAffected(result)
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) Delete(
|
||||
ctx context.Context,
|
||||
userID, credentialID int64,
|
||||
) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM passkey_credentials
|
||||
WHERE user_id = $1 AND id = $2
|
||||
`, userID, credentialID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete passkey credential: %w", err)
|
||||
}
|
||||
return requirePasskeyAffected(result)
|
||||
}
|
||||
|
||||
type passkeyScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPasskeyCredential(scanner passkeyScanner) (*service.PasskeyCredentialRecord, error) {
|
||||
var (
|
||||
record service.PasskeyCredentialRecord
|
||||
credentialJSON []byte
|
||||
)
|
||||
if err := scanner.Scan(
|
||||
&record.ID,
|
||||
&record.UserID,
|
||||
&record.UserHandle,
|
||||
&record.Name,
|
||||
&credentialJSON,
|
||||
&record.LastUsedAt,
|
||||
&record.CreatedAt,
|
||||
&record.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(credentialJSON, &record.Credential); err != nil {
|
||||
return nil, fmt.Errorf("decode passkey credential: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func requirePasskeyAffected(result sql.Result) error {
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return service.ErrPasskeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const passkeySessionPrefix = "passkey:session:"
|
||||
|
||||
type passkeySessionStore struct {
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewPasskeySessionStore(redisClient *redis.Client) service.PasskeySessionStore {
|
||||
return &passkeySessionStore{redis: redisClient}
|
||||
}
|
||||
|
||||
func (s *passkeySessionStore) Store(
|
||||
ctx context.Context,
|
||||
session *service.PasskeySession,
|
||||
ttl time.Duration,
|
||||
) (string, error) {
|
||||
if session == nil || ttl <= 0 {
|
||||
return "", fmt.Errorf("invalid passkey session")
|
||||
}
|
||||
random := make([]byte, 32)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return "", fmt.Errorf("generate passkey session token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(random)
|
||||
payload, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode passkey session: %w", err)
|
||||
}
|
||||
if err = s.redis.Set(ctx, passkeySessionPrefix+token, payload, ttl).Err(); err != nil {
|
||||
return "", fmt.Errorf("store passkey session: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *passkeySessionStore) Consume(
|
||||
ctx context.Context,
|
||||
token string,
|
||||
) (*service.PasskeySession, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" || len(token) > 128 {
|
||||
return nil, service.ErrPasskeySession
|
||||
}
|
||||
payload, err := s.redis.GetDel(ctx, passkeySessionPrefix+token).Bytes()
|
||||
if err == redis.Nil {
|
||||
return nil, service.ErrPasskeySession
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("consume passkey session: %w", err)
|
||||
}
|
||||
var session service.PasskeySession
|
||||
if err = json.Unmarshal(payload, &session); err != nil {
|
||||
return nil, service.ErrPasskeySession
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
@@ -88,6 +88,8 @@ var ProviderSet = wire.NewSet(
|
||||
NewSettingRepository,
|
||||
NewOpsRepository,
|
||||
NewAuditLogRepository,
|
||||
NewPasskeyRepository,
|
||||
NewPasskeySessionStore,
|
||||
NewUserSubscriptionRepository,
|
||||
NewUserAttributeDefinitionRepository,
|
||||
NewUserAttributeValueRepository,
|
||||
|
||||
@@ -146,6 +146,8 @@ var auditActionOverrides = map[string]string{
|
||||
// auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。
|
||||
// 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。
|
||||
var auditBodyOmittedRoutes = map[string]struct{}{
|
||||
"POST /api/v1/auth/passkey/login/finish": {},
|
||||
"POST /api/v1/user/passkeys/register/finish": {},
|
||||
"POST /api/v1/admin/accounts/import/codex-session": {},
|
||||
"PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session": {},
|
||||
"PUT /api/v1/admin/prompt-audit/config": {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ func RegisterAuthRoutes(
|
||||
auth.POST("/login/2fa", rateLimiter.LimitWithOptions("auth-login-2fa", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.Login2FA)
|
||||
auth.POST("/passkey/login/begin", rateLimiter.LimitWithOptions("passkey-login-begin", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Passkey.BeginLogin)
|
||||
auth.POST("/passkey/login/finish", rateLimiter.LimitWithOptions("passkey-login-finish", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Passkey.FinishLogin)
|
||||
auth.POST("/send-verify-code", rateLimiter.LimitWithOptions("auth-send-verify-code", 5, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.SendVerifyCode)
|
||||
|
||||
@@ -58,6 +58,15 @@ func RegisterUserRoutes(
|
||||
// 敏感操作二次验证:授予当前会话一段时间的 step-up 权限
|
||||
totp.POST("/step-up", h.Totp.StepUp)
|
||||
}
|
||||
|
||||
passkeys := user.Group("/passkeys")
|
||||
{
|
||||
passkeys.GET("", h.Passkey.List)
|
||||
passkeys.POST("/register/begin", h.Passkey.BeginRegistration)
|
||||
passkeys.POST("/register/finish", h.Passkey.FinishRegistration)
|
||||
passkeys.PATCH("/:id", h.Passkey.Rename)
|
||||
passkeys.DELETE("/:id", h.Passkey.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
// API Key管理
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
const (
|
||||
passkeySessionRegistration = "registration"
|
||||
passkeySessionLogin = "login"
|
||||
passkeySessionTTL = 5 * time.Minute
|
||||
defaultPasskeyName = "Passkey"
|
||||
maxPasskeyNameLength = 100
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPasskeysDisabled = infraerrors.Forbidden("PASSKEY_DISABLED", "passkey authentication is not enabled")
|
||||
ErrPasskeyNotFound = infraerrors.NotFound("PASSKEY_NOT_FOUND", "passkey not found")
|
||||
ErrPasskeyExists = infraerrors.Conflict("PASSKEY_ALREADY_EXISTS", "this passkey is already registered")
|
||||
ErrPasskeySession = infraerrors.BadRequest("PASSKEY_SESSION_INVALID", "passkey session is invalid or expired")
|
||||
ErrPasskeyVerify = infraerrors.Unauthorized("PASSKEY_VERIFICATION_FAILED", "passkey verification failed")
|
||||
)
|
||||
|
||||
// PasskeyCredentialRecord is the persistence representation used by the
|
||||
// WebAuthn service. Credential contains the complete WebAuthn credential record
|
||||
// so future library versions can continue to validate and update it.
|
||||
type PasskeyCredentialRecord struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserHandle []byte
|
||||
Name string
|
||||
Credential webauthn.Credential
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PasskeyRepository interface {
|
||||
EnsureUserHandle(ctx context.Context, userID int64, candidate []byte) ([]byte, error)
|
||||
GetUserHandle(ctx context.Context, userID int64) ([]byte, error)
|
||||
GetByCredentialID(ctx context.Context, credentialID []byte) (*PasskeyCredentialRecord, error)
|
||||
ListByUserID(ctx context.Context, userID int64) ([]PasskeyCredentialRecord, error)
|
||||
Create(ctx context.Context, record *PasskeyCredentialRecord) (*PasskeyCredentialRecord, error)
|
||||
UpdateCredential(ctx context.Context, userID int64, credential *webauthn.Credential, usedAt time.Time) error
|
||||
Rename(ctx context.Context, userID, credentialID int64, name string) error
|
||||
Delete(ctx context.Context, userID, credentialID int64) error
|
||||
}
|
||||
|
||||
type PasskeySession struct {
|
||||
Kind string `json:"kind"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
WebAuthn webauthn.SessionData `json:"webauthn"`
|
||||
}
|
||||
|
||||
type PasskeySessionStore interface {
|
||||
Store(ctx context.Context, session *PasskeySession, ttl time.Duration) (string, error)
|
||||
Consume(ctx context.Context, token string) (*PasskeySession, error)
|
||||
}
|
||||
|
||||
type PasskeyCredentialSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
Backup bool `json:"backup"`
|
||||
}
|
||||
|
||||
type passkeyUser struct {
|
||||
account *User
|
||||
handle []byte
|
||||
credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u *passkeyUser) WebAuthnID() []byte {
|
||||
return u.handle
|
||||
}
|
||||
|
||||
func (u *passkeyUser) WebAuthnName() string {
|
||||
return u.account.Email
|
||||
}
|
||||
|
||||
func (u *passkeyUser) WebAuthnDisplayName() string {
|
||||
if name := strings.TrimSpace(u.account.Username); name != "" {
|
||||
return name
|
||||
}
|
||||
return u.account.Email
|
||||
}
|
||||
|
||||
func (u *passkeyUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
return u.credentials
|
||||
}
|
||||
|
||||
type PasskeyService struct {
|
||||
enabled bool
|
||||
webAuthn *webauthn.WebAuthn
|
||||
repo PasskeyRepository
|
||||
sessions PasskeySessionStore
|
||||
userRepo UserRepository
|
||||
}
|
||||
|
||||
func NewPasskeyService(
|
||||
cfg *config.Config,
|
||||
repo PasskeyRepository,
|
||||
sessions PasskeySessionStore,
|
||||
userRepo UserRepository,
|
||||
) (*PasskeyService, error) {
|
||||
s := &PasskeyService{
|
||||
repo: repo,
|
||||
sessions: sessions,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
if cfg == nil || !cfg.WebAuthn.Enabled {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
instance, err := webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: cfg.WebAuthn.RPDisplayName,
|
||||
RPID: cfg.WebAuthn.RPID,
|
||||
RPOrigins: cfg.WebAuthn.RPOrigins,
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||
UserVerification: protocol.VerificationRequired,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize WebAuthn: %w", err)
|
||||
}
|
||||
s.enabled = true
|
||||
s.webAuthn = instance
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *PasskeyService) Enabled() bool {
|
||||
return s != nil && s.enabled && s.webAuthn != nil
|
||||
}
|
||||
|
||||
func (s *PasskeyService) requireEnabled() error {
|
||||
if !s.Enabled() {
|
||||
return ErrPasskeysDisabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PasskeyService) BeginRegistration(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
) (creation *protocol.CredentialCreation, sessionToken string, err error) {
|
||||
if err = s.requireEnabled(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !user.IsActive() {
|
||||
return nil, "", ErrUserNotActive
|
||||
}
|
||||
|
||||
candidate := make([]byte, 32)
|
||||
if _, err = rand.Read(candidate); err != nil {
|
||||
return nil, "", fmt.Errorf("generate passkey user handle: %w", err)
|
||||
}
|
||||
handle, err := s.repo.EnsureUserHandle(ctx, userID, candidate)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
waUser, err := s.loadWebAuthnUser(ctx, user, handle)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
creation, session, err := s.webAuthn.BeginRegistration(
|
||||
waUser,
|
||||
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
|
||||
webauthn.WithExclusions(webauthn.Credentials(waUser.credentials).CredentialDescriptors()),
|
||||
webauthn.WithExtensions(protocol.AuthenticationExtensions{"credProps": true}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("begin passkey registration: %w", err)
|
||||
}
|
||||
sessionToken, err = s.sessions.Store(ctx, &PasskeySession{
|
||||
Kind: passkeySessionRegistration,
|
||||
UserID: userID,
|
||||
WebAuthn: *session,
|
||||
}, passkeySessionTTL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return creation, sessionToken, nil
|
||||
}
|
||||
|
||||
func (s *PasskeyService) FinishRegistration(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
sessionToken, name string,
|
||||
request *http.Request,
|
||||
) (*PasskeyCredentialSummary, error) {
|
||||
if err := s.requireEnabled(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := s.sessions.Consume(ctx, sessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session == nil || session.Kind != passkeySessionRegistration || session.UserID != userID {
|
||||
return nil, ErrPasskeySession
|
||||
}
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := s.repo.GetUserHandle(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
waUser, err := s.loadWebAuthnUser(ctx, user, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credential, err := s.webAuthn.FinishRegistration(waUser, session.WebAuthn, request)
|
||||
if err != nil {
|
||||
return nil, ErrPasskeyVerify
|
||||
}
|
||||
|
||||
record, err := s.repo.Create(ctx, &PasskeyCredentialRecord{
|
||||
UserID: userID,
|
||||
UserHandle: handle,
|
||||
Name: normalizePasskeyName(name),
|
||||
Credential: *credential,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return passkeySummary(record), nil
|
||||
}
|
||||
|
||||
func (s *PasskeyService) BeginLogin(
|
||||
ctx context.Context,
|
||||
) (assertion *protocol.CredentialAssertion, sessionToken string, err error) {
|
||||
if err = s.requireEnabled(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
assertion, session, err := s.webAuthn.BeginDiscoverableLogin(
|
||||
webauthn.WithUserVerification(protocol.VerificationRequired),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("begin passkey login: %w", err)
|
||||
}
|
||||
sessionToken, err = s.sessions.Store(ctx, &PasskeySession{
|
||||
Kind: passkeySessionLogin,
|
||||
WebAuthn: *session,
|
||||
}, passkeySessionTTL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return assertion, sessionToken, nil
|
||||
}
|
||||
|
||||
func (s *PasskeyService) FinishLogin(
|
||||
ctx context.Context,
|
||||
sessionToken string,
|
||||
request *http.Request,
|
||||
) (*User, error) {
|
||||
if err := s.requireEnabled(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := s.sessions.Consume(ctx, sessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session == nil || session.Kind != passkeySessionLogin {
|
||||
return nil, ErrPasskeySession
|
||||
}
|
||||
|
||||
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
record, lookupErr := s.repo.GetByCredentialID(ctx, rawID)
|
||||
if lookupErr != nil || record == nil || !bytes.Equal(record.UserHandle, userHandle) {
|
||||
return nil, ErrPasskeyVerify
|
||||
}
|
||||
account, lookupErr := s.userRepo.GetByID(ctx, record.UserID)
|
||||
if lookupErr != nil || account == nil || !account.IsActive() {
|
||||
return nil, ErrPasskeyVerify
|
||||
}
|
||||
return s.loadWebAuthnUser(ctx, account, record.UserHandle)
|
||||
}
|
||||
|
||||
validatedUser, credential, err := s.webAuthn.FinishPasskeyLogin(handler, session.WebAuthn, request)
|
||||
if err != nil {
|
||||
return nil, ErrPasskeyVerify
|
||||
}
|
||||
waUser, ok := validatedUser.(*passkeyUser)
|
||||
if !ok || waUser.account == nil {
|
||||
return nil, ErrPasskeyVerify
|
||||
}
|
||||
if err = s.repo.UpdateCredential(ctx, waUser.account.ID, credential, time.Now().UTC()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return waUser.account, nil
|
||||
}
|
||||
|
||||
func (s *PasskeyService) List(ctx context.Context, userID int64) ([]PasskeyCredentialSummary, error) {
|
||||
if err := s.requireEnabled(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records, err := s.repo.ListByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]PasskeyCredentialSummary, 0, len(records))
|
||||
for i := range records {
|
||||
result = append(result, *passkeySummary(&records[i]))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *PasskeyService) Rename(ctx context.Context, userID, credentialID int64, name string) error {
|
||||
if err := s.requireEnabled(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Rename(ctx, userID, credentialID, normalizePasskeyName(name))
|
||||
}
|
||||
|
||||
func (s *PasskeyService) Delete(ctx context.Context, userID, credentialID int64) error {
|
||||
if err := s.requireEnabled(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Delete(ctx, userID, credentialID)
|
||||
}
|
||||
|
||||
func (s *PasskeyService) loadWebAuthnUser(
|
||||
ctx context.Context,
|
||||
user *User,
|
||||
handle []byte,
|
||||
) (*passkeyUser, error) {
|
||||
records, err := s.repo.ListByUserID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials := make([]webauthn.Credential, 0, len(records))
|
||||
for i := range records {
|
||||
credentials = append(credentials, records[i].Credential)
|
||||
}
|
||||
return &passkeyUser{account: user, handle: handle, credentials: credentials}, nil
|
||||
}
|
||||
|
||||
func normalizePasskeyName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return defaultPasskeyName
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) > maxPasskeyNameLength {
|
||||
name = string(runes[:maxPasskeyNameLength])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func passkeySummary(record *PasskeyCredentialRecord) *PasskeyCredentialSummary {
|
||||
return &PasskeyCredentialSummary{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
CreatedAt: record.CreatedAt,
|
||||
LastUsedAt: record.LastUsedAt,
|
||||
Backup: record.Credential.Flags.BackupEligible,
|
||||
}
|
||||
}
|
||||
|
||||
var _ webauthn.User = (*passkeyUser)(nil)
|
||||
@@ -0,0 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizePasskeyName(t *testing.T) {
|
||||
require.Equal(t, defaultPasskeyName, normalizePasskeyName(" "))
|
||||
require.Equal(t, "Laptop", normalizePasskeyName(" Laptop "))
|
||||
|
||||
longName := strings.Repeat("密", maxPasskeyNameLength+10)
|
||||
require.Len(t, []rune(normalizePasskeyName(longName)), maxPasskeyNameLength)
|
||||
}
|
||||
@@ -289,6 +289,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
PasswordResetEnabled: passwordResetEnabled,
|
||||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||
PasskeyEnabled: s.cfg != nil && s.cfg.WebAuthn.Enabled,
|
||||
LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
|
||||
LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
|
||||
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||||
@@ -446,6 +447,7 @@ type PublicSettingsInjectionPayload struct {
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"`
|
||||
PasskeyEnabled bool `json:"passkey_enabled"`
|
||||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||
@@ -515,6 +517,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
PasskeyEnabled: settings.PasskeyEnabled,
|
||||
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||
LoginAgreementMode: settings.LoginAgreementMode,
|
||||
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||
|
||||
@@ -676,6 +676,7 @@ func ProvideAPIKeyService(
|
||||
var ProviderSet = wire.NewSet(
|
||||
// Core services
|
||||
NewAuthService,
|
||||
NewPasskeyService,
|
||||
NewUserService,
|
||||
ProvideAPIKeyService,
|
||||
ProvideAPIKeyAuthCacheInvalidator,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS passkey_user_handles (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_handle BYTEA NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS passkey_credentials (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id BYTEA NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL DEFAULT 'Passkey',
|
||||
credential_data JSONB NOT NULL,
|
||||
last_used_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS passkey_credentials_user_id_idx
|
||||
ON passkey_credentials (user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS passkey_credentials_last_used_at_idx
|
||||
ON passkey_credentials (last_used_at);
|
||||
@@ -72,6 +72,22 @@ server:
|
||||
# 每个流的最大上传缓冲区(字节,默认 512KB)
|
||||
max_upload_buffer_per_stream: 524288
|
||||
|
||||
# =============================================================================
|
||||
# WebAuthn / Passkey Configuration
|
||||
# WebAuthn / Passkey 配置
|
||||
# =============================================================================
|
||||
# Passkeys require HTTPS in production. The RP ID is the domain only (no scheme
|
||||
# or port), while RP origins are exact browser origins.
|
||||
# Passkey 在生产环境要求 HTTPS。RP ID 只填写域名(不含协议和端口),RP origins
|
||||
# 填写浏览器访问使用的完整 Origin。
|
||||
webauthn:
|
||||
enabled: false
|
||||
rp_display_name: "Sub2API"
|
||||
# Example / 示例: "sub2api.example.com"
|
||||
rp_id: ""
|
||||
# Example / 示例: ["https://sub2api.example.com"]
|
||||
rp_origins: []
|
||||
|
||||
# =============================================================================
|
||||
# Run Mode Configuration
|
||||
# 运行模式配置
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get, post, patch, remove, credentialGet } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
credentialGet: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get,
|
||||
post,
|
||||
patch,
|
||||
delete: remove
|
||||
}
|
||||
}))
|
||||
|
||||
import { passkeyAPI } from '@/api/passkey'
|
||||
|
||||
class FakePublicKeyCredential {
|
||||
id = 'credential-id'
|
||||
rawId = Uint8Array.from([1, 2, 3]).buffer
|
||||
type = 'public-key'
|
||||
authenticatorAttachment = 'platform'
|
||||
response = {
|
||||
authenticatorData: Uint8Array.from([4, 5]).buffer,
|
||||
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||
signature: Uint8Array.from([8, 9]).buffer,
|
||||
userHandle: Uint8Array.from([10, 11]).buffer
|
||||
}
|
||||
|
||||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('passkey api', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
post.mockReset()
|
||||
patch.mockReset()
|
||||
remove.mockReset()
|
||||
credentialGet.mockReset()
|
||||
|
||||
vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential)
|
||||
Object.defineProperty(window, 'PublicKeyCredential', {
|
||||
configurable: true,
|
||||
value: FakePublicKeyCredential
|
||||
})
|
||||
Object.defineProperty(navigator, 'credentials', {
|
||||
configurable: true,
|
||||
value: { get: credentialGet }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('converts assertion options and response bytes to WebAuthn JSON', async () => {
|
||||
post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
session_token: 'one-time-session',
|
||||
options: {
|
||||
publicKey: {
|
||||
challenge: 'AQID',
|
||||
rpId: 'sub2api.example.com',
|
||||
userVerification: 'required'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'access',
|
||||
token_type: 'Bearer',
|
||||
user: { id: 1 }
|
||||
}
|
||||
})
|
||||
credentialGet.mockResolvedValue(new FakePublicKeyCredential())
|
||||
|
||||
await passkeyAPI.login()
|
||||
|
||||
const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions
|
||||
expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3])
|
||||
expect(request.publicKey!.userVerification).toBe('required')
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/auth/passkey/login/finish', {
|
||||
session_token: 'one-time-session',
|
||||
credential: {
|
||||
id: 'credential-id',
|
||||
rawId: 'AQID',
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: 'platform',
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
authenticatorData: 'BAU',
|
||||
clientDataJSON: 'Bgc',
|
||||
signature: 'CAk',
|
||||
userHandle: 'Cgs'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ export { userGroupsAPI } from './groups'
|
||||
export { userChannelsAPI } from './channels'
|
||||
export * as batchImageAPI from './batchImage'
|
||||
export { totpAPI } from './totp'
|
||||
export { passkeyAPI, type PasskeyCredentialSummary } from './passkey'
|
||||
export { default as announcementsAPI } from './announcements'
|
||||
export { channelMonitorUserAPI } from './channelMonitor'
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { apiClient } from './client'
|
||||
import type { AuthResponse } from '@/types'
|
||||
|
||||
export interface PasskeyCredentialSummary {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
backup: boolean
|
||||
}
|
||||
|
||||
interface CeremonyOptionsResponse {
|
||||
session_token: string
|
||||
options: {
|
||||
publicKey: Record<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): Promise<PasskeyCredentialSummary> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
|
||||
'/user/passkeys/register/begin'
|
||||
)
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: creationOptionsFromJSON(begin.options.publicKey)
|
||||
})
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('Passkey creation was cancelled')
|
||||
}
|
||||
const { data } = await apiClient.post<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): Promise<void> {
|
||||
await apiClient.delete(`/user/passkeys/${id}`)
|
||||
}
|
||||
|
||||
export const passkeyAPI = {
|
||||
isSupported: () => Boolean(window.PublicKeyCredential && navigator.credentials),
|
||||
login,
|
||||
register,
|
||||
list,
|
||||
rename,
|
||||
remove
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<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="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.featureDisabled') }}
|
||||
</div>
|
||||
<div v-else-if="!supported" class="text-sm text-amber-600 dark:text-amber-400">
|
||||
{{ t('profile.passkey.unsupported') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<form
|
||||
v-if="showAddForm"
|
||||
class="mb-5 flex flex-col gap-3 rounded-lg border border-gray-200 p-4 dark:border-dark-700 sm:flex-row sm:items-end"
|
||||
@submit.prevent="addPasskey"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<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 class="flex 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">
|
||||
{{ 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>
|
||||
</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 credentials = ref<PasskeyCredentialSummary[]>([])
|
||||
|
||||
async function loadCredentials(): Promise<void> {
|
||||
if (!supported) return
|
||||
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> {
|
||||
busy.value = true
|
||||
try {
|
||||
await passkeyAPI.register(newName.value.trim())
|
||||
appStore.showSuccess(t('profile.passkey.added'))
|
||||
cancelAdd()
|
||||
await loadCredentials()
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'NotAllowedError')) {
|
||||
appStore.showError(t('profile.passkey.addFailed'))
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAdd(): void {
|
||||
showAddForm.value = false
|
||||
newName.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
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePasskey(credential: PasskeyCredentialSummary): Promise<void> {
|
||||
if (!window.confirm(t('profile.passkey.deleteConfirm', { name: credential.name }))) return
|
||||
busy.value = true
|
||||
try {
|
||||
await passkeyAPI.remove(credential.id)
|
||||
credentials.value = credentials.value.filter((item) => item.id !== credential.id)
|
||||
appStore.showSuccess(t('profile.passkey.deleted'))
|
||||
} catch {
|
||||
appStore.showError(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,
|
||||
(enabled) => {
|
||||
if (enabled) void loadCredentials()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -205,6 +205,10 @@ export default {
|
||||
signInToAccount: 'Sign in to your account to continue',
|
||||
signIn: 'Sign In',
|
||||
signingIn: 'Signing in...',
|
||||
passkeySignIn: 'Sign in with a passkey',
|
||||
passkeySigningIn: 'Waiting for passkey...',
|
||||
passkeyCancelled: 'Passkey sign-in was cancelled.',
|
||||
passkeyFailed: 'Passkey sign-in failed. Please try again.',
|
||||
createAccount: 'Create Account',
|
||||
signUpToStart: 'Sign up to start using {siteName}',
|
||||
signUp: 'Sign up',
|
||||
|
||||
@@ -690,6 +690,29 @@ export default {
|
||||
codeSent: 'Verification code sent to your email',
|
||||
sendCodeFailed: 'Failed to send verification code'
|
||||
},
|
||||
passkey: {
|
||||
title: 'Passkeys',
|
||||
description: 'Use Face ID, Touch ID, Windows Hello, or a security key to sign in without a password.',
|
||||
add: 'Add passkey',
|
||||
continue: 'Create passkey',
|
||||
name: 'Passkey name',
|
||||
namePlaceholder: 'For example, MacBook Touch ID',
|
||||
empty: 'No passkeys are registered yet.',
|
||||
synced: 'Synced',
|
||||
createdAt: 'Created {date}',
|
||||
lastUsed: 'Last used {date}',
|
||||
featureDisabled: 'Passkeys have not been configured by the administrator.',
|
||||
unsupported: 'This browser or device does not support passkeys.',
|
||||
loadFailed: 'Failed to load passkeys.',
|
||||
added: 'Passkey added.',
|
||||
addFailed: 'Failed to add passkey.',
|
||||
renamePrompt: 'Enter a new name for this passkey',
|
||||
renamed: 'Passkey renamed.',
|
||||
renameFailed: 'Failed to rename passkey.',
|
||||
deleteConfirm: 'Delete “{name}”? You will no longer be able to sign in with it.',
|
||||
deleted: 'Passkey deleted.',
|
||||
deleteFailed: 'Failed to delete passkey.'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: 'Balance Low Notification',
|
||||
description: 'Send email alert when account balance falls below threshold',
|
||||
|
||||
@@ -205,6 +205,10 @@ export default {
|
||||
signInToAccount: '登录您的账户以继续',
|
||||
signIn: '登录',
|
||||
signingIn: '登录中...',
|
||||
passkeySignIn: '使用 Passkey 登录',
|
||||
passkeySigningIn: '正在等待 Passkey...',
|
||||
passkeyCancelled: '已取消 Passkey 登录。',
|
||||
passkeyFailed: 'Passkey 登录失败,请重试。',
|
||||
createAccount: '创建账户',
|
||||
signUpToStart: '注册以开始使用 {siteName}',
|
||||
signUp: '注册',
|
||||
|
||||
@@ -695,6 +695,29 @@ export default {
|
||||
codeSent: '验证码已发送到您的邮箱',
|
||||
sendCodeFailed: '发送验证码失败'
|
||||
},
|
||||
passkey: {
|
||||
title: 'Passkey',
|
||||
description: '使用面容 ID、触控 ID、Windows Hello 或安全密钥免密码登录。',
|
||||
add: '添加 Passkey',
|
||||
continue: '创建 Passkey',
|
||||
name: 'Passkey 名称',
|
||||
namePlaceholder: '例如:MacBook 触控 ID',
|
||||
empty: '尚未添加任何 Passkey。',
|
||||
synced: '已同步',
|
||||
createdAt: '创建于 {date}',
|
||||
lastUsed: '上次使用 {date}',
|
||||
featureDisabled: '管理员尚未配置 Passkey 功能。',
|
||||
unsupported: '当前浏览器或设备不支持 Passkey。',
|
||||
loadFailed: '加载 Passkey 失败。',
|
||||
added: 'Passkey 已添加。',
|
||||
addFailed: '添加 Passkey 失败。',
|
||||
renamePrompt: '请输入新的 Passkey 名称',
|
||||
renamed: 'Passkey 已重命名。',
|
||||
renameFailed: '重命名 Passkey 失败。',
|
||||
deleteConfirm: '删除“{name}”?删除后将无法再使用它登录。',
|
||||
deleted: 'Passkey 已删除。',
|
||||
deleteFailed: '删除 Passkey 失败。'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: '余额不足提醒',
|
||||
description: '当账户余额低于阈值时发送邮件提醒',
|
||||
|
||||
@@ -358,6 +358,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
github_oauth_enabled: false,
|
||||
google_oauth_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
passkey_enabled: false,
|
||||
version: siteVersion.value,
|
||||
balance_low_notify_enabled: false,
|
||||
account_quota_notify_enabled: false,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { authAPI, isTotp2FARequired, type LoginResponse } from '@/api'
|
||||
import { authAPI, isTotp2FARequired, passkeyAPI, type LoginResponse } from '@/api'
|
||||
import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
||||
|
||||
const AUTH_TOKEN_KEY = 'auth_token'
|
||||
@@ -275,6 +275,17 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPasskey(): Promise<User> {
|
||||
try {
|
||||
const response = await passkeyAPI.login()
|
||||
setAuthFromResponse(response)
|
||||
return user.value!
|
||||
} catch (error) {
|
||||
clearAuth({ preservePendingAuthSession: pendingAuthSession.value !== null })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auth state from an AuthResponse
|
||||
* Internal helper function
|
||||
@@ -486,6 +497,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// Actions
|
||||
login,
|
||||
loginWithPasskey,
|
||||
login2FA,
|
||||
register,
|
||||
setToken,
|
||||
|
||||
@@ -201,6 +201,7 @@ export interface PublicSettings {
|
||||
login_agreement_revision?: string
|
||||
login_agreement_documents?: LoginAgreementDocument[]
|
||||
turnstile_enabled: boolean
|
||||
passkey_enabled?: boolean
|
||||
turnstile_site_key: string
|
||||
site_name: string
|
||||
site_logo: string
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
@open="showAgreementModal = true"
|
||||
/>
|
||||
|
||||
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
|
||||
<div v-if="showPasskeyLogin || showOAuthLogin" class="space-y-3 pt-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
@@ -140,6 +140,17 @@
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showPasskeyLogin"
|
||||
type="button"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="authActionDisabled"
|
||||
@click="handlePasskeyLogin"
|
||||
>
|
||||
<Icon name="key" size="md" class="mr-2" />
|
||||
{{ passkeyLoading ? t('auth.passkeySigningIn') : t('auth.passkeySignIn') }}
|
||||
</button>
|
||||
|
||||
<EmailOAuthButtons
|
||||
:disabled="authActionDisabled"
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
@@ -229,6 +240,7 @@ const appStore = useAppStore()
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const passkeyLoading = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
const showPassword = ref<boolean>(false)
|
||||
const publicSettingsLoaded = ref<boolean>(false)
|
||||
@@ -245,6 +257,7 @@ const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const githubOAuthEnabled = ref<boolean>(false)
|
||||
const googleOAuthEnabled = ref<boolean>(false)
|
||||
const passwordResetEnabled = ref<boolean>(false)
|
||||
const passkeyEnabled = ref<boolean>(false)
|
||||
const loginAgreementEnabled = ref<boolean>(false)
|
||||
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
|
||||
const loginAgreementUpdatedAt = ref<string>('')
|
||||
@@ -283,7 +296,11 @@ const agreementGateActive = computed(
|
||||
)
|
||||
|
||||
const authActionDisabled = computed(
|
||||
() => isLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
|
||||
() => isLoading.value || passkeyLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
|
||||
)
|
||||
|
||||
const showPasskeyLogin = computed(
|
||||
() => passkeyEnabled.value && typeof window.PublicKeyCredential !== 'undefined'
|
||||
)
|
||||
|
||||
const showOAuthLogin = computed(
|
||||
@@ -328,6 +345,7 @@ onMounted(async () => {
|
||||
googleOAuthEnabled.value = settings.google_oauth_enabled
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
passwordResetEnabled.value = settings.password_reset_enabled
|
||||
passkeyEnabled.value = settings.passkey_enabled === true
|
||||
applyLoginAgreementSettings(settings)
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
@@ -516,6 +534,33 @@ async function handleLogin(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin(): Promise<void> {
|
||||
if (agreementGateActive.value) {
|
||||
appStore.showWarning(t('legal.loginAgreementPrompt.loginRequiredWarning'))
|
||||
if (loginAgreementMode.value !== 'checkbox') {
|
||||
showAgreementModal.value = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
await authStore.loginWithPasskey()
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
|
||||
await router.push(redirectTo)
|
||||
} catch (error: unknown) {
|
||||
const fallback = error instanceof DOMException && error.name === 'NotAllowedError'
|
||||
? t('auth.passkeyCancelled')
|
||||
: t('auth.passkeyFailed')
|
||||
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', fallback)
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 2FA Handlers ====================
|
||||
|
||||
async function handle2FAVerify(code: string): Promise<void> {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
/>
|
||||
|
||||
<ProfileTotpCard />
|
||||
<ProfilePasskeyCard :enabled="passkeyEnabled" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -57,6 +58,7 @@ import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNo
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||
import ProfilePasskeyCard from '@/components/user/profile/ProfilePasskeyCard.vue'
|
||||
import { isWeChatWebOAuthEnabled } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -76,6 +78,7 @@ const wechatOAuthOpenEnabled = ref<boolean | undefined>(undefined)
|
||||
const wechatOAuthMPEnabled = ref<boolean | undefined>(undefined)
|
||||
const oidcOAuthEnabled = ref(false)
|
||||
const oidcOAuthProviderName = ref('OIDC')
|
||||
const passkeyEnabled = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const profileRefresh = authStore.refreshUser().catch((error) => {
|
||||
@@ -101,6 +104,7 @@ onMounted(async () => {
|
||||
: undefined
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled ?? false
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
passkeyEnabled.value = settings.passkey_enabled === true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load settings:', error)
|
||||
|
||||
Reference in New Issue
Block a user