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:
Sakurasan committed 2026-10-29 02:56:46 +08:00
1 parent d6db0f8162
commit e3ff0d3329
14 files changed
+392 -37

No files matched your search

+119 -15
View File
@@ -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
}
+88
View File
@@ -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 {
+10 -2
View File
@@ -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
+4 -2
View File
@@ -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")
{
+12 -1
View File
@@ -3,6 +3,7 @@ package app
import (
"context"
"fmt"
"log"
"time"
@@ -79,7 +80,17 @@ func (a *App) Seed() error {
var count int64
a.DB.Model(&store.User{}).Where("role = ?", store.RoleAdmin).Count(&count)
if count == 0 {
hash, err := a.Hasher.HashPassword(envOr("OT_ADMIN_PASSWORD", "admin123"))
password := a.Cfg.AdminPassword
if password == "" {
// config.validate 已拒绝缺 OT_ADMIN_PASSWORD 的生产配置;这里再拦一层,
// 防止绕过 Load 的调用方(测试/工具)带着公开默认密码进非开发环境。
if a.Cfg.Env != "development" {
return fmt.Errorf("非 development 环境必须显式设置 OT_ADMIN_PASSWORD,拒绝用默认密码创建管理员")
}
password = "admin123"
log.Printf("seed: using default admin password admin123 — DO NOT expose this instance to the public internet")
}
hash, err := a.Hasher.HashPassword(password)
if err != nil {
return err
}
+21 -7
View File
@@ -241,26 +241,40 @@ func splitList(s string) []string {
return out
}
// validate 生产环境的密钥断言:这些默认值是公开已知的,忘配就等于没有认证。
//
// master key 缺失时 crypto.NewEncryptor 会用 sha256("") 派生密钥(结果同样公开),
// 渠道上游密钥的密文就变成谁都能解;jwt.secret 用默认值则能直接伪造管理员 token。
// Load 之后、使用之前统一校验。env 值枚举化:OT_ENV 拼错(如 "prod")会静默落入
// 开发配置,让下面所有生产断言形同虚设。
func (c *Config) validate() error {
if c.Env != "development" && c.Env != "production" {
return fmt.Errorf(`OT_ENV 只能是 "development" 或 "production",当前 %q(拼错会静默跳过生产密钥断言)`, c.Env)
}
if c.Env != "production" {
return nil
}
var problems []string
if c.JWT.Secret == "" || c.JWT.Secret == "dev-only-secret-change-me" {
if c.JWT.Secret == "" || knownSecret(c.JWT.Secret) {
problems = append(problems, "OT_JWT_SECRET 未设置或仍是仓库里的默认值(可伪造会话 token)")
}
if len(c.Master) < 16 {
problems = append(problems, "OT_MASTER_KEY 未设置或少于 16 字节(缺失时会用 sha256(\"\") 派生,渠道密文可被公开还原)")
if len(c.Master) < 16 || knownSecret(c.Master) {
problems = append(problems, "OT_MASTER_KEY 未设置、少于 16 字节或仍是示例占位值(缺失/公开时渠道密文可被公开还原)")
}
if c.AdminPassword == "" || c.AdminPassword == "admin123" {
problems = append(problems, "OT_ADMIN_PASSWORD 未设置或仍是 admin123")
}
if !c.JWT.CookieSecure {
problems = append(problems, "OT_JWT_COOKIE_SECURE 必须为 true(生产环境 refresh cookie 走 HTTP 明文可被中间人截取;自签证书或反代终止 TLS 后也要置 true)")
}
if len(problems) > 0 {
return fmt.Errorf("生产环境启动被拒绝:%s", strings.Join(problems, ";"))
}
return nil
}
// knownSecret 仓库/示例文件里公开出现过的密钥占位值:复制 .env.example 不改密钥
// 是最常见的部署路径,必须当作默认值拦下。
func knownSecret(s string) bool {
switch s {
case "dev-only-secret-change-me", "change-me-to-a-long-random-string", "change-me-master-key":
return true
}
return false
}
+11
View File
@@ -8,6 +8,7 @@ import (
func prodCfg() *Config {
c := &Config{Env: "production"}
c.JWT.Secret = "a-real-random-secret-not-the-default"
c.JWT.CookieSecure = true
c.Master = "0123456789abcdef0123456789abcdef"
c.AdminPassword = "strong-enough-password"
return c
@@ -25,10 +26,14 @@ func TestValidateProduction(t *testing.T) {
}{
{"默认 jwt secret", func(c *Config) { c.JWT.Secret = "dev-only-secret-change-me" }, "OT_JWT_SECRET"},
{"空 jwt secret", func(c *Config) { c.JWT.Secret = "" }, "OT_JWT_SECRET"},
// 复制 .env.example 不改密钥是最常见的部署路径,占位值必须拦下
{"示例文件里的 jwt secret 占位值", func(c *Config) { c.JWT.Secret = "change-me-to-a-long-random-string" }, "OT_JWT_SECRET"},
{"缺失 master key", func(c *Config) { c.Master = "" }, "OT_MASTER_KEY"},
{"过短 master key", func(c *Config) { c.Master = "short" }, "OT_MASTER_KEY"},
{"示例文件里的 master key 占位值", func(c *Config) { c.Master = "change-me-master-key" }, "OT_MASTER_KEY"},
{"默认管理员密码", func(c *Config) { c.AdminPassword = "admin123" }, "OT_ADMIN_PASSWORD"},
{"未设管理员密码", func(c *Config) { c.AdminPassword = "" }, "OT_ADMIN_PASSWORD"},
{"cookie 未开 secure", func(c *Config) { c.JWT.CookieSecure = false }, "OT_JWT_COOKIE_SECURE"},
}
for _, tc := range cases {
c := prodCfg()
@@ -55,11 +60,17 @@ func TestValidateProduction(t *testing.T) {
dev := prodCfg()
dev.Env = "development"
dev.JWT.Secret = "dev-only-secret-change-me"
dev.JWT.CookieSecure = false
dev.Master = ""
dev.AdminPassword = "admin123"
if err := dev.validate(); err != nil {
t.Fatalf("development 不应拦截:%v", err)
}
// env 拼错(如 "prod")会静默跳过全部生产断言,必须枚举校验直接拒绝
if err := (&Config{Env: "prod"}).validate(); err == nil || !strings.Contains(err.Error(), "OT_ENV") {
t.Fatalf("未知 env 值应被拒绝:%v", err)
}
}
func TestSplitList(t *testing.T) {
+28 -1
View File
@@ -33,11 +33,19 @@ type Service struct {
sessions map[string]webauthn.SessionData // keyed by challenge
}
// maxSessions challenge 会话上限:login/begin 无需认证,不设上限的话每次调用
// 都会向 map 永久写入一条(只有完成认证才删),持续打可把进程内存打爆。
const maxSessions = 4096
func New(db *gorm.DB, cfg Config) (*Service, error) {
wa, err := webauthn.New(&webauthn.Config{
RPDisplayName: cfg.Name,
RPID: cfg.RPID,
RPOrigins: []string{cfg.Origin},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{Enforce: true, Timeout: 5 * time.Minute, TimeoutUVD: 5 * time.Minute},
Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: 5 * time.Minute, TimeoutUVD: 5 * time.Minute},
},
})
if err != nil {
return nil, err
@@ -224,8 +232,27 @@ func (s *Service) updateCredential(userID uint64, cred *webauthn.Credential) err
func (s *Service) storeSession(session *webauthn.SessionData) {
s.mu.Lock()
defer s.mu.Unlock()
// 到容量上限先清一轮已过期会话;仍满则淘汰最早过期的(未过期说明正在被
// 正常使用,丢最不紧迫的)。配合 Enforce 过期校验,map 大小有硬上界。
if len(s.sessions) >= maxSessions {
now := time.Now()
var oldestKey string
var oldestExp time.Time
for k, v := range s.sessions {
if now.After(v.Expires) {
delete(s.sessions, k)
continue
}
if oldestKey == "" || v.Expires.Before(oldestExp) {
oldestKey, oldestExp = k, v.Expires
}
}
if len(s.sessions) >= maxSessions && oldestKey != "" {
delete(s.sessions, oldestKey)
}
}
s.sessions[session.Challenge] = *session
s.mu.Unlock()
}
func (s *Service) takeSession(challenge string) (webauthn.SessionData, bool) {
+5 -2
View File
@@ -9,10 +9,12 @@ import (
)
// Claims 用户声明;Subject 字段区分 "access" / "refresh"。
// Ver 是用户级会话版本号:改密/登出时递增,旧 token 立即失效(可撤销性)。
type Claims struct {
UserID uint64 `json:"uid"`
Username string `json:"uname"`
Role string `json:"role"`
Ver int `json:"ver"`
jwt.RegisteredClaims
}
@@ -30,8 +32,8 @@ func NewManager(secret, issuer string, accessTTL, refreshTTL time.Duration) *Man
func (m *Manager) AccessTTL() time.Duration { return m.accessTTL }
func (m *Manager) RefreshTTL() time.Duration { return m.refreshTTL }
// Sign 签发 token;typ 取 "access" / "refresh"。
func (m *Manager) Sign(userID uint64, username, role, typ string) (string, time.Time, error) {
// Sign 签发 token;typ 取 "access" / "refresh",ver 为签发时的会话版本。
func (m *Manager) Sign(userID uint64, username, role, typ string, ver int) (string, time.Time, error) {
ttl := m.accessTTL
if typ == "refresh" {
ttl = m.refreshTTL
@@ -42,6 +44,7 @@ func (m *Manager) Sign(userID uint64, username, role, typ string) (string, time.
UserID: userID,
Username: username,
Role: role,
Ver: ver,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: m.issuer,
Subject: typ,
+21 -2
View File
@@ -7,7 +7,7 @@ import (
func TestSignParseAccess(t *testing.T) {
m := NewManager("test-secret", "openteam", time.Hour, 24*time.Hour)
tok, exp, err := m.Sign(42, "alice", "admin", "access")
tok, exp, err := m.Sign(42, "alice", "admin", "access", 7)
if err != nil {
t.Fatalf("Sign: %v", err)
}
@@ -24,6 +24,25 @@ func TestSignParseAccess(t *testing.T) {
if claims.Subject != "access" {
t.Fatalf("subject mismatch: %s", claims.Subject)
}
if claims.Ver != 7 {
t.Fatalf("ver mismatch: %d", claims.Ver)
}
}
// 会话版本承载撤销语义:Ver 编码进 token,校验方与用户当前版本比对
func TestClaimsCarryVersion(t *testing.T) {
m := NewManager("test-secret", "openteam", time.Hour, 24*time.Hour)
tok, _, err := m.Sign(1, "bob", "user", "refresh", 3)
if err != nil {
t.Fatalf("Sign: %v", err)
}
claims, err := m.Parse(tok)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if claims.Ver != 3 || claims.Subject != "refresh" {
t.Fatalf("claims mismatch: %+v", claims)
}
}
func TestParseRejectsBadToken(t *testing.T) {
@@ -35,7 +54,7 @@ func TestParseRejectsBadToken(t *testing.T) {
func TestExpiredToken(t *testing.T) {
m := NewManager("test-secret", "openteam", -time.Hour, -time.Hour)
tok, _, err := m.Sign(1, "bob", "user", "access")
tok, _, err := m.Sign(1, "bob", "user", "access", 0)
if err != nil {
t.Fatalf("Sign: %v", err)
}
+43 -2
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
@@ -20,16 +21,18 @@ import (
// 注意:c39c3e7 给 Model 加了 cache_write_price/context_length/supports_tools/
// supports_vision/supports_reasoning 等列并删掉了 display_name,但当时漏改这里,
// 导致已建库的实例永远跳过重迁移、新增模型时 Create 因缺列而失败。这里补到 3 触发一次迁移。
const schemaVersion = 3
// v4:users 加 token_version(会话撤销)。
const schemaVersion = 4
// Open 打开数据库连接并自动迁移。
// 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。
// 开发默认 SQLite(自动追加 WAL 与 busy_timeout,见 withSQLitePragmas),生产可切 postgres。
func Open(driver, dsn string) (*gorm.DB, error) {
var dialector gorm.Dialector
switch driver {
case "postgres":
dialector = postgresDialector(dsn)
default:
dsn = withSQLitePragmas(dsn)
// 确保 SQLite 文件所在目录存在
if dir := sqliteDir(dsn); dir != "" {
_ = os.MkdirAll(dir, 0o755)
@@ -43,6 +46,7 @@ func Open(driver, dsn string) (*gorm.DB, error) {
if err != nil {
return nil, err
}
configurePool(db, driver)
if driver != "postgres" && currentSQLiteVersion(db) >= schemaVersion {
log.Printf("store: connected driver=%s (schema up-to-date v%d, skip migrate)", driver, schemaVersion)
@@ -61,6 +65,43 @@ func Open(driver, dsn string) (*gorm.DB, error) {
return db, nil
}
// withSQLitePragmas 给 DSN 补上 busy_timeout 与 WAL。
// glebarez(modernc)驱动只解析 _pragma/_txlock/_time_format/vfs 四种参数——
// 旧注释宣称的 `?_journal_mode=WAL` 实际不生效,缺 WAL 时并发写会频繁 database is locked。
// 用户 DSN 里已显式声明的项不覆盖。
func withSQLitePragmas(dsn string) string {
for _, p := range []string{"_pragma=busy_timeout(10000)", "_pragma=journal_mode(WAL)"} {
if strings.Contains(dsn, p) {
continue
}
if strings.Contains(dsn, "?") {
dsn += "&" + p
} else {
dsn += "?" + p
}
}
return dsn
}
// configurePool 连接池上限。此前全仓无任何配置:PG 连接数无上限,
// SQLite 多连接并发写叠加无 WAL 加剧锁竞争。
func configurePool(db *gorm.DB, driver string) {
sqlDB, err := db.DB()
if err != nil {
return
}
if driver == "postgres" {
sqlDB.SetMaxOpenConns(30)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
return
}
// SQLite:WAL 下读写可并发,写仍串行——限连接数让写竞争在 busy_timeout 内排队
sqlDB.SetMaxOpenConns(8)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
}
// currentSQLiteVersion 读取 PRAGMA user_version。
func currentSQLiteVersion(db *gorm.DB) int {
var v int
+1
View File
@@ -56,6 +56,7 @@ type User struct {
Role string `gorm:"size:16;not null;default:user" json:"role"`
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
Status string `gorm:"size:16;not null;default:active" json:"status"`
TokenVersion int `gorm:"not null;default:0" json:"-"` // 会话版本:改密/登出递增,低于当前版本的 token 全部失效
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"` // 用户级模型白名单(空=不限制)
DeniedModels []string `gorm:"type:jsonb;serializer:json" json:"denied_models,omitempty"` // 用户级模型黑名单
InviteCode *string `json:"invite_code,omitempty"`