diff --git a/.env.example b/.env.example index 87e7494..c6032d2 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # openteam 服务配置示例 —— 复制为 .env 后修改 # 所有项均可用环境变量 OT_ 覆盖(点号转下划线,如 db.driver → OT_DB_DRIVER) -# 运行环境 development | production +# 运行环境 development | production(仅这两个值;生产启动会做密钥断言,见下) OT_ENV=development OT_PORT=8080 @@ -11,10 +11,11 @@ OT_DB_DSN=data/openteam.db # OT_DB_DRIVER=postgres # OT_DB_DSN=host=localhost user=openteam password=openteam dbname=openteam port=5432 sslmode=disable -# JWT(生产必须更换为随机长字符串) +# JWT(生产必须更换为随机长字符串;OT_ENV=production 时本文件里的占位值会被启动校验拒绝) OT_JWT_SECRET=change-me-to-a-long-random-string OT_JWT_ACCESS_TTL=2h OT_JWT_REFRESH_TTL=168h +# 生产必须为 true(refresh cookie 走 HTTPS;反代终止 TLS 时也要置 true) OT_JWT_COOKIE_SECURE=false # 注册策略:open(开放)| invite(邀请码) @@ -68,7 +69,8 @@ OT_WEBAUTHN_RP_ID=localhost OT_WEBAUTHN_RP_ORIGIN=http://localhost:5173 OT_WEBAUTHN_RP_NAME=openteam -# 初始管理员(仅首次创建生效) +# 初始管理员(仅首次创建生效)。非 development 环境未显式设置 OT_ADMIN_PASSWORD +# 时拒绝播种管理员;admin123 是公开默认值,生产启动校验也会拒绝它。 OT_ADMIN_USERNAME=admin OT_ADMIN_EMAIL=admin@localhost OT_ADMIN_PASSWORD=admin123 diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index 4760102..f169a4e 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -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 +} diff --git a/server/internal/api/invite_test.go b/server/internal/api/invite_test.go new file mode 100644 index 0000000..671e4e9 --- /dev/null +++ b/server/internal/api/invite_test.go @@ -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 +} diff --git a/server/internal/api/middleware/middleware.go b/server/internal/api/middleware/middleware.go index 720b625..f2d9ffa 100644 --- a/server/internal/api/middleware/middleware.go +++ b/server/internal/api/middleware/middleware.go @@ -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 { diff --git a/server/internal/api/passkey.go b/server/internal/api/passkey.go index 96ef772..512a4f8 100644 --- a/server/internal/api/passkey.go +++ b/server/internal/api/passkey.go @@ -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 diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 72db43c..2b9ff1e 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -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") { diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 8d9b9f0..10f9d01 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -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 } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index bd99e84..7e34568 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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 +} diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index 6020de7..df284a2 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -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) { diff --git a/server/internal/passkey/passkey.go b/server/internal/passkey/passkey.go index 2479e22..f8ced7a 100644 --- a/server/internal/passkey/passkey.go +++ b/server/internal/passkey/passkey.go @@ -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) { diff --git a/server/internal/pkg/jwt/jwt.go b/server/internal/pkg/jwt/jwt.go index 28b7e11..b309225 100644 --- a/server/internal/pkg/jwt/jwt.go +++ b/server/internal/pkg/jwt/jwt.go @@ -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, diff --git a/server/internal/pkg/jwt/jwt_test.go b/server/internal/pkg/jwt/jwt_test.go index 52fc53e..335f1c9 100644 --- a/server/internal/pkg/jwt/jwt_test.go +++ b/server/internal/pkg/jwt/jwt_test.go @@ -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) } diff --git a/server/internal/store/db.go b/server/internal/store/db.go index 8d51146..5a7f096 100644 --- a/server/internal/store/db.go +++ b/server/internal/store/db.go @@ -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 diff --git a/server/internal/store/models.go b/server/internal/store/models.go index cf6ba7c..cdc0487 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -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"`