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:
@@ -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
|
||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"opencatd-open/internal/dao"
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/passkey"
|
||||||
"opencatd-open/internal/store"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/internal/pkg/apikey"
|
"opencatd-open/internal/pkg/apikey"
|
||||||
"opencatd-open/internal/pkg/crypto"
|
"opencatd-open/internal/pkg/crypto"
|
||||||
@@ -23,9 +24,10 @@ type Handler struct {
|
|||||||
modelDAO *dao.ModelDAO
|
modelDAO *dao.ModelDAO
|
||||||
usageDAO *dao.UsageDAO
|
usageDAO *dao.UsageDAO
|
||||||
dailyDAO *dao.DailyUsageDAO
|
dailyDAO *dao.DailyUsageDAO
|
||||||
|
passkeys *passkey.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *gorm.DB) *Handler {
|
func NewHandler(db *gorm.DB, passkeys *passkey.Service) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
userDAO: dao.NewUserDAO(db),
|
userDAO: dao.NewUserDAO(db),
|
||||||
@@ -34,6 +36,7 @@ func NewHandler(db *gorm.DB) *Handler {
|
|||||||
modelDAO: dao.NewModelDAO(db),
|
modelDAO: dao.NewModelDAO(db),
|
||||||
usageDAO: dao.NewUsageDAO(db),
|
usageDAO: dao.NewUsageDAO(db),
|
||||||
dailyDAO: dao.NewDailyUsageDAO(db),
|
dailyDAO: dao.NewDailyUsageDAO(db),
|
||||||
|
passkeys: passkeys,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}})
|
||||||
|
}
|
||||||
@@ -13,18 +13,16 @@ type Api struct {
|
|||||||
userService *service.UserServiceImpl
|
userService *service.UserServiceImpl
|
||||||
tokenService *service.TokenServiceImpl
|
tokenService *service.TokenServiceImpl
|
||||||
keyService *service.ApiKeyServiceImpl
|
keyService *service.ApiKeyServiceImpl
|
||||||
webAuthService *service.WebAuthnService
|
|
||||||
usageService *service.UsageService
|
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{
|
return &Api{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
db: db,
|
db: db,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
tokenService: tokenService,
|
tokenService: tokenService,
|
||||||
keyService: keyService,
|
keyService: keyService,
|
||||||
webAuthService: webAuthService,
|
|
||||||
usageService: usageService,
|
usageService: usageService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -197,16 +197,8 @@ type Passkey struct {
|
|||||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||||||
Name string `gorm:"size:64" json:"name"`
|
Name string `gorm:"size:64" json:"name"`
|
||||||
CredentialID string `gorm:"size:255;not null" json:"-"`
|
CredentialID []byte `gorm:"size:255;not null" json:"-"`
|
||||||
PublicKey string `gorm:"size:512;not null" json:"-"`
|
Credential []byte `gorm:"type:blob;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:"-"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"opencatd-open/internal/api"
|
"opencatd-open/internal/api"
|
||||||
"opencatd-open/internal/channel"
|
"opencatd-open/internal/channel"
|
||||||
"opencatd-open/internal/dao"
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/passkey"
|
||||||
"opencatd-open/internal/proxy"
|
"opencatd-open/internal/proxy"
|
||||||
"opencatd-open/internal/usage"
|
"opencatd-open/internal/usage"
|
||||||
"opencatd-open/middleware"
|
"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 := proxy.NewGateway(ctx, cfg, db, &wg, userDAO, apiKeyDAO, usageDAO, dailyDAO)
|
||||||
gateway.SetChannelService(channelSvc)
|
gateway.SetChannelService(channelSvc)
|
||||||
gateway.SetUsageRecorder(usageRecorder)
|
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
|
// Initialize API handler
|
||||||
apiHandler := api.NewHandler(db)
|
apiHandler := api.NewHandler(db, passkeySvc)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
r.Use(middleware.CORS())
|
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("/register", apiHandler.Register)
|
||||||
public.POST("/login", apiHandler.Login)
|
public.POST("/login", apiHandler.Login)
|
||||||
|
public.POST("/passkey/begin", apiHandler.PasskeyLoginBegin)
|
||||||
|
public.POST("/passkey/finish", apiHandler.PasskeyLoginComplete)
|
||||||
}
|
}
|
||||||
|
|
||||||
// API routes (authenticated)
|
// 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", apiHandler.UpdateProfile)
|
||||||
apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword)
|
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)
|
// User management (admin)
|
||||||
apiGroup.GET("/users", apiHandler.ListUsers)
|
apiGroup.GET("/users", apiHandler.ListUsers)
|
||||||
apiGroup.GET("/users/:id", apiHandler.GetUser)
|
apiGroup.GET("/users/:id", apiHandler.GetUser)
|
||||||
|
|||||||
@@ -16,26 +16,21 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
const addPasskey = async () => {
|
const addPasskey = async (name?: string) => {
|
||||||
error.value = "";
|
error.value = "";
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// 1. 从后端获取注册选项 (Creation Options)
|
// 1. 从后端获取注册选项 (Creation Options)
|
||||||
const res = await request.get("/profile/passkey");
|
const res = await request.post("/webauthn/register/begin", {});
|
||||||
// console.log("begin:", res.data.data.publicKey);
|
const { creation, challenge } = res.data.data;
|
||||||
const options = res.data.data.publicKey;
|
|
||||||
|
|
||||||
// 调用 Web Authentication API 进行注册
|
// 调用 Web Authentication API 进行注册
|
||||||
// const credential = await navigator.credentials.create(options);
|
|
||||||
// console.log("credential:", credential);
|
|
||||||
let attestation;
|
let attestation;
|
||||||
try {
|
try {
|
||||||
// Pass 'undefined' as the second argument if you are not using an AbortSignal
|
// Pass 'undefined' as the second argument if you are not using an AbortSignal
|
||||||
attestation = await startRegistration({ optionsJSON: options });
|
attestation = await startRegistration({ optionsJSON: creation });
|
||||||
// console.log("WebAuthn 注册结果 (Attestation):", JSON.stringify(attestation));
|
|
||||||
error.value = null;
|
error.value = null;
|
||||||
} catch (regError: any) {
|
} catch (regError: any) {
|
||||||
// console.log("WebAuthn 注册失败或取消:", regError);
|
|
||||||
if (regError.name === "NotAllowedError") {
|
if (regError.name === "NotAllowedError") {
|
||||||
error.value = "Passkey 操作被取消或不允许。";
|
error.value = "Passkey 操作被取消或不允许。";
|
||||||
} else {
|
} else {
|
||||||
@@ -45,8 +40,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
|
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
|
||||||
const res2: AxiosResponse = await request.post("/profile/passkey", attestation);
|
const res2: AxiosResponse = await request.post("/webauthn/register/complete", {
|
||||||
// console.log("end:", res2);
|
challenge,
|
||||||
|
name: name || "passkey",
|
||||||
|
credential: attestation,
|
||||||
|
});
|
||||||
return res2;
|
return res2;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.error || "添加 Passkey 失败,请稍后重试。";
|
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;
|
error.value = null;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// 1. 从后端获取登录选项 (Assertion Options)
|
// 1. 从后端获取登录选项 (Assertion Options)
|
||||||
const res = await request.get("/auth/passkey/begin");
|
const res = await request.post("/auth/passkey/begin", { username });
|
||||||
// console.log("login begin:", res.data);
|
const { assertion, challenge, user_id } = res.data.data;
|
||||||
const options = res.data.data.publicKey;
|
|
||||||
|
|
||||||
// 2. 调用 Web Authentication API 进行认证
|
// 2. 调用 Web Authentication API 进行认证
|
||||||
let assertion;
|
let credential;
|
||||||
try {
|
try {
|
||||||
assertion = await startAuthentication({ optionsJSON: options });
|
credential = await startAuthentication({ optionsJSON: assertion });
|
||||||
// console.log("WebAuthn 认证结果 (Assertion):", JSON.stringify(assertion));
|
|
||||||
} catch (loginError: any) {
|
} catch (loginError: any) {
|
||||||
if (loginError.name === "NotAllowedError") {
|
if (loginError.name === "NotAllowedError") {
|
||||||
error.value = "Passkey 登录被取消或不允许。";
|
error.value = "Passkey 登录被取消或不允许。";
|
||||||
@@ -80,8 +76,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token
|
// 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token
|
||||||
const challenge = options.challenge; // 从 begin 接口返回的 options 中获取 challenge
|
const res2: AxiosResponse = await request.post("/auth/passkey/finish", {
|
||||||
const res2: AxiosResponse = await request.post(`/auth/passkey/finish?challenge=${challenge}`, assertion);
|
challenge,
|
||||||
|
credential,
|
||||||
|
user_id,
|
||||||
|
});
|
||||||
|
|
||||||
// 4. 处理登录成功的响应,通常包含 Token
|
// 4. 处理登录成功的响应,通常包含 Token
|
||||||
if (res2.status === 200 && !!res2.data.data?.token) {
|
if (res2.status === 200 && !!res2.data.data?.token) {
|
||||||
@@ -103,8 +102,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const response = await request.get('/profile/passkeys')
|
const response = await request.get('/webauthn/passkeys')
|
||||||
// console.log('getPasskeys',response.data.data)
|
|
||||||
passkeys.value = response.data.data
|
passkeys.value = response.data.data
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.error || '获取token列表失败';
|
error.value = err.response?.data?.error || '获取token列表失败';
|
||||||
@@ -118,7 +116,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const response: AxiosResponse = await request.delete(`/profile/passkeys/${id}`)
|
const response: AxiosResponse = await request.delete(`/webauthn/passkeys/${id}`)
|
||||||
return response
|
return response
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.error || `删除passkey ${id} 失败`;
|
error.value = err.response?.data?.error || `删除passkey ${id} 失败`;
|
||||||
|
|||||||
@@ -170,8 +170,6 @@
|
|||||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
<th class="pl-4">Name</th>
|
<th class="pl-4">Name</th>
|
||||||
<th>Create Time</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>
|
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -179,8 +177,6 @@
|
|||||||
<tr v-for="passkey in passkeys" :key="passkey.id" class="border-base-300/40 hover:bg-base-200/50">
|
<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="pl-4 font-medium">{{ passkey.name }}</td>
|
||||||
<td class="tabular-nums text-base-content/70">{{ formatDateTime(passkey.created_at) }}</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">
|
<td class="pr-4 text-right">
|
||||||
<button class="btn btn-ghost btn-xs btn-square text-error"
|
<button class="btn btn-ghost btn-xs btn-square text-error"
|
||||||
@click="confirmRmovePasskey(passkey)" aria-label="Delete passkey">
|
@click="confirmRmovePasskey(passkey)" aria-label="Delete passkey">
|
||||||
|
|||||||
Reference in New Issue
Block a user