Passkey: 账户设置绑定 + 免密登录(WebAuthn)

- 引入 go-webauthn, Passkey 表存凭据, challenge 会话内存存储(带过期)
- API: /webauthn/register|login begin/complete, /webauthn/passkeys 列表/删除
- 配置 OT_WEBAUTHN_RP_ID/RP_ORIGIN/RP_NAME;登录成功发 JWT+refresh cookie
- 前端 lib/webauthn(编解码+凭据序列化+安全上下文检测), 账户设置绑定区, 登录页免密按钮
- 需 HTTPS 或 localhost(安全上下文)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-16 02:00:08 +08:00
co-authored by Claude
parent 057b1b2c0b
commit 9324a782d5
14 changed files with 677 additions and 13 deletions
+9 -2
View File
@@ -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
+20 -4
View File
@@ -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=
+7 -3
View File
@@ -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}
}
// ---------------------------------------------------------------------------
// 认证
+154
View File
@@ -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})
}
+11 -1
View File
@@ -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)
+9
View File
@@ -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
}
+17
View File
@@ -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
}
+242
View File
@@ -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
}
+11
View File
@@ -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{},
}
}