// 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() // 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 }