Files
openteam/server/internal/api/passkey.go
T
SakurasanandClaude 9324a782d5 Passkey: 账户设置绑定 + 免密登录(WebAuthn)
- 引入 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>
2026-08-16 02:00:08 +08:00

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