diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6da81f3 --- /dev/null +++ b/.env.example @@ -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 diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 849efe5..4f70c7c 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -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, } } diff --git a/backend/internal/api/passkey.go b/backend/internal/api/passkey.go new file mode 100644 index 0000000..f31b2e0 --- /dev/null +++ b/backend/internal/api/passkey.go @@ -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}}) +} diff --git a/backend/internal/controller/init.go b/backend/internal/controller/init.go index c3db7cc..b63eb68 100644 --- a/backend/internal/controller/init.go +++ b/backend/internal/controller/init.go @@ -8,23 +8,21 @@ import ( ) type Api struct { - cfg *config.Config - db *gorm.DB - userService *service.UserServiceImpl - tokenService *service.TokenServiceImpl - keyService *service.ApiKeyServiceImpl - webAuthService *service.WebAuthnService - usageService *service.UsageService + cfg *config.Config + db *gorm.DB + userService *service.UserServiceImpl + tokenService *service.TokenServiceImpl + keyService *service.ApiKeyServiceImpl + 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, + cfg: cfg, + db: db, + userService: userService, + tokenService: tokenService, + keyService: keyService, + usageService: usageService, } } diff --git a/backend/internal/dto/passkey.go b/backend/internal/dto/passkey.go deleted file mode 100644 index 5dd7aa5..0000000 --- a/backend/internal/dto/passkey.go +++ /dev/null @@ -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"` -} diff --git a/backend/internal/passkey/passkey.go b/backend/internal/passkey/passkey.go new file mode 100644 index 0000000..1742d5a --- /dev/null +++ b/backend/internal/passkey/passkey.go @@ -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 +} diff --git a/backend/internal/service/webauth.go b/backend/internal/service/webauth.go deleted file mode 100644 index b2ad7cd..0000000 --- a/backend/internal/service/webauth.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/store/models.go b/backend/internal/store/models.go index 5c02b39..79f23c5 100644 --- a/backend/internal/store/models.go +++ b/backend/internal/store/models.go @@ -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"` } diff --git a/backend/router/setRouter.go b/backend/router/setRouter.go index 5eca2b8..c19cd4f 100644 --- a/backend/router/setRouter.go +++ b/backend/router/setRouter.go @@ -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) diff --git a/frontend/src/stores/webauth.ts b/frontend/src/stores/webauth.ts index 76f1fcd..8d11df3 100644 --- a/frontend/src/stores/webauth.ts +++ b/frontend/src/stores/webauth.ts @@ -16,26 +16,21 @@ export const useWebAuthStore = defineStore("webauth", () => { const loading = ref(false); const error = ref(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} 失败`; diff --git a/frontend/src/views/dashboard/Profile.vue b/frontend/src/views/dashboard/Profile.vue index 0532aed..cb76109 100644 --- a/frontend/src/views/dashboard/Profile.vue +++ b/frontend/src/views/dashboard/Profile.vue @@ -170,8 +170,6 @@ Name Create Time - Sign Count - Device Actions @@ -179,8 +177,6 @@ {{ passkey.name }} {{ formatDateTime(passkey.created_at) }} - {{ passkey.sign_count }} - {{ passkey.device_type }}