feat: add passkey authentication

This commit is contained in:
Zhixuan Jiang
2026-07-26 09:50:28 -04:00
parent 2730c1c43b
commit cc62979aa7
37 changed files with 1797 additions and 8 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=
@@ -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=
+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 {
@@ -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")
}
+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)
}
})
}
}
+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
+1
View File
@@ -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"`
+1
View File
@@ -58,6 +58,7 @@ type Handlers struct {
OpenAIGateway *OpenAIGatewayHandler
Setting *SettingHandler
Totp *TotpHandler
Passkey *PasskeyHandler
Payment *PaymentHandler
PaymentWebhook *PaymentWebhookHandler
AvailableChannel *AvailableChannelHandler
+207
View File
@@ -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,
+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 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,
@@ -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
}
+6
View File
@@ -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)
+9
View File
@@ -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管理
+378
View File
@@ -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)
+16
View File
@@ -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,
+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);