- users 加 token_version(schema v4):改密/登出递增,该用户全部已发 access/refresh 立即作废;refresh 改轮换式换发新 cookie - 邀请码改精确匹配 + 注册成功后消费(一码一用),修复子串匹配让任意后缀放行、可无限薅体验金 - 生产校验拦下 .env.example 占位密钥;OT_ENV 枚举校验(拼错不再静默跳过断言);生产强制 COOKIE_SECURE;非 development 拒绝默认密码播种管理员 - WebAuthn 会话启用过期校验 + 4096 容量上限(未认证 login/begin 可无限撑爆内存),begin/complete 挂登录级限速 - /api/v1 挂 1MB 请求体上限;login 先限 IP 桶再解析 body;全局安全响应头 + CSP - 连接池配置(PG 30/SQLite 8);SQLite DSN 自动追加 WAL + busy_timeout(驱动只认 _pragma,旧注释的 _journal_mode 写法不生效)
163 lines
5.3 KiB
Go
163 lines
5.3 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"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)。
|
|
// 无需认证且每次调用都会生成并暂存一个 challenge,必须与登录同级限速。
|
|
func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
|
|
if h.authLimited(c, "webauthn:ip:"+c.ClientIP(), h.a.Cfg.RateLimit.LoginIPPerMin, time.Minute) {
|
|
return
|
|
}
|
|
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) {
|
|
if h.authLimited(c, "webauthn:ip:"+c.ClientIP(), h.a.Cfg.RateLimit.LoginIPPerMin, time.Minute) {
|
|
return
|
|
}
|
|
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", u.TokenVersion)
|
|
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", u.TokenVersion)
|
|
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})
|
|
}
|