Some checks are pending
Security Checks / security (push) Waiting to run
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
555 lines
18 KiB
Go
555 lines
18 KiB
Go
package webauthnservice
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/models"
|
|
"git.pcux.cn/xuans/Smail/utils/credential"
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
webauthn "github.com/go-webauthn/webauthn/webauthn"
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
var (
|
|
ErrUnavailable = errors.New("webauthn service unavailable")
|
|
ErrInvalidRequest = errors.New("webauthn request is invalid")
|
|
ErrSessionNotFound = errors.New("webauthn ceremony session is invalid")
|
|
ErrSessionExpired = errors.New("webauthn ceremony session expired")
|
|
ErrCredentialMissing = errors.New("webauthn credential is missing")
|
|
ErrCredentialExists = errors.New("webauthn credential already exists")
|
|
)
|
|
|
|
const (
|
|
ceremonyTTL = 5 * time.Minute
|
|
maxSessions = 1024
|
|
)
|
|
|
|
// Config 保存 WebAuthn 信赖方和凭据加密存储配置。
|
|
type Config struct {
|
|
RPID string
|
|
RPDisplayName string
|
|
RPOrigins []string
|
|
VaultRoot string
|
|
}
|
|
|
|
// Service 管理 WebAuthn 注册、登录和凭据生命周期。
|
|
type Service struct {
|
|
engine *xorm.Engine
|
|
vault string
|
|
authn *webauthn.WebAuthn
|
|
|
|
mu sync.Mutex
|
|
credentialMu sync.Mutex
|
|
sessions map[string]ceremony
|
|
now func() time.Time
|
|
}
|
|
|
|
type ceremony struct {
|
|
userID int
|
|
kind ceremonyKind
|
|
data webauthn.SessionData
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type ceremonyKind uint8
|
|
|
|
const (
|
|
registrationCeremony ceremonyKind = iota + 1
|
|
loginCeremony
|
|
discoverableLoginCeremony
|
|
)
|
|
|
|
// NewService 创建 WebAuthn 服务并校验必要配置。
|
|
func NewService(engine *xorm.Engine, cfg Config) (*Service, error) {
|
|
if engine == nil || strings.TrimSpace(cfg.RPID) == "" || strings.TrimSpace(cfg.RPDisplayName) == "" || len(cfg.RPOrigins) == 0 || strings.TrimSpace(cfg.VaultRoot) == "" {
|
|
return nil, ErrUnavailable
|
|
}
|
|
authn, err := webauthn.New(&webauthn.Config{
|
|
RPID: strings.TrimSpace(cfg.RPID),
|
|
RPDisplayName: strings.TrimSpace(cfg.RPDisplayName),
|
|
RPOrigins: append([]string(nil), cfg.RPOrigins...),
|
|
AttestationPreference: protocol.PreferNoAttestation,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create webauthn configuration: %w", err)
|
|
}
|
|
return &Service{engine: engine, vault: cfg.VaultRoot, authn: authn, sessions: make(map[string]ceremony), now: time.Now}, nil
|
|
}
|
|
|
|
// BeginRegistration 开始指定用户的 WebAuthn 注册流程。
|
|
func (s *Service) BeginRegistration(userID int) (*protocol.CredentialCreation, string, error) {
|
|
user, err := s.loadUser(userID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
adapter, err := s.loadWebAuthnUser(user)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
creation, data, err := s.authn.BeginRegistration(adapter, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("begin webauthn registration: %w", err)
|
|
}
|
|
sessionID, err := s.putSession(ceremony{userID: user.ID, kind: registrationCeremony, data: *data, expiresAt: s.now().Add(ceremonyTTL)})
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return creation, sessionID, nil
|
|
}
|
|
|
|
// FinishRegistration 完成指定用户的 WebAuthn 注册流程。
|
|
func (s *Service) FinishRegistration(userID int, sessionID string, request *http.Request, displayName string) (*models.WebAuthnCredential, error) {
|
|
item, err := s.takeSession(sessionID, registrationCeremony, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if request == nil || strings.TrimSpace(displayName) == "" || len([]byte(displayName)) > 128 {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
user, err := s.loadUser(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adapter, err := s.loadWebAuthnUser(user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
validated, err := s.authn.FinishRegistration(adapter, item.data, request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finish webauthn registration: %w", err)
|
|
}
|
|
credentialID := base64.RawURLEncoding.EncodeToString(validated.ID)
|
|
if credentialID == "" {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
var existing models.WebAuthnCredential
|
|
has, err := s.engine.Where("credential_id = ?", credentialID).Get(&existing)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check webauthn credential: %w", err)
|
|
}
|
|
if has {
|
|
return nil, ErrCredentialExists
|
|
}
|
|
|
|
encoded, err := json.Marshal(validated)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode webauthn credential: %w", err)
|
|
}
|
|
protected, err := credential.Encrypt(s.vault, string(encoded))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("protect webauthn credential: %w", err)
|
|
}
|
|
record := &models.WebAuthnCredential{
|
|
UserID: userID,
|
|
CredentialID: credentialID,
|
|
CredentialData: protected,
|
|
DisplayName: strings.TrimSpace(displayName),
|
|
Discoverable: true,
|
|
CreatedAt: s.now(),
|
|
}
|
|
if _, err := s.engine.Insert(record); err != nil {
|
|
return nil, fmt.Errorf("save webauthn credential: %w", err)
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
// BeginLogin 开始指定用户的 WebAuthn 登录流程。
|
|
func (s *Service) BeginLogin(userID int) (*protocol.CredentialAssertion, string, error) {
|
|
user, err := s.loadUser(userID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
adapter, err := s.loadWebAuthnUser(user)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
assertion, data, err := s.authn.BeginLogin(adapter)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("begin webauthn login: %w", err)
|
|
}
|
|
sessionID, err := s.putSession(ceremony{userID: user.ID, kind: loginCeremony, data: *data, expiresAt: s.now().Add(ceremonyTTL)})
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return assertion, sessionID, nil
|
|
}
|
|
|
|
// FinishLogin 完成指定用户的 WebAuthn 登录流程。
|
|
func (s *Service) FinishLogin(userID int, sessionID string, request *http.Request) (*models.WebAuthnCredential, error) {
|
|
item, err := s.takeSession(sessionID, loginCeremony, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if request == nil {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
user, err := s.loadUser(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adapter, err := s.loadWebAuthnUser(user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
validated, err := s.authn.FinishLogin(adapter, item.data, request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finish webauthn login: %w", err)
|
|
}
|
|
credentialID := base64.RawURLEncoding.EncodeToString(validated.ID)
|
|
s.credentialMu.Lock()
|
|
defer s.credentialMu.Unlock()
|
|
var record models.WebAuthnCredential
|
|
has, err := s.engine.Where("user_id = ? AND credential_id = ? AND revoked_at IS NULL AND discoverable = ?", userID, credentialID, true).Get(&record)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load webauthn login credential: %w", err)
|
|
}
|
|
if !has {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
|
|
currentPlain, err := credential.Decrypt(s.vault, record.CredentialData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt current webauthn credential: %w", err)
|
|
}
|
|
var current webauthn.Credential
|
|
if err := json.Unmarshal([]byte(currentPlain), ¤t); err != nil {
|
|
return nil, fmt.Errorf("decode current webauthn credential: %w", err)
|
|
}
|
|
if validated.Authenticator.SignCount < current.Authenticator.SignCount {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
encoded, err := json.Marshal(validated)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode webauthn login credential: %w", err)
|
|
}
|
|
protected, err := credential.Encrypt(s.vault, string(encoded))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("protect webauthn login credential: %w", err)
|
|
}
|
|
now := s.now()
|
|
updated := &models.WebAuthnCredential{CredentialData: protected, LastUsedAt: &now}
|
|
if affected, err := s.engine.ID(record.ID).Cols("credential_data", "last_used_at").Update(updated); err != nil {
|
|
return nil, fmt.Errorf("update webauthn login credential: %w", err)
|
|
} else if affected != 1 {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
record.CredentialData = protected
|
|
record.LastUsedAt = &now
|
|
return &record, nil
|
|
}
|
|
|
|
func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, string, error) {
|
|
if s == nil || s.authn == nil {
|
|
return nil, "", ErrUnavailable
|
|
}
|
|
assertion, data, err := s.authn.BeginDiscoverableLogin()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("begin discoverable webauthn login: %w", err)
|
|
}
|
|
sessionID, err := s.putSession(ceremony{kind: discoverableLoginCeremony, data: *data, expiresAt: s.now().Add(ceremonyTTL)})
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return assertion, sessionID, nil
|
|
}
|
|
|
|
func (s *Service) FinishDiscoverableLogin(sessionID string, request *http.Request) (*models.User, *models.WebAuthnCredential, error) {
|
|
item, err := s.takeDiscoverableSession(sessionID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if request == nil {
|
|
return nil, nil, ErrInvalidRequest
|
|
}
|
|
var selected *webAuthnUser
|
|
validated, err := s.authn.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
|
|
adapter, loadErr := s.loadDiscoverableUser(rawID, userHandle)
|
|
if loadErr != nil {
|
|
return nil, loadErr
|
|
}
|
|
selected = adapter
|
|
return adapter, nil
|
|
}, item.data, request)
|
|
if err != nil || selected == nil {
|
|
return nil, nil, ErrCredentialMissing
|
|
}
|
|
record, err := s.persistLoginCredential(selected.user.ID, validated)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
user := selected.user
|
|
return &user, record, nil
|
|
}
|
|
|
|
// ListCredentials 列出指定用户的非敏感 WebAuthn 凭据摘要。
|
|
func (s *Service) ListCredentials(userID int) ([]models.WebAuthnCredential, error) {
|
|
if s == nil || s.engine == nil || userID <= 0 {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
var records []models.WebAuthnCredential
|
|
if err := s.engine.Where("user_id = ?", userID).Cols("id", "user_id", "credential_id", "display_name", "discoverable", "created_at", "last_used_at", "revoked_at").Find(&records); err != nil {
|
|
return nil, fmt.Errorf("list webauthn credentials: %w", err)
|
|
}
|
|
for index := range records {
|
|
records[index].CredentialData = ""
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
// RenameCredential 修改指定用户凭据的显示名称。
|
|
func (s *Service) RenameCredential(userID, credentialID int, displayName string) error {
|
|
if userID <= 0 || credentialID <= 0 || strings.TrimSpace(displayName) == "" || len([]byte(displayName)) > 128 {
|
|
return ErrInvalidRequest
|
|
}
|
|
if affected, err := s.engine.ID(credentialID).Where("user_id = ? AND revoked_at IS NULL", userID).Cols("display_name").Update(&models.WebAuthnCredential{DisplayName: strings.TrimSpace(displayName)}); err != nil {
|
|
return fmt.Errorf("rename webauthn credential: %w", err)
|
|
} else if affected != 1 {
|
|
return ErrCredentialMissing
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeCredential 撤销指定用户的一项 WebAuthn 凭据。
|
|
func (s *Service) RevokeCredential(userID, credentialID int) error {
|
|
if userID <= 0 || credentialID <= 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
now := s.now()
|
|
if affected, err := s.engine.ID(credentialID).Where("user_id = ? AND revoked_at IS NULL", userID).Cols("revoked_at").Update(&models.WebAuthnCredential{RevokedAt: &now}); err != nil {
|
|
return fmt.Errorf("revoke webauthn credential: %w", err)
|
|
} else if affected != 1 {
|
|
return ErrCredentialMissing
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeAllCredentials 撤销指定用户的全部 WebAuthn 凭据。
|
|
func (s *Service) RevokeAllCredentials(userID int) error {
|
|
if userID <= 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
now := s.now()
|
|
if _, err := s.engine.Where("user_id = ? AND revoked_at IS NULL", userID).Cols("revoked_at").Update(&models.WebAuthnCredential{RevokedAt: &now}); err != nil {
|
|
return fmt.Errorf("revoke all webauthn credentials: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) loadUser(userID int) (*models.User, error) {
|
|
if s == nil || s.engine == nil || s.authn == nil || userID <= 0 {
|
|
return nil, ErrUnavailable
|
|
}
|
|
var user models.User
|
|
has, err := s.engine.Where("id = ? AND disabled = 0", userID).Get(&user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load webauthn user: %w", err)
|
|
}
|
|
if !has {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (s *Service) loadWebAuthnUser(user *models.User) (*webAuthnUser, error) {
|
|
if user == nil {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
var records []models.WebAuthnCredential
|
|
if err := s.engine.Where("user_id = ? AND revoked_at IS NULL AND discoverable = ?", user.ID, true).Find(&records); err != nil {
|
|
return nil, fmt.Errorf("load webauthn credentials: %w", err)
|
|
}
|
|
result := make([]webauthn.Credential, 0, len(records))
|
|
for _, record := range records {
|
|
plain, err := credential.Decrypt(s.vault, record.CredentialData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt webauthn credential: %w", err)
|
|
}
|
|
var item webauthn.Credential
|
|
if err := json.Unmarshal([]byte(plain), &item); err != nil {
|
|
return nil, fmt.Errorf("decode webauthn credential: %w", err)
|
|
}
|
|
if base64.RawURLEncoding.EncodeToString(item.ID) != record.CredentialID {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
result = append(result, item)
|
|
}
|
|
return &webAuthnUser{user: *user, credentials: result}, nil
|
|
}
|
|
|
|
func (s *Service) loadDiscoverableUser(rawID, userHandle []byte) (*webAuthnUser, error) {
|
|
const prefix = "smail-user-"
|
|
if !bytes.HasPrefix(userHandle, []byte(prefix)) || len(rawID) == 0 {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
userID, err := strconv.Atoi(string(userHandle[len(prefix):]))
|
|
if err != nil || userID <= 0 {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
user, err := s.loadUser(userID)
|
|
if err != nil {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
adapter, err := s.loadWebAuthnUser(user)
|
|
if err != nil {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
for _, item := range adapter.credentials {
|
|
if bytes.Equal(item.ID, rawID) {
|
|
return adapter, nil
|
|
}
|
|
}
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
|
|
func (s *Service) persistLoginCredential(userID int, validated *webauthn.Credential) (*models.WebAuthnCredential, error) {
|
|
if validated == nil || userID <= 0 {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
credentialID := base64.RawURLEncoding.EncodeToString(validated.ID)
|
|
s.credentialMu.Lock()
|
|
defer s.credentialMu.Unlock()
|
|
var record models.WebAuthnCredential
|
|
has, err := s.engine.Where("user_id = ? AND credential_id = ? AND revoked_at IS NULL", userID, credentialID).Get(&record)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load webauthn login credential: %w", err)
|
|
}
|
|
if !has {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
currentPlain, err := credential.Decrypt(s.vault, record.CredentialData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt current webauthn credential: %w", err)
|
|
}
|
|
var current webauthn.Credential
|
|
if err := json.Unmarshal([]byte(currentPlain), ¤t); err != nil {
|
|
return nil, fmt.Errorf("decode current webauthn credential: %w", err)
|
|
}
|
|
if validated.Authenticator.SignCount < current.Authenticator.SignCount {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
encoded, err := json.Marshal(validated)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode webauthn login credential: %w", err)
|
|
}
|
|
protected, err := credential.Encrypt(s.vault, string(encoded))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("protect webauthn login credential: %w", err)
|
|
}
|
|
now := s.now()
|
|
updated := &models.WebAuthnCredential{CredentialData: protected, LastUsedAt: &now}
|
|
if affected, err := s.engine.ID(record.ID).Cols("credential_data", "last_used_at").Update(updated); err != nil {
|
|
return nil, fmt.Errorf("update webauthn login credential: %w", err)
|
|
} else if affected != 1 {
|
|
return nil, ErrCredentialMissing
|
|
}
|
|
record.CredentialData = protected
|
|
record.LastUsedAt = &now
|
|
return &record, nil
|
|
}
|
|
|
|
func (s *Service) putSession(item ceremony) (string, error) {
|
|
if s == nil {
|
|
return "", ErrUnavailable
|
|
}
|
|
tokenBytes := make([]byte, 32)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
return "", fmt.Errorf("create webauthn session: %w", err)
|
|
}
|
|
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.pruneSessionsLocked(s.now())
|
|
if len(s.sessions) >= maxSessions {
|
|
return "", ErrUnavailable
|
|
}
|
|
s.sessions[token] = item
|
|
return token, nil
|
|
}
|
|
|
|
func (s *Service) takeDiscoverableSession(token string) (ceremony, error) {
|
|
if s == nil || strings.TrimSpace(token) == "" {
|
|
return ceremony{}, ErrSessionNotFound
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
item, ok := s.sessions[token]
|
|
if !ok {
|
|
return ceremony{}, ErrSessionNotFound
|
|
}
|
|
delete(s.sessions, token)
|
|
if item.kind != discoverableLoginCeremony {
|
|
return ceremony{}, ErrSessionNotFound
|
|
}
|
|
now := s.now()
|
|
if !item.expiresAt.After(now) || (!item.data.Expires.IsZero() && !item.data.Expires.After(now)) {
|
|
return ceremony{}, ErrSessionExpired
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Service) takeSession(token string, kind ceremonyKind, userID int) (ceremony, error) {
|
|
if s == nil || strings.TrimSpace(token) == "" || userID <= 0 {
|
|
return ceremony{}, ErrSessionNotFound
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
item, ok := s.sessions[token]
|
|
if !ok {
|
|
return ceremony{}, ErrSessionNotFound
|
|
}
|
|
delete(s.sessions, token)
|
|
if item.kind != kind || item.userID != userID {
|
|
return ceremony{}, ErrSessionNotFound
|
|
}
|
|
now := s.now()
|
|
if !item.expiresAt.After(now) || (!item.data.Expires.IsZero() && !item.data.Expires.After(now)) {
|
|
return ceremony{}, ErrSessionExpired
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
// InvalidateUser 删除指定用户尚未完成的全部 WebAuthn 仪式会话。
|
|
func (s *Service) InvalidateUser(userID int) {
|
|
if s == nil || userID <= 0 {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for token, item := range s.sessions {
|
|
if item.userID == userID {
|
|
delete(s.sessions, token)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) pruneSessionsLocked(now time.Time) {
|
|
for token, item := range s.sessions {
|
|
if !item.expiresAt.After(now) || (!item.data.Expires.IsZero() && !item.data.Expires.After(now)) {
|
|
delete(s.sessions, token)
|
|
}
|
|
}
|
|
}
|
|
|
|
type webAuthnUser struct {
|
|
user models.User
|
|
credentials []webauthn.Credential
|
|
}
|
|
|
|
func (u *webAuthnUser) WebAuthnID() []byte { return []byte(fmt.Sprintf("smail-user-%d", u.user.ID)) }
|
|
|
|
func (u *webAuthnUser) WebAuthnName() string { return u.user.Account }
|
|
|
|
func (u *webAuthnUser) WebAuthnDisplayName() string { return u.user.Name }
|
|
|
|
func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|