diff --git a/.env.example b/.env.example index 718fde7..c5f205d 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,11 @@ OT_PROXY_HEALTH_FAIL_THRESHOLD=2 # 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制) OT_RATELIMIT_USER_RPS=20 +# Passkey(WebAuthn):RPID 为域名,RPOrigin 为前端来源(需 HTTPS 或 localhost) +OT_WEBAUTHN_RP_ID=localhost +OT_WEBAUTHN_RP_ORIGIN=http://localhost:5173 +OT_WEBAUTHN_RP_NAME=openteam + # 初始管理员(仅首次创建生效) OT_ADMIN_USERNAME=admin OT_ADMIN_EMAIL=admin@localhost diff --git a/server/go.mod b/server/go.mod index b05eadc..5357a04 100644 --- a/server/go.mod +++ b/server/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 + github.com/go-webauthn/webauthn v0.17.4 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/spf13/viper v1.21.0 golang.org/x/crypto v0.55.0 @@ -19,16 +20,19 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect 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.30.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.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.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.10.0 // indirect @@ -42,6 +46,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -51,8 +56,10 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/x448/float16 v0.8.4 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect diff --git a/server/go.sum b/server/go.sum index 7de6646..a1bef40 100644 --- a/server/go.sum +++ b/server/go.sum @@ -15,6 +15,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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -33,8 +35,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +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.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -43,11 +49,15 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -81,6 +91,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -118,10 +130,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index 99f0e9c..069319d 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/openteam/server/internal/api/middleware" "github.com/openteam/server/internal/app" + "github.com/openteam/server/internal/passkey" "github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/proxy" "github.com/openteam/server/internal/store" @@ -17,11 +18,14 @@ import ( // Handler 聚合所有管理 API。 type Handler struct { - a *app.App - gw *proxy.Gateway + a *app.App + gw *proxy.Gateway + passkeys *passkey.Service } -func NewHandler(a *app.App, gw *proxy.Gateway) *Handler { return &Handler{a: a, gw: gw} } +func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler { + return &Handler{a: a, gw: gw, passkeys: pk} +} // --------------------------------------------------------------------------- // 认证 diff --git a/server/internal/api/passkey.go b/server/internal/api/passkey.go new file mode 100644 index 0000000..96ef772 --- /dev/null +++ b/server/internal/api/passkey.go @@ -0,0 +1,154 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/openteam/server/internal/pkg/resp" + "github.com/openteam/server/internal/store" +) + +// PasskeyRegisterBegin POST /api/v1/webauthn/register/begin — 生成注册选项。 +func (h *Handler) PasskeyRegisterBegin(c *gin.Context) { + u := sessionUser(c) + creation, err := h.passkeys.BeginRegistration(u) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to begin registration: "+err.Error()) + return + } + resp.OK(c, gin.H{"creation": creation, "challenge": creation.Response.Challenge}) +} + +// PasskeyRegisterComplete POST /api/v1/webauthn/register/complete — 校验并保存凭据。 +func (h *Handler) PasskeyRegisterComplete(c *gin.Context) { + u := sessionUser(c) + var req struct { + Challenge string `json:"challenge"` + Name string `json:"name"` + Credential json.RawMessage `json:"credential"` + } + if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 { + resp.Fail(c, http.StatusBadRequest, "invalid input") + return + } + if err := h.passkeys.FinishRegistration(u, req.Challenge, req.Credential, []byte(req.Name)); err != nil { + resp.Fail(c, http.StatusBadRequest, "passkey 注册失败: "+err.Error()) + return + } + resp.OK(c, gin.H{"ok": true}) +} + +// PasskeyLoginBegin POST /api/v1/webauthn/login/begin — 生成断言选项。 +// 传 username 用指定用户;不传则用可发现凭据(平台 passkey)。 +func (h *Handler) PasskeyLoginBegin(c *gin.Context) { + var req struct { + Username string `json:"username"` + } + _ = c.ShouldBindJSON(&req) + if req.Username != "" { + var u store.User + err := h.a.DB.Where("username = ? OR email = ?", req.Username, req.Username).First(&u).Error + if err != nil || u.Status != store.UserStatusActive { + resp.Fail(c, http.StatusNotFound, "user not found") + return + } + assertion, err := h.passkeys.BeginLogin(&u) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error()) + return + } + resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge, "user_id": u.ID}) + return + } + assertion, err := h.passkeys.BeginDiscoverableLogin() + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error()) + return + } + resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge}) +} + +// PasskeyLoginComplete POST /api/v1/webauthn/login/complete — 校验断言并发放令牌。 +func (h *Handler) PasskeyLoginComplete(c *gin.Context) { + var req struct { + Challenge string `json:"challenge"` + Credential json.RawMessage `json:"credential"` + UserID uint64 `json:"user_id"` + } + if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 { + resp.Fail(c, http.StatusBadRequest, "invalid input") + return + } + var u *store.User + if req.UserID > 0 { + var su store.User + if err := h.a.DB.First(&su, req.UserID).Error; err != nil { + resp.Fail(c, http.StatusUnauthorized, "user not found") + return + } + u = &su + if err := h.passkeys.FinishLogin(u, req.Challenge, req.Credential); err != nil { + resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error()) + return + } + } else { + var err error + u, err = h.passkeys.FinishDiscoverableLogin(req.Challenge, req.Credential) + if err != nil { + resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error()) + return + } + } + if u.Status != store.UserStatusActive { + resp.Fail(c, http.StatusForbidden, "user account disabled") + return + } + access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access") + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to issue token") + return + } + refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh") + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to issue token") + return + } + h.setRefreshCookie(c, refresh) + resp.OK(c, gin.H{ + "access_token": access, + "expires_in": int(h.a.JWT.AccessTTL().Seconds()), + "user": h.publicUser(u), + }) +} + +// PasskeyList GET /api/v1/webauthn/passkeys — 当前用户的 passkey 列表。 +func (h *Handler) PasskeyList(c *gin.Context) { + u := sessionUser(c) + pks, err := h.passkeys.List(u.ID) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to load passkeys") + return + } + out := make([]gin.H, 0, len(pks)) + for _, pk := range pks { + out = append(out, gin.H{"id": pk.ID, "name": pk.Name, "created_at": pk.CreatedAt}) + } + resp.OK(c, gin.H{"items": out}) +} + +// PasskeyDelete DELETE /api/v1/webauthn/passkeys/:id — 解除绑定。 +func (h *Handler) PasskeyDelete(c *gin.Context) { + u := sessionUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid passkey id") + return + } + if err := h.passkeys.Delete(u.ID, id); err != nil { + resp.Fail(c, http.StatusNotFound, "passkey not found") + return + } + resp.OK(c, gin.H{"ok": true}) +} diff --git a/server/internal/api/router.go b/server/internal/api/router.go index c63d4d9..5ce0575 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -21,7 +21,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery(), middleware.CORS()) - h := NewHandler(a, gw) + h := NewHandler(a, gw, a.Passkeys) // --- 代理端点(对外)--- proxyGroup := r.Group("/v1") @@ -67,6 +67,16 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine { auth.GET("/me", middleware.SessionAuth(a), h.Me) } + webauthn := api.Group("/webauthn") + { + webauthn.POST("/register/begin", middleware.SessionAuth(a), h.PasskeyRegisterBegin) + webauthn.POST("/register/complete", middleware.SessionAuth(a), h.PasskeyRegisterComplete) + webauthn.POST("/login/begin", h.PasskeyLoginBegin) + webauthn.POST("/login/complete", h.PasskeyLoginComplete) + webauthn.GET("/passkeys", middleware.SessionAuth(a), h.PasskeyList) + webauthn.DELETE("/passkeys/:id", middleware.SessionAuth(a), h.PasskeyDelete) + } + user := api.Group("", middleware.SessionAuth(a)) { user.GET("/user/profile", h.UserProfile) diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 6aced75..556e13c 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -8,6 +8,7 @@ import ( "github.com/openteam/server/internal/channel" "github.com/openteam/server/internal/config" + "github.com/openteam/server/internal/passkey" "github.com/openteam/server/internal/pkg/crypto" "github.com/openteam/server/internal/pkg/jwt" "github.com/openteam/server/internal/pkg/ratelimit" @@ -25,6 +26,7 @@ type App struct { Usage *usage.Recorder Health *channel.HealthMonitor Limit *ratelimit.Limiter + Passkeys *passkey.Service startedAt time.Time ctx context.Context cancel context.CancelFunc @@ -57,6 +59,13 @@ func New(cfg *config.Config) (*App, error) { FailThreshold: cfg.Proxy.HealthFailThreshold, }) a.Health.Start(a.ctx) + + a.Passkeys, err = passkey.New(db, passkey.Config{ + RPID: cfg.WebAuthn.RPID, Origin: cfg.WebAuthn.RPOrigin, Name: cfg.WebAuthn.RPName, + }) + if err != nil { + return nil, err + } return a, nil } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index eb5bfa1..d52dc78 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -18,9 +18,17 @@ type Config struct { Auth AuthConfig Proxy ProxyConfig RateLimit RateLimitConfig + WebAuthn WebAuthnConfig Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量) } +// WebAuthnConfig Passkey(WebAuthn)配置。 +type WebAuthnConfig struct { + RPID string // Relying Party ID(域名,如 localhost) + RPOrigin string // 前端来源,如 http://localhost:5173 + RPName string // 展示名 +} + // RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。 type RateLimitConfig struct { UserRPS int // 用户级每秒请求数上限(0=不限制) @@ -124,6 +132,10 @@ func Load() (*Config, error) { v.SetDefault("ratelimit.user_rps", 20) + v.SetDefault("webauthn.rp_id", "localhost") + v.SetDefault("webauthn.rp_origin", "http://localhost:5173") + v.SetDefault("webauthn.rp_name", "openteam") + return &Config{ Env: v.GetString("env"), Port: v.GetInt("port"), @@ -160,6 +172,11 @@ func Load() (*Config, error) { RateLimit: RateLimitConfig{ UserRPS: v.GetInt("ratelimit.user_rps"), }, + WebAuthn: WebAuthnConfig{ + RPID: v.GetString("webauthn.rp_id"), + RPOrigin: v.GetString("webauthn.rp_origin"), + RPName: v.GetString("webauthn.rp_name"), + }, Master: v.GetString("master_key"), }, nil } diff --git a/server/internal/passkey/passkey.go b/server/internal/passkey/passkey.go new file mode 100644 index 0000000..7daf004 --- /dev/null +++ b/server/internal/passkey/passkey.go @@ -0,0 +1,242 @@ +// Package passkey 封装 WebAuthn(passkey)注册与登录。 +// 依赖 go-webauthn 库;RPID/RPOrigin 由配置提供。 +package passkey + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/openteam/server/internal/store" + "gorm.io/gorm" +) + +type Config struct { + RPID string + Origin string + Name string +} + +// Service WebAuthn 服务:凭据存储 + challenge 会话(内存)。 +type Service struct { + wa *webauthn.WebAuthn + db *gorm.DB + + mu sync.Mutex + sessions map[string]webauthn.SessionData // keyed by challenge +} + +func New(db *gorm.DB, cfg Config) (*Service, error) { + wa, err := webauthn.New(&webauthn.Config{ + RPDisplayName: cfg.Name, + RPID: cfg.RPID, + RPOrigins: []string{cfg.Origin}, + }) + if err != nil { + return nil, err + } + return &Service{wa: wa, db: db, sessions: map[string]webauthn.SessionData{}}, nil +} + +// webUser 实现 go-webauthn 的 User 接口。 +type webUser struct { + id uint64 + name string + displayName string + credentials []webauthn.Credential +} + +func (u *webUser) WebAuthnID() []byte { return []byte(strconv.FormatUint(u.id, 10)) } +func (u *webUser) WebAuthnName() string { return u.name } +func (u *webUser) WebAuthnDisplayName() string { return u.displayName } +func (u *webUser) WebAuthnIcon() string { return "" } +func (u *webUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } + +func (s *Service) loadWebUser(u *store.User) (*webUser, error) { + var pks []store.Passkey + s.db.Where("user_id = ?", u.ID).Find(&pks) + creds := make([]webauthn.Credential, 0, len(pks)) + for _, pk := range pks { + var c webauthn.Credential + if err := json.Unmarshal(pk.Credential, &c); err == nil { + creds = append(creds, c) + } + } + return &webUser{id: u.ID, name: u.Username, displayName: u.Username, credentials: creds}, nil +} + +// --------------------------------------------------------------------------- +// 注册 + +// BeginRegistration 生成注册选项并暂存 challenge。 +func (s *Service) BeginRegistration(u *store.User) (*protocol.CredentialCreation, error) { + wu, err := s.loadWebUser(u) + if err != nil { + return nil, err + } + creation, session, err := s.wa.BeginRegistration(wu) + if err != nil { + return nil, err + } + s.storeSession(session) + return creation, nil +} + +// FinishRegistration 校验浏览器返回的凭据并落库。 +func (s *Service) FinishRegistration(u *store.User, challenge string, body, name []byte) error { + session, ok := s.takeSession(challenge) + if !ok { + return errors.New("challenge 已过期或不存在") + } + wu, err := s.loadWebUser(u) + if err != nil { + return err + } + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + cred, err := s.wa.FinishRegistration(wu, session, req) + if err != nil { + return err + } + raw, _ := json.Marshal(cred) + nm := string(name) + if nm == "" { + nm = "passkey" + } + return s.db.Create(&store.Passkey{ + UserID: u.ID, Name: nm, CredentialID: cred.ID, Credential: raw, + }).Error +} + +// --------------------------------------------------------------------------- +// 登录 + +// BeginLogin 已知用户(按用户名)发起断言。 +func (s *Service) BeginLogin(u *store.User) (*protocol.CredentialAssertion, error) { + wu, err := s.loadWebUser(u) + if err != nil { + return nil, err + } + assertion, session, err := s.wa.BeginLogin(wu) + if err != nil { + return nil, err + } + s.storeSession(session) + return assertion, nil +} + +// BeginDiscoverableLogin 无用户名(使用平台/漫游器上的可发现凭据)。 +func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, error) { + assertion, session, err := s.wa.BeginDiscoverableLogin() + if err != nil { + return nil, err + } + s.storeSession(session) + return assertion, nil +} + +// FinishLogin 校验断言并更新签名计数。 +func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) error { + session, ok := s.takeSession(challenge) + if !ok { + return errors.New("challenge 已过期或不存在") + } + wu, err := s.loadWebUser(u) + if err != nil { + return err + } + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + cred, err := s.wa.FinishLogin(wu, session, req) + if err != nil { + return err + } + return s.updateCredential(u.ID, cred) +} + +// FinishDiscoverableLogin 通过凭据定位用户并校验断言。 +func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store.User, error) { + session, ok := s.takeSession(challenge) + if !ok { + return nil, errors.New("challenge 已过期或不存在") + } + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + handler := func(rawID, userHandle []byte) (webauthn.User, error) { + var pk store.Passkey + if err := s.db.Where("credential_id = ?", rawID).First(&pk).Error; err != nil { + return nil, errors.New("credential not found") + } + var u store.User + if err := s.db.First(&u, pk.UserID).Error; err != nil { + return nil, errors.New("user not found") + } + return s.loadWebUser(&u) + } + user, cred, err := s.wa.FinishPasskeyLogin(handler, session, req) + if err != nil { + return nil, err + } + wu := user.(*webUser) + _ = s.updateCredential(wu.id, cred) + var st store.User + if err := s.db.First(&st, wu.id).Error; err != nil { + return nil, err + } + return &st, nil +} + +// --------------------------------------------------------------------------- +// 管理 + +// List 列出用户的 passkey。 +func (s *Service) List(userID uint64) ([]store.Passkey, error) { + var pks []store.Passkey + err := s.db.Where("user_id = ?", userID).Order("id DESC").Find(&pks).Error + return pks, err +} + +// Delete 删除用户的 passkey。 +func (s *Service) Delete(userID, id uint64) error { + res := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&store.Passkey{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *Service) updateCredential(userID uint64, cred *webauthn.Credential) error { + raw, _ := json.Marshal(cred) + return s.db.Model(&store.Passkey{}). + Where("user_id = ? AND credential_id = ?", userID, cred.ID). + Update("credential", raw).Error +} + +// --------------------------------------------------------------------------- +// challenge 会话 + +func (s *Service) storeSession(session *webauthn.SessionData) { + s.mu.Lock() + s.sessions[session.Challenge] = *session + s.mu.Unlock() +} + +func (s *Service) takeSession(challenge string) (webauthn.SessionData, bool) { + s.mu.Lock() + sess, ok := s.sessions[challenge] + if ok { + delete(s.sessions, challenge) + } + s.mu.Unlock() + if ok && time.Now().After(sess.Expires) { + return webauthn.SessionData{}, false + } + return sess, ok +} diff --git a/server/internal/store/models.go b/server/internal/store/models.go index e1e28f4..bb5243f 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -200,6 +200,16 @@ type BalanceLog struct { CreatedAt time.Time `json:"created_at"` } +// Passkey WebAuthn 凭据(passkey 绑定/登录) +type Passkey struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + UserID uint64 `gorm:"index;not null" json:"user_id"` + Name string `gorm:"size:64" json:"name"` + CredentialID []byte `gorm:"size:255;not null" json:"-"` // credential.ID + Credential []byte `gorm:"type:blob;not null" json:"-"` // json.Marshal(webauthn.Credential) + CreatedAt time.Time `json:"created_at"` +} + // SystemConfig 系统配置(PLANNING §6.9) type SystemConfig struct { Key string `gorm:"primaryKey;size:64" json:"key"` @@ -218,6 +228,7 @@ func AllModels() []any { &UsageDaily{}, &RechargeOrder{}, &BalanceLog{}, + &Passkey{}, &SystemConfig{}, } } diff --git a/web/src/lib/webauthn.ts b/web/src/lib/webauthn.ts new file mode 100644 index 0000000..c97fb0a --- /dev/null +++ b/web/src/lib/webauthn.ts @@ -0,0 +1,76 @@ +// WebAuthn(passkey)浏览器侧工具:选项编解码 + 凭据序列化。 +// go-webauthn 返回的 challenge/id 为 base64url 字符串,浏览器需要 ArrayBuffer; +// 回调的 credential 需要把 ArrayBuffer 字段转回 base64url。 + +export function bufToB64url(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf) + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +export function b64urlToBuf(s: string): ArrayBuffer { + const t = s.replace(/-/g, '+').replace(/_/g, '/') + const pad = t.length % 4 === 0 ? '' : '='.repeat(4 - (t.length % 4)) + const bin = atob(t + pad) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + return bytes.buffer +} + +interface CredentialId { + type?: string + id: string +} + +function prepareIds(list?: CredentialId[]): { type?: string; id: ArrayBuffer }[] | undefined { + if (!list) return undefined + return list.map((c) => ({ ...c, id: b64urlToBuf(c.id) })) +} + +// registerPasskey 调用 navigator.credentials.create,返回可提交后端的 JSON。 +export async function registerPasskey(options: Record): Promise> { + const pk: Record = { ...options.publicKey } + pk.challenge = b64urlToBuf(pk.challenge) + if (pk.user?.id) pk.user = { ...pk.user, id: b64urlToBuf(pk.user.id) } + pk.excludeCredentials = prepareIds(pk.excludeCredentials) + + const cred = (await navigator.credentials.create({ publicKey: pk as PublicKeyCredentialCreationOptions })) as PublicKeyCredential + const resp = cred.response as AuthenticatorAttestationResponse + return { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + response: { + clientDataJSON: bufToB64url(resp.clientDataJSON), + attestationObject: bufToB64url(resp.attestationObject), + transports: (resp as unknown as { getTransports?: () => string[] }).getTransports?.() ?? [], + }, + } +} + +// loginPasskey 调用 navigator.credentials.get,返回可提交后端的 JSON。 +export async function loginPasskey(options: Record): Promise> { + const pk: Record = { ...options.publicKey } + pk.challenge = b64urlToBuf(pk.challenge) + pk.allowCredentials = prepareIds(pk.allowCredentials) + + const cred = (await navigator.credentials.get({ publicKey: pk as PublicKeyCredentialRequestOptions })) as PublicKeyCredential + const resp = cred.response as AuthenticatorAssertionResponse + return { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + response: { + clientDataJSON: bufToB64url(resp.clientDataJSON), + authenticatorData: bufToB64url(resp.authenticatorData), + signature: bufToB64url(resp.signature), + userHandle: resp.userHandle ? bufToB64url(resp.userHandle) : null, + }, + } +} + +// isWebAuthnSupported 是否处于安全上下文(passkey 需要 HTTPS 或 localhost)。 +export function isWebAuthnSupported(): boolean { + return typeof navigator !== 'undefined' && !!navigator.credentials && window.isSecureContext +} diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 3a5a529..4ef5428 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -29,7 +29,9 @@ export const useAuthStore = defineStore('auth', { }, async login(username: string, password: string) { const { data } = await http.post('/auth/login', { username, password }) - const d = data.data as { access_token: string; user: User } + this.setSession(data.data) + }, + setSession(d: { access_token: string; user: User }) { this.accessToken = d.access_token this.user = d.user localStorage.setItem('ot_access', d.access_token) diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 347db1c..5ef3eca 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -1,9 +1,11 @@