- 引入 go-webauthn, Passkey 表存凭据, challenge 会话内存存储(带过期) - API: /webauthn/register|login begin/complete, /webauthn/passkeys 列表/删除 - 配置 OT_WEBAUTHN_RP_ID/RP_ORIGIN/RP_NAME;登录成功发 JWT+refresh cookie - 前端 lib/webauthn(编解码+凭据序列化+安全上下文检测), 账户设置绑定区, 登录页免密按钮 - 需 HTTPS 或 localhost(安全上下文) Co-Authored-By: Claude <noreply@anthropic.com>
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/openteam/server/internal/pkg/resp"
|
|
"github.com/openteam/server/internal/store"
|
|
)
|
|
|
|
// PasskeyRegisterBegin POST /api/v1/webauthn/register/begin — 生成注册选项。
|
|
func (h *Handler) PasskeyRegisterBegin(c *gin.Context) {
|
|
u := sessionUser(c)
|
|
creation, err := h.passkeys.BeginRegistration(u)
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to begin registration: "+err.Error())
|
|
return
|
|
}
|
|
resp.OK(c, gin.H{"creation": creation, "challenge": creation.Response.Challenge})
|
|
}
|
|
|
|
// PasskeyRegisterComplete POST /api/v1/webauthn/register/complete — 校验并保存凭据。
|
|
func (h *Handler) PasskeyRegisterComplete(c *gin.Context) {
|
|
u := sessionUser(c)
|
|
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 {
|
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
|
return
|
|
}
|
|
if err := h.passkeys.FinishRegistration(u, req.Challenge, req.Credential, []byte(req.Name)); err != nil {
|
|
resp.Fail(c, http.StatusBadRequest, "passkey 注册失败: "+err.Error())
|
|
return
|
|
}
|
|
resp.OK(c, gin.H{"ok": true})
|
|
}
|
|
|
|
// PasskeyLoginBegin POST /api/v1/webauthn/login/begin — 生成断言选项。
|
|
// 传 username 用指定用户;不传则用可发现凭据(平台 passkey)。
|
|
func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
}
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Username != "" {
|
|
var u store.User
|
|
err := h.a.DB.Where("username = ? OR email = ?", req.Username, req.Username).First(&u).Error
|
|
if err != nil || u.Status != store.UserStatusActive {
|
|
resp.Fail(c, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
assertion, err := h.passkeys.BeginLogin(&u)
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error())
|
|
return
|
|
}
|
|
resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge, "user_id": u.ID})
|
|
return
|
|
}
|
|
assertion, err := h.passkeys.BeginDiscoverableLogin()
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error())
|
|
return
|
|
}
|
|
resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge})
|
|
}
|
|
|
|
// PasskeyLoginComplete POST /api/v1/webauthn/login/complete — 校验断言并发放令牌。
|
|
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 {
|
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
|
return
|
|
}
|
|
var u *store.User
|
|
if req.UserID > 0 {
|
|
var su store.User
|
|
if err := h.a.DB.First(&su, req.UserID).Error; err != nil {
|
|
resp.Fail(c, http.StatusUnauthorized, "user not found")
|
|
return
|
|
}
|
|
u = &su
|
|
if err := h.passkeys.FinishLogin(u, req.Challenge, req.Credential); err != nil {
|
|
resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error())
|
|
return
|
|
}
|
|
} else {
|
|
var err error
|
|
u, err = h.passkeys.FinishDiscoverableLogin(req.Challenge, req.Credential)
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
if u.Status != store.UserStatusActive {
|
|
resp.Fail(c, http.StatusForbidden, "user account disabled")
|
|
return
|
|
}
|
|
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
|
return
|
|
}
|
|
refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh")
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
|
return
|
|
}
|
|
h.setRefreshCookie(c, refresh)
|
|
resp.OK(c, gin.H{
|
|
"access_token": access,
|
|
"expires_in": int(h.a.JWT.AccessTTL().Seconds()),
|
|
"user": h.publicUser(u),
|
|
})
|
|
}
|
|
|
|
// PasskeyList GET /api/v1/webauthn/passkeys — 当前用户的 passkey 列表。
|
|
func (h *Handler) PasskeyList(c *gin.Context) {
|
|
u := sessionUser(c)
|
|
pks, err := h.passkeys.List(u.ID)
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "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})
|
|
}
|
|
resp.OK(c, gin.H{"items": out})
|
|
}
|
|
|
|
// PasskeyDelete DELETE /api/v1/webauthn/passkeys/:id — 解除绑定。
|
|
func (h *Handler) PasskeyDelete(c *gin.Context) {
|
|
u := sessionUser(c)
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusBadRequest, "invalid passkey id")
|
|
return
|
|
}
|
|
if err := h.passkeys.Delete(u.ID, id); err != nil {
|
|
resp.Fail(c, http.StatusNotFound, "passkey not found")
|
|
return
|
|
}
|
|
resp.OK(c, gin.H{"ok": true})
|
|
}
|