- 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
163 lines
5.4 KiB
Go
163 lines
5.4 KiB
Go
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}})
|
|
}
|