security: 会话可撤销与认证面加固
- 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 写法不生效)
This commit is contained in:
1 parent
d6db0f8162
commit
e3ff0d3329
14 files changed
+392
-37
No files matched your search
+119
-15
@@ -3,6 +3,8 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/proxy"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler 聚合所有管理 API。
|
||||
@@ -73,14 +76,31 @@ func (h *Handler) Register(c *gin.Context) {
|
||||
mode = modeCfg
|
||||
}
|
||||
if mode == "invite" {
|
||||
var icRaw string
|
||||
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &icRaw)
|
||||
var ic string
|
||||
_ = json.Unmarshal([]byte(icRaw), &ic)
|
||||
if req.InviteCode == "" || !strings.Contains(ic, req.InviteCode) {
|
||||
codes, err := loadInviteCodes(h.a.DB)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to load invite codes")
|
||||
return
|
||||
}
|
||||
// 精确匹配:子串匹配(Contains)会让任一真实邀请码的任意后缀都放行
|
||||
matched := false
|
||||
for _, code := range codes {
|
||||
if req.InviteCode == code {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if req.InviteCode == "" || !matched {
|
||||
resp.Fail(c, http.StatusForbidden, "valid invite code required")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
// 一码一用:注册成功(2xx,Created=201)才消费,避免失败请求作废邀请码
|
||||
if s := c.Writer.Status(); s >= 200 && s < 300 {
|
||||
if err := consumeInviteCode(h.a.DB, req.InviteCode); err != nil {
|
||||
log.Printf("invite: failed to consume code after registration: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
var count int64
|
||||
h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, req.Email).Count(&count)
|
||||
@@ -119,16 +139,17 @@ type loginReq struct {
|
||||
|
||||
// Login POST /api/v1/auth/login — 返回 access token,refresh token 写入 HttpOnly Cookie。
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
// 先限 IP 桶再解析 body(与 Register 同策略):登录是未认证面,不先给大请求做绑定
|
||||
ip := c.ClientIP()
|
||||
if h.authLimited(c, "login:ip:"+ip, h.a.Cfg.RateLimit.LoginIPPerMin, time.Minute) {
|
||||
return
|
||||
}
|
||||
var req loginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
// 两个桶:单 IP 总量挡扫库式爆破,单 IP×账号挡换 IP 定向爆破同一个账号
|
||||
ip := c.ClientIP()
|
||||
if h.authLimited(c, "login:ip:"+ip, h.a.Cfg.RateLimit.LoginIPPerMin, time.Minute) {
|
||||
return
|
||||
}
|
||||
// 单 IP×账号桶:挡换 IP 定向爆破同一个账号
|
||||
if h.authLimited(c, "login:u:"+ip+"|"+strings.ToLower(req.Username), h.a.Cfg.RateLimit.LoginUserPerMin, time.Minute) {
|
||||
return
|
||||
}
|
||||
@@ -146,12 +167,12 @@ func (h *Handler) Login(c *gin.Context) {
|
||||
now := time.Now()
|
||||
h.a.DB.Model(&u).Update("last_login_at", now)
|
||||
|
||||
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
|
||||
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")
|
||||
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
|
||||
@@ -177,16 +198,35 @@ func (h *Handler) Refresh(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusUnauthorized, "user not found or disabled")
|
||||
return
|
||||
}
|
||||
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
|
||||
if claims.Ver != u.TokenVersion {
|
||||
// 改密/登出已递增会话版本:全部旧 refresh 作废
|
||||
resp.Fail(c, http.StatusUnauthorized, "session revoked, please sign in again")
|
||||
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 cookie(滑动续期):单个 refresh token 的暴露窗口不超过一个刷新间隔
|
||||
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())})
|
||||
}
|
||||
|
||||
// Logout POST /api/v1/auth/logout — 清除 refresh cookie。
|
||||
// Logout POST /api/v1/auth/logout — 服务端撤销会话并清除 refresh cookie。
|
||||
// TokenVersion 递增使该用户全部已发 access/refresh 失效(含其他设备);cookie 清理只是客户端侧补充。
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
if u, ok := userFromContext(c); ok {
|
||||
if err := h.a.DB.Model(&store.User{}).Where("id = ?", u.ID).
|
||||
Update("token_version", gorm.Expr("token_version + 1")).Error; err != nil {
|
||||
log.Printf("auth: failed to bump token version on logout for user %d: %v", u.ID, err)
|
||||
}
|
||||
}
|
||||
h.setRefreshCookie(c, "")
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -214,7 +254,11 @@ func (h *Handler) ChangePassword(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
if err := h.a.DB.Model(&store.User{}).Where("id = ?", u.ID).Update("password_hash", hash).Error; err != nil {
|
||||
// 密码与会话版本同语句更新:改密后该用户全部已发 token 立即作废
|
||||
if err := h.a.DB.Model(&store.User{}).Where("id = ?", u.ID).Updates(map[string]any{
|
||||
"password_hash": hash,
|
||||
"token_version": gorm.Expr("token_version + 1"),
|
||||
}).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update password")
|
||||
return
|
||||
}
|
||||
@@ -270,3 +314,63 @@ func userFromContext(c *gin.Context) (*store.User, bool) {
|
||||
}
|
||||
return u.(*store.User), true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 邀请码
|
||||
|
||||
// loadInviteCodes 读取邀请码列表。兼容两种存储形态:历史版本存单个 JSON 字符串
|
||||
// (逗号/分号/空白分隔),现在统一以 JSON 数组写回。
|
||||
func loadInviteCodes(db *gorm.DB) ([]string, error) {
|
||||
var raw string
|
||||
if err := db.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &raw).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var arr []string
|
||||
if json.Unmarshal([]byte(raw), &arr) == nil {
|
||||
return trimCodes(arr), nil
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal([]byte(raw), &s) == nil {
|
||||
return trimCodes(strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
|
||||
})), nil
|
||||
}
|
||||
return nil, fmt.Errorf("invite_codes 配置格式无法解析: %s", raw)
|
||||
}
|
||||
|
||||
// consumeInviteCode 把已使用的邀请码从列表移除(一码一用)。码不存在时静默成功。
|
||||
// 写回保持「JSON 字符串 + 逗号分隔」的历史形态,管理后台的输入框按该格式展示编辑。
|
||||
func consumeInviteCode(db *gorm.DB, code string) error {
|
||||
codes, err := loadInviteCodes(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rest := make([]string, 0, len(codes))
|
||||
found := false
|
||||
for _, c := range codes {
|
||||
if c == code && !found {
|
||||
found = true // 同码重复录入只删一个
|
||||
continue
|
||||
}
|
||||
rest = append(rest, c)
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
raw, _ := json.Marshal(strings.Join(rest, ","))
|
||||
return db.Save(&store.SystemConfig{Key: "invite_codes", Value: string(raw)}).Error
|
||||
}
|
||||
|
||||
func trimCodes(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 邀请码必须精确匹配(子串匹配会让任意后缀放行)且用后消费(一码一用)。
|
||||
func TestInviteCodesLoadAndConsume(t *testing.T) {
|
||||
db := openInviteTestDB(t)
|
||||
|
||||
// 历史形态:JSON 字符串 + 逗号分隔
|
||||
if err := db.Create(&store.SystemConfig{Key: "invite_codes", Value: `"ALPHA-2026, BETA-7;"`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes, err := loadInviteCodes(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(codes) != 2 || codes[0] != "ALPHA-2026" || codes[1] != "BETA-7" {
|
||||
t.Fatalf("应解析出 2 个邀请码,实际 %v", codes)
|
||||
}
|
||||
|
||||
// 消费:精确码被移除,其余保留;写回仍是字符串形态(管理后台按逗号分隔编辑)
|
||||
if err := consumeInviteCode(db, "ALPHA-2026"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes, _ = loadInviteCodes(db)
|
||||
if len(codes) != 1 || codes[0] != "BETA-7" {
|
||||
t.Fatalf("消费后应剩 1 个邀请码,实际 %v", codes)
|
||||
}
|
||||
var raw string
|
||||
db.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &raw)
|
||||
if raw != `"BETA-7"` {
|
||||
t.Fatalf("写回应为 JSON 字符串形态,实际 %s", raw)
|
||||
}
|
||||
|
||||
// 消费不存在的码:无变化、不报错
|
||||
if err := consumeInviteCode(db, "NOT-THERE"); err != nil {
|
||||
t.Fatalf("消费不存在的码不应报错: %v", err)
|
||||
}
|
||||
|
||||
// 新形态:JSON 数组
|
||||
db.Where("key = ?", "invite_codes").Delete(&store.SystemConfig{})
|
||||
db.Create(&store.SystemConfig{Key: "invite_codes", Value: `["CODE-A","CODE-B"]`})
|
||||
codes, err = loadInviteCodes(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(codes) != 2 || codes[0] != "CODE-A" {
|
||||
t.Fatalf("数组形态应可解析,实际 %v", codes)
|
||||
}
|
||||
|
||||
// 非法 JSON:报错而不是静默全放行
|
||||
db.Where("key = ?", "invite_codes").Delete(&store.SystemConfig{})
|
||||
db.Create(&store.SystemConfig{Key: "invite_codes", Value: `{invalid`})
|
||||
if _, err := loadInviteCodes(db); err == nil {
|
||||
t.Fatal("非法配置应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 消费时同码重复录入只删一个。
|
||||
func TestConsumeInviteCodeDuplicateEntry(t *testing.T) {
|
||||
db := openInviteTestDB(t)
|
||||
db.Create(&store.SystemConfig{Key: "invite_codes", Value: `"CODE-A,CODE-A,CODE-B"`})
|
||||
if err := consumeInviteCode(db, "CODE-A"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes, _ := loadInviteCodes(db)
|
||||
if len(codes) != 2 || codes[0] != "CODE-A" || codes[1] != "CODE-B" {
|
||||
t.Fatalf("同码重复只应删一个,实际 %v", codes)
|
||||
}
|
||||
}
|
||||
|
||||
func openInviteTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := store.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
})
|
||||
return db
|
||||
}
|
||||
@@ -39,6 +39,12 @@ func SessionAuth(a *app.App) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 会话版本不匹配 = 改密/登出后签发的旧 token,立即失效
|
||||
if claims.Ver != u.TokenVersion {
|
||||
resp.Fail(c, http.StatusUnauthorized, "session revoked, please sign in again")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(CtxSessionUser, &u)
|
||||
c.Next()
|
||||
}
|
||||
@@ -100,6 +106,24 @@ func CORS(allow []string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityHeaders 基础安全响应头 + SPA 的 CSP。
|
||||
// script-src 'self':Vite 构建产物无内联脚本;style 保留 'unsafe-inline'
|
||||
// (Vue 运行时 style 绑定与 Tailwind 注入需要);access token 不再落 localStorage 后,
|
||||
// XSS 即使发生也拿不到持久凭据,CSP 是纵深防御的一层。
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.Writer.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data:; font-src 'self' data:; connect-src 'self'; "+
|
||||
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// BodyLimit 限制请求体字节数。超限后 ReadAll 会拿到 *http.MaxBytesError,
|
||||
// 由代理层按客户端协议返回 413;0 表示不限制。
|
||||
func BodyLimit(maxBytes int64) gin.HandlerFunc {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
@@ -42,7 +43,11 @@ func (h *Handler) PasskeyRegisterComplete(c *gin.Context) {
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -72,6 +77,9 @@ func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
|
||||
|
||||
// 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"`
|
||||
@@ -105,12 +113,12 @@ func (h *Handler) PasskeyLoginComplete(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusForbidden, "user account disabled")
|
||||
return
|
||||
}
|
||||
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
|
||||
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")
|
||||
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
|
||||
|
||||
@@ -26,7 +26,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
panic("invalid trusted proxies: " + err.Error())
|
||||
}
|
||||
}
|
||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS(a.Cfg.CORSAllowOrigins))
|
||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS(a.Cfg.CORSAllowOrigins), middleware.SecurityHeaders())
|
||||
|
||||
h := NewHandler(a, gw, a.Passkeys)
|
||||
|
||||
@@ -70,7 +70,9 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
})
|
||||
|
||||
// --- 管理 API ---
|
||||
api := r.Group("/api/v1")
|
||||
// 请求体上限 1MB:全部接口都是小 JSON(配置/密钥/用户),未认证面(login/register)
|
||||
// 不给大 body 的内存放大空间。
|
||||
api := r.Group("/api/v1", middleware.BodyLimit(1<<20))
|
||||
{
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user