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:
@@ -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}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 认证
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user