Files
opencatd-open/backend/internal/passkey/passkey.go
T
Sakurasan 9733b3c20b 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
2026-09-01 22:34:44 +08:00

276 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}