refactor: restructure passkey with dedicated package

- Create internal/passkey/ package with challenge session management
- Add HTTP handler layer in internal/api/passkey.go
- Simplify Passkey model to use blob credential storage
- Register passkey routes in router
- Update frontend to match new API endpoints
- Add .env.example template
This commit is contained in:
Sakurasan
2026-09-01 22:34:44 +08:00
parent d28fca8ee6
commit 9733b3c20b
11 changed files with 546 additions and 268 deletions
+48
View File
@@ -0,0 +1,48 @@
# ===========================================
# OpenCatd-Open 配置文件
# 复制此文件为 .env 并修改相应配置
# ===========================================
# --- 服务器配置 ---
PORT=80
READ_TIMEOUT=10
WRITE_TIMEOUT=10
# --- Passkey (WebAuthn) 配置 ---
# 应用名称(显示给用户)
APP_NAME=OpenTeam
# 依赖方 ID(通常为域名,生产环境需改为实际域名)
RPID=localhost
# 依赖方来源(前端 URL,逗号分隔)
RPORIGINS=http://localhost:5173,http://localhost:3000
# --- 数据库配置 ---
# 支持: sqlite, mysql, postgres
DB_TYPE=sqlite
# DSN 连接字符串(SQLite 可留空)
DB_DSN=
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
# --- Redis 配置(可选)---
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_PASSWORD=
# REDIS_DB=0
# --- 日志配置 ---
LOG_LEVEL=info
LOG_PATH=./logs/
# --- 功能开关 ---
# 允许注册(false=关闭注册)
ALLOW_REGISTER=false
# 无限制配额(true=不限制)
UNLIMITED_QUOTA=true
# 新用户默认激活
DEFAULT_ACTIVE=true
# --- 用量统计 ---
USAGE_WORKER=1
USAGE_CHAN_SIZE=1000
TASK_TIME_INTERVAL=60
+4 -1
View File
@@ -3,6 +3,7 @@ package api
import (
"net/http"
"opencatd-open/internal/dao"
"opencatd-open/internal/passkey"
"opencatd-open/internal/store"
"opencatd-open/internal/pkg/apikey"
"opencatd-open/internal/pkg/crypto"
@@ -23,9 +24,10 @@ type Handler struct {
modelDAO *dao.ModelDAO
usageDAO *dao.UsageDAO
dailyDAO *dao.DailyUsageDAO
passkeys *passkey.Service
}
func NewHandler(db *gorm.DB) *Handler {
func NewHandler(db *gorm.DB, passkeys *passkey.Service) *Handler {
return &Handler{
db: db,
userDAO: dao.NewUserDAO(db),
@@ -34,6 +36,7 @@ func NewHandler(db *gorm.DB) *Handler {
modelDAO: dao.NewModelDAO(db),
usageDAO: dao.NewUsageDAO(db),
dailyDAO: dao.NewDailyUsageDAO(db),
passkeys: passkeys,
}
}
+162
View File
@@ -0,0 +1,162 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"opencatd-open/internal/auth"
"opencatd-open/internal/pkg/jwt"
"opencatd-open/internal/store"
)
// PasskeyRegisterBegin POST /api/webauthn/register/begin — 生成注册选项。
func (h *Handler) PasskeyRegisterBegin(c *gin.Context) {
userID, _ := c.Get("user_id")
u, err := h.passkeys.GetUserByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
creation, err := h.passkeys.BeginRegistration(u)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin registration: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"creation": creation, "challenge": creation.Response.Challenge}})
}
// PasskeyRegisterComplete POST /api/webauthn/register/complete — 校验并保存凭据。
func (h *Handler) PasskeyRegisterComplete(c *gin.Context) {
userID, _ := c.Get("user_id")
u, err := h.passkeys.GetUserByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
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 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
if err := h.passkeys.FinishRegistration(u, req.Challenge, req.Credential, req.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "passkey 注册失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
}
// PasskeyLoginBegin POST /api/auth/passkey/begin — 生成断言选项。
// 传 username 用指定用户;不传则用可发现凭据(平台 passkey)。
func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
var req struct {
Username string `json:"username"`
}
_ = c.ShouldBindJSON(&req)
if req.Username != "" {
u, err := h.passkeys.GetUserByUsername(req.Username)
if err != nil || u.Status != store.UserStatusActive {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
assertion, err := h.passkeys.BeginLogin(u)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin login: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge, "user_id": u.ID}})
return
}
assertion, err := h.passkeys.BeginDiscoverableLogin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin login: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge}})
}
// PasskeyLoginComplete POST /api/auth/passkey/finish — 校验断言并发放令牌。
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 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var u *store.User
if req.UserID > 0 {
var err error
u, err = h.passkeys.GetUserByID(req.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
if err := h.passkeys.FinishLogin(u, req.Challenge, req.Credential); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "passkey 校验失败: " + err.Error()})
return
}
} else {
var err error
u, err = h.passkeys.FinishDiscoverableLogin(req.Challenge, req.Credential)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "passkey 校验失败: " + err.Error()})
return
}
}
if u.Status != store.UserStatusActive {
c.JSON(http.StatusForbidden, gin.H{"error": "user account disabled"})
return
}
secret := auth.GetSecretKey()
accessToken, refreshToken, err := jwt.GenerateTokenPair(u.ID, u.Username, u.Role, secret, 24*time.Hour, 7*24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue token"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"token": accessToken,
"access_token": accessToken,
"refresh_token": refreshToken,
},
})
}
// PasskeyList GET /api/profile/passkeys — 当前用户的 passkey 列表。
func (h *Handler) PasskeyList(c *gin.Context) {
userID, _ := c.Get("user_id")
pks, err := h.passkeys.List(userID.(uint64))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "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})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// PasskeyDelete DELETE /api/profile/passkeys/:id — 解除绑定。
func (h *Handler) PasskeyDelete(c *gin.Context) {
userID, _ := c.Get("user_id")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid passkey id"})
return
}
if err := h.passkeys.Delete(userID.(uint64), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "passkey not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
}
+1 -3
View File
@@ -13,18 +13,16 @@ type Api struct {
userService *service.UserServiceImpl
tokenService *service.TokenServiceImpl
keyService *service.ApiKeyServiceImpl
webAuthService *service.WebAuthnService
usageService *service.UsageService
}
func NewApi(cfg *config.Config, db *gorm.DB, userService *service.UserServiceImpl, tokenService *service.TokenServiceImpl, keyService *service.ApiKeyServiceImpl, webAuthService *service.WebAuthnService, usageService *service.UsageService) *Api {
func NewApi(cfg *config.Config, db *gorm.DB, userService *service.UserServiceImpl, tokenService *service.TokenServiceImpl, keyService *service.ApiKeyServiceImpl, usageService *service.UsageService) *Api {
return &Api{
cfg: cfg,
db: db,
userService: userService,
tokenService: tokenService,
keyService: keyService,
webAuthService: webAuthService,
usageService: usageService,
}
}
-11
View File
@@ -1,11 +0,0 @@
package dto
type Passkey struct {
ID int64 `json:"id" gorm:"column:id;primaryKey;autoIncrement"`
Name string `json:"name" gorm:"column:name"` // 凭证名称,用于用户识别不同的设备
SignCount uint32 `json:"sign_count" gorm:"column:sign_count"` // 签名计数器,用于防止重放攻击
DeviceType string `json:"device_type" gorm:"column:device_type"` // 设备类型,如"platform"或"cross-platform"
LastUsedAt int64 `json:"last_used_at" gorm:"column:last_used_at"` // 最后使用时间
CreatedAt int64 `json:"created_at,omitempty" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updated_at,omitempty" gorm:"autoUpdateTime"`
}
+275
View File
@@ -0,0 +1,275 @@
// Package passkey 封装 WebAuthn(passkey)注册与登录。
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"
"opencatd-open/internal/store"
"gorm.io/gorm"
)
type Config struct {
RPID string
Origins []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: cfg.Origins,
})
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
}
// GetUserByUsername 通过用户名或邮箱查找用户
func (s *Service) GetUserByUsername(username string) (*store.User, error) {
var u store.User
if err := s.db.Where("username = ? OR email = ?", username, username).First(&u).Error; err != nil {
return nil, err
}
return &u, nil
}
// GetUserByID 通过 ID 查找用户
func (s *Service) GetUserByID(id uint64) (*store.User, error) {
var u store.User
if err := s.db.First(&u, id).Error; err != nil {
return nil, err
}
return &u, 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 []byte, name string) 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 := 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))
// 先通过 credential_id 反查用户
var pks []store.Passkey
if err := s.db.Find(&pks).Error; err != nil {
return nil, err
}
// 遍历所有 passkey 找到匹配的
for _, pk := range pks {
var c webauthn.Credential
if err := json.Unmarshal(pk.Credential, &c); err != nil {
continue
}
// 尝试用这个用户的凭据进行登录
var u store.User
if err := s.db.First(&u, pk.UserID).Error; err != nil {
continue
}
wu, err := s.loadWebUser(&u)
if err != nil {
continue
}
cred, err := s.wa.FinishLogin(wu, session, req)
if err != nil {
// 不是这个用户的 passkey,继续尝试
session, _ = s.takeSession(challenge)
if !ok {
return nil, errors.New("challenge 已过期或不存在")
}
continue
}
_ = s.updateCredential(u.ID, cred)
return &u, nil
}
return nil, errors.New("no matching passkey found")
}
// ---------------------------------------------------------------------------
// 管理
// 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()
// Expires 可能为零值:go-webauthn 默认 Enforce=false 不设过期时间。
// 零值时间恒早于 now,直接 After 会把每个 challenge 都判为过期,
// 与库内部一致,仅当显式设置了过期时间才做校验。
if ok && !sess.Expires.IsZero() && time.Now().After(sess.Expires) {
return webauthn.SessionData{}, false
}
return sess, ok
}
-203
View File
@@ -1,203 +0,0 @@
package service
import (
"encoding/base64"
"fmt"
"net/http"
"opencatd-open/internal/store"
"opencatd-open/pkg/config"
"strconv"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
)
type WebAuthnUser struct {
User *store.User
Credentials []webauthn.Credential
}
func (u *WebAuthnUser) WebAuthnID() []byte {
return []byte(strconv.FormatUint(u.User.ID, 10))
}
func (u *WebAuthnUser) WebAuthnName() string {
return u.User.Username
}
func (u *WebAuthnUser) WebAuthnDisplayName() string {
return u.User.Username
}
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
return u.Credentials
}
func (u *WebAuthnUser) WebAuthnCredentialDescriptors() (descriptors []protocol.CredentialDescriptor) {
credentials := u.WebAuthnCredentials()
descriptors = make([]protocol.CredentialDescriptor, len(credentials))
for i, credential := range credentials {
descriptors[i] = credential.Descriptor()
}
return descriptors
}
type WebAuthnService struct {
cfg *config.Config
DB *gorm.DB
WebAuthn *webauthn.WebAuthn
}
func NewWebAuthnService(cfg *config.Config, db *gorm.DB) (*WebAuthnService, error) {
wconfig := &webauthn.Config{
RPDisplayName: cfg.AppName,
RPID: cfg.RPID,
RPOrigins: cfg.RPOrigins,
AuthenticatorSelection: protocol.AuthenticatorSelection{
RequireResidentKey: protocol.ResidentKeyRequired(),
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationPreferred,
},
}
wa, err := webauthn.New(wconfig)
if err != nil {
return nil, err
}
return &WebAuthnService{
cfg: cfg,
DB: db,
WebAuthn: wa,
}, nil
}
func (s *WebAuthnService) GetUserWithCredentials(userID uint64) (*WebAuthnUser, error) {
var user store.User
if err := s.DB.First(&user, userID).Error; err != nil {
return nil, err
}
var passkeys []store.Passkey
if err := s.DB.Where("user_id = ?", userID).Find(&passkeys).Error; err != nil {
return nil, err
}
credentials := make([]webauthn.Credential, len(passkeys))
for i, pk := range passkeys {
credentialIDBytes, err := base64.StdEncoding.DecodeString(pk.CredentialID)
if err != nil {
return nil, fmt.Errorf("failed to decode CredentialID: %w", err)
}
publicKeyBytes, err := base64.StdEncoding.DecodeString(pk.PublicKey)
if err != nil {
return nil, fmt.Errorf("failed to decode PublicKey: %w", err)
}
aaguidBytes, err := base64.StdEncoding.DecodeString(pk.AAGUID)
if err != nil {
return nil, fmt.Errorf("failed to decode AAGUID: %w", err)
}
var transport []protocol.AuthenticatorTransport
if pk.Transport != "" {
transport = []protocol.AuthenticatorTransport{protocol.AuthenticatorTransport(pk.Transport)}
}
credentials[i] = webauthn.Credential{
ID: credentialIDBytes,
PublicKey: publicKeyBytes,
AttestationType: pk.AttestationType,
Transport: transport,
Flags: webauthn.CredentialFlags{
UserPresent: true,
UserVerified: true,
BackupEligible: pk.BackupEligible,
BackupState: pk.BackupState,
},
Authenticator: webauthn.Authenticator{
AAGUID: aaguidBytes,
SignCount: uint32(pk.SignCount),
},
}
}
return &WebAuthnUser{
User: &user,
Credentials: credentials,
}, nil
}
func (s *WebAuthnService) BeginRegistration(userID uint64) (*protocol.CredentialCreation, error) {
user, err := s.GetUserWithCredentials(userID)
if err != nil {
return nil, err
}
options, _, err := s.WebAuthn.BeginRegistration(user)
if err != nil {
return nil, err
}
return options, nil
}
func (s *WebAuthnService) FinishRegistration(userID uint64, response *http.Request, deviceName string) (*store.Passkey, error) {
user, err := s.GetUserWithCredentials(userID)
if err != nil {
return nil, err
}
credential, err := s.WebAuthn.FinishRegistration(user, webauthn.SessionData{}, response)
if err != nil {
return nil, err
}
var transport string
if len(credential.Transport) > 0 {
transport = string(credential.Transport[0])
}
passkey := &store.Passkey{
UserID: userID,
CredentialID: base64.StdEncoding.EncodeToString(credential.ID),
PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey),
AttestationType: string(credential.AttestationType),
AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
SignCount: uint64(credential.Authenticator.SignCount),
Name: deviceName,
DeviceType: strings.TrimSpace(fmt.Sprintf("%s", deviceName)),
LastUsedAt: time.Now().Unix(),
BackupEligible: credential.Flags.BackupEligible,
BackupState: credential.Flags.BackupState,
Transport: transport,
}
if err := s.DB.Create(passkey).Error; err != nil {
return nil, err
}
return passkey, nil
}
func (s *WebAuthnService) BeginLogin() (*protocol.CredentialAssertion, error) {
options, _, err := s.WebAuthn.BeginDiscoverableLogin()
if err != nil {
return nil, err
}
return options, nil
}
func (s *WebAuthnService) ListPasskeys(userID uint64) ([]store.Passkey, error) {
var passkeys []store.Passkey
if err := s.DB.Where("user_id = ?", userID).Find(&passkeys).Error; err != nil {
return nil, err
}
return passkeys, nil
}
func (s *WebAuthnService) DeletePasskey(userID uint64, passkeyID uint64) error {
return s.DB.Where("id = ? AND user_id = ?", passkeyID, userID).Delete(&store.Passkey{}).Error
}
+2 -10
View File
@@ -197,16 +197,8 @@ 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 string `gorm:"size:255;not null" json:"-"`
PublicKey string `gorm:"size:512;not null" json:"-"`
AttestationType string `gorm:"size:64" json:"-"`
AAGUID string `gorm:"size:64" json:"-"`
SignCount uint64 `json:"-"`
DeviceType string `gorm:"size:255" json:"device_type,omitempty"`
LastUsedAt int64 `json:"last_used_at,omitempty"`
BackupEligible bool `json:"-"`
BackupState bool `json:"-"`
Transport string `gorm:"size:32" json:"-"`
CredentialID []byte `gorm:"size:255;not null" json:"-"`
Credential []byte `gorm:"type:blob;not null" json:"-"`
CreatedAt time.Time `json:"created_at"`
}
+21 -1
View File
@@ -10,6 +10,7 @@ import (
"opencatd-open/internal/api"
"opencatd-open/internal/channel"
"opencatd-open/internal/dao"
"opencatd-open/internal/passkey"
"opencatd-open/internal/proxy"
"opencatd-open/internal/usage"
"opencatd-open/middleware"
@@ -61,8 +62,19 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
gateway := proxy.NewGateway(ctx, cfg, db, &wg, userDAO, apiKeyDAO, usageDAO, dailyDAO)
gateway.SetChannelService(channelSvc)
gateway.SetUsageRecorder(usageRecorder)
// Initialize passkey service
passkeySvc, err := passkey.New(db, passkey.Config{
RPID: cfg.RPID,
Origins: cfg.RPOrigins,
Name: cfg.AppName,
})
if err != nil {
log.Fatalf("Failed to initialize passkey service: %v", err)
}
// Initialize API handler
apiHandler := api.NewHandler(db)
apiHandler := api.NewHandler(db, passkeySvc)
r := gin.Default()
r.Use(middleware.CORS())
@@ -72,6 +84,8 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
{
public.POST("/register", apiHandler.Register)
public.POST("/login", apiHandler.Login)
public.POST("/passkey/begin", apiHandler.PasskeyLoginBegin)
public.POST("/passkey/finish", apiHandler.PasskeyLoginComplete)
}
// API routes (authenticated)
@@ -83,6 +97,12 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
apiGroup.POST("/profile/update", apiHandler.UpdateProfile)
apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword)
// Passkey management
apiGroup.POST("/webauthn/register/begin", apiHandler.PasskeyRegisterBegin)
apiGroup.POST("/webauthn/register/complete", apiHandler.PasskeyRegisterComplete)
apiGroup.GET("/webauthn/passkeys", apiHandler.PasskeyList)
apiGroup.DELETE("/webauthn/passkeys/:id", apiHandler.PasskeyDelete)
// User management (admin)
apiGroup.GET("/users", apiHandler.ListUsers)
apiGroup.GET("/users/:id", apiHandler.GetUser)
+21 -23
View File
@@ -16,26 +16,21 @@ export const useWebAuthStore = defineStore("webauth", () => {
const loading = ref(false);
const error = ref<string | null>(null);
const addPasskey = async () => {
const addPasskey = async (name?: string) => {
error.value = "";
loading.value = true;
try {
// 1. 从后端获取注册选项 (Creation Options)
const res = await request.get("/profile/passkey");
// console.log("begin:", res.data.data.publicKey);
const options = res.data.data.publicKey;
const res = await request.post("/webauthn/register/begin", {});
const { creation, challenge } = res.data.data;
// 调用 Web Authentication API 进行注册
// const credential = await navigator.credentials.create(options);
// console.log("credential:", credential);
let attestation;
try {
// Pass 'undefined' as the second argument if you are not using an AbortSignal
attestation = await startRegistration({ optionsJSON: options });
// console.log("WebAuthn 注册结果 (Attestation):", JSON.stringify(attestation));
attestation = await startRegistration({ optionsJSON: creation });
error.value = null;
} catch (regError: any) {
// console.log("WebAuthn 注册失败或取消:", regError);
if (regError.name === "NotAllowedError") {
error.value = "Passkey 操作被取消或不允许。";
} else {
@@ -45,8 +40,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
}
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
const res2: AxiosResponse = await request.post("/profile/passkey", attestation);
// console.log("end:", res2);
const res2: AxiosResponse = await request.post("/webauthn/register/complete", {
challenge,
name: name || "passkey",
credential: attestation,
});
return res2;
} catch (err: any) {
error.value = err.response?.data?.error || "添加 Passkey 失败,请稍后重试。";
@@ -56,20 +54,18 @@ export const useWebAuthStore = defineStore("webauth", () => {
}
};
const loginPasskey = async () => {
const loginPasskey = async (username?: string) => {
error.value = null;
loading.value = true;
try {
// 1. 从后端获取登录选项 (Assertion Options)
const res = await request.get("/auth/passkey/begin");
// console.log("login begin:", res.data);
const options = res.data.data.publicKey;
const res = await request.post("/auth/passkey/begin", { username });
const { assertion, challenge, user_id } = res.data.data;
// 2. 调用 Web Authentication API 进行认证
let assertion;
let credential;
try {
assertion = await startAuthentication({ optionsJSON: options });
// console.log("WebAuthn 认证结果 (Assertion):", JSON.stringify(assertion));
credential = await startAuthentication({ optionsJSON: assertion });
} catch (loginError: any) {
if (loginError.name === "NotAllowedError") {
error.value = "Passkey 登录被取消或不允许。";
@@ -80,8 +76,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
}
// 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token
const challenge = options.challenge; // 从 begin 接口返回的 options 中获取 challenge
const res2: AxiosResponse = await request.post(`/auth/passkey/finish?challenge=${challenge}`, assertion);
const res2: AxiosResponse = await request.post("/auth/passkey/finish", {
challenge,
credential,
user_id,
});
// 4. 处理登录成功的响应,通常包含 Token
if (res2.status === 200 && !!res2.data.data?.token) {
@@ -103,8 +102,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
loading.value = true;
error.value = null;
try {
const response = await request.get('/profile/passkeys')
// console.log('getPasskeys',response.data.data)
const response = await request.get('/webauthn/passkeys')
passkeys.value = response.data.data
} catch (err: any) {
error.value = err.response?.data?.error || '获取token列表失败';
@@ -118,7 +116,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/profile/passkeys/${id}`)
const response: AxiosResponse = await request.delete(`/webauthn/passkeys/${id}`)
return response
} catch (err: any) {
error.value = err.response?.data?.error || `删除passkey ${id} 失败`;
-4
View File
@@ -170,8 +170,6 @@
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th class="pl-4">Name</th>
<th>Create Time</th>
<th>Sign Count</th>
<th>Device</th>
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
@@ -179,8 +177,6 @@
<tr v-for="passkey in passkeys" :key="passkey.id" class="border-base-300/40 hover:bg-base-200/50">
<td class="pl-4 font-medium">{{ passkey.name }}</td>
<td class="tabular-nums text-base-content/70">{{ formatDateTime(passkey.created_at) }}</td>
<td class="tabular-nums">{{ passkey.sign_count }}</td>
<td class="text-base-content/70">{{ passkey.device_type }}</td>
<td class="pr-4 text-right">
<button class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmRmovePasskey(passkey)" aria-label="Delete passkey">