diff --git a/docs/superpowers/specs/2026-05-13-security-remediation-design.md b/docs/superpowers/specs/2026-05-13-security-remediation-design.md new file mode 100644 index 0000000..bbb4814 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-security-remediation-design.md @@ -0,0 +1,377 @@ +# FLVX 安全问题修复设计 + +**日期**: 2026-05-13 +**状态**: 待审核 +**作者**: AI Assistant + +## 概述 + +针对 PR #502 提到的安全问题,对 FLVX 后端认证、配置访问控制、配置写入保护、备份导出和 JWT 失效模型做一次集中修复。目标是优先消除高风险漏洞,同时保留当前必须兼容的登录页品牌配置读取和验证码兼容行为。 + +本设计采用“高危项一次收口,结构性问题只分析不重构”的策略:本轮修复 MD5 密码存储、未受控配置读取、敏感配置写入、备份配置泄露和 JWT 长期有效且改密后不失效的问题;不修改“无 Cloudflare secret 时允许当前 captcha 兼容行为”,也不重构 `autoMigrateAll()` 与 `migrateSchema()` 的双迁移入口。 + +## 背景 + +当前主线存在以下已确认问题: + +1. `login`、`open_api/sub_store`、用户改密、管理员创建用户和管理员修改用户密码仍然使用 `security.MD5(...)`。 +2. `/api/v1/config/get` 在 middleware 的 `shouldSkip()` 中被匿名放行,导致任意调用方可以读取绝大多数配置。 +3. `updateConfigs()` 与 `updateSingleConfig()` 使用了两套不同的限制逻辑,敏感配置键在单项写接口中未被保护。 +4. `ExportAll()` 和 `ExportPartial(types=["configs"])` 会直接导出所有配置,包含 `jwt_secret`、`license_key`、`cloudflare_secret_key`。 +5. JWT 当前有效期为 90 天,且 token 在用户改密、禁用、角色变化后仍可继续使用到过期。 + +同时存在两个重要约束: + +1. 登录页和未登录态品牌展示依赖匿名读取 `app_name`、`app_logo`、`app_favicon`、`app_bg_image` 和 `cloudflare_site_key`。 +2. `tests/contract/migration_contract_test.go` 已把“无 Cloudflare secret 时允许当前 captcha 兼容行为”定义为既有契约,本轮不改变。 + +## 目标 + +1. 新增和更新后的用户密码不再以 MD5 存储。 +2. 历史 MD5 用户可在首次成功认证时自动迁移到强哈希。 +3. 匿名请求不能再读取任意配置,只能读取明确的公开配置白名单。 +4. 通用配置写接口不能覆盖敏感配置键。 +5. 备份导出默认不泄露敏感配置明文。 +6. 用户改密、禁用或角色变化后,旧 JWT 应立即失效。 +7. 不破坏现有登录页品牌展示和 captcha 兼容行为。 + +## 非目标 + +1. 不重构 `open_api/sub_store` 的整体认证模型;该接口仍使用现有用户名和密码查询参数语义。 +2. 不实现完整 refresh token、session 管理后台或 token 黑名单体系。 +3. 不改变“无 Cloudflare secret 时允许当前 captcha 兼容行为”。 +4. 不在本轮重构 `autoMigrateAll()` 与 `migrateSchema()` 的启动流程。 +5. 不引入前端测试框架。 + +## 影响范围 + +### 后端 + +- `go-backend/internal/security/` +- `go-backend/internal/auth/jwt.go` +- `go-backend/internal/http/middleware/auth.go` +- `go-backend/internal/http/handler/handler.go` +- `go-backend/internal/http/handler/mutations.go` +- `go-backend/internal/store/model/model.go` +- `go-backend/internal/store/repo/repository.go` +- `go-backend/internal/store/repo/repository_mutations.go` +- `go-backend/tests/contract/` +- `go-backend/internal/store/repo/*_test.go` + +### 前端 + +- `vite-frontend/src/api/index.ts` +- `vite-frontend/src/config/site.ts` +- `vite-frontend/src/pages/index.tsx` +- 任何在未登录态读取品牌配置的组件 + +## 设计决策 + +### 已确认决策 + +1. 本轮采用安全优先策略,允许收紧危险默认行为。 +2. MD5 密码采用“登录成功时自动迁移”的兼容方案。 +3. captcha 在未配置 Cloudflare secret 时的兼容行为保持不变。 +4. JWT 采用“最小可撤销”方案,而不是完整 session 体系。 +5. 双重迁移系统只分析,不在本轮中修改。 + +### 迁移系统分析结论 + +`autoMigrateAll()` 与 `migrateSchema()` 当前职责并不相同: + +1. `autoMigrateAll()` 负责表和列结构补齐。 +2. `migrateSchema()` 负责基于 `schema_version` 的数据修正,以及 PostgreSQL ID 默认值修复等兼容迁移。 +3. 现有 `repository_migrate_test.go` 已明确覆盖这两部分逻辑,说明它们在现有代码库中是被依赖的互补结构,而不是已确认的重复安全漏洞。 + +因此本轮仅记录该分析结论,不把双迁移入口纳入改动范围,避免把安全修复扩展为启动流程重构。 + +## 详细设计 + +### 1. 密码存储与认证迁移 + +在 `internal/security/` 中新增统一密码能力,替代各处直接使用 `security.MD5(...)` 的做法。 + +建议新增以下接口: + +```go +func HashPassword(plain string) (string, error) +func VerifyPassword(storedHash, plain string) (ok bool, legacy bool) +func IsLegacyPasswordHash(storedHash string) bool +``` + +哈希算法使用 `bcrypt`: + +1. `user.pwd` 当前为 `varchar(100)`,足以容纳 bcrypt 哈希。 +2. 不需要修改密码列长度,改动最小。 +3. 对当前 Go 后端来说,bcrypt 是最稳妥的强哈希升级路径。 + +所有密码入口统一改为走这套能力: + +1. `login` +2. `openAPISubStore` +3. `updatePassword` +4. `userCreate` +5. `userUpdate` 中的管理员改密路径 + +认证迁移规则: + +1. 如果数据库中存的是 bcrypt,则按 bcrypt 校验。 +2. 如果数据库中存的是历史 MD5,则先按旧逻辑校验。 +3. 历史 MD5 校验成功后,立即把 `pwd` 改写为 bcrypt。 +4. 自动迁移不仅在网页登录时执行,也在 `open_api/sub_store` 成功鉴权时执行,避免只使用订阅接口的老用户永远停留在 MD5。 + +默认管理员种子账号仍保留当前默认密码语义和 `requirePasswordChange` 行为,但种子哈希改为 bcrypt,不再在新建数据库中写入 MD5 值。 + +### 2. JWT 最小可撤销方案 + +本轮不引入 refresh token 和黑名单表,而是做一个可以立即生效的最小撤销闭环。 + +#### 数据模型 + +在 `user` 表新增字段: + +- `password_changed_at BIGINT NOT NULL DEFAULT 0` + +该字段专门表示密码最后一次变更时间,不能复用现有 `updated_time`,原因是 `updated_time` 还会被流量、状态或其他用户资料更新触发,复用后会让非密码更新错误地使 token 失效。 + +#### token 签发与校验 + +继续使用现有 `iat` 声明,但把有效期从 90 天收紧到 7 天。 + +token 校验分两步: + +1. 先做现有签名和过期时间校验。 +2. 再读取用户最小认证状态,确认: + - 用户仍存在 + - 用户状态未被禁用 + - 当前 `role_id` 与 token 中一致 + - `claims.iat` 不早于 `password_changed_at` + +为避免 middleware 每次都查询完整用户对象,Repository 新增专用读取方法,只返回 token 校验需要的最小字段,例如: + +```go +type UserAuthState struct { + ID int64 + RoleID int + Status int + PasswordChangedAt int64 +} + +func (r *Repository) GetUserAuthState(userID int64) (*UserAuthState, error) +``` + +#### 失效语义 + +以下场景下,旧 token 应立即失效: + +1. 用户修改密码 +2. 管理员修改用户密码 +3. 用户被禁用 +4. 用户角色发生变化 + +这会带来一次明确的兼容收紧:升级完成后,部分历史 token 可能因为寿命策略或认证状态变化而失效,这是安全优先下的可接受行为。 + +### 3. 配置读取访问控制 + +为了避免继续让 `/api/v1/config/get` 承担“有时匿名、有时鉴权”的混合语义,本设计将公开配置读取拆成单独的 public 端点。 + +#### 端点设计 + +保留现有受保护端点: + +- `POST /api/v1/config/get` + +新增公开端点: + +- `POST /api/v1/public/config/get` + +middleware 仅对白名单 public 端点放行,不再放行 `/api/v1/config/get`。 + +#### 公开白名单 + +匿名仅允许读取以下配置: + +1. `app_name` +2. `app_logo` +3. `app_favicon` +4. `app_bg_image` +5. `cloudflare_site_key` + +理由: + +1. 登录页与未登录态品牌渲染依赖前四项。 +2. 登录页在 captcha 开启时需要读取 `cloudflare_site_key`。 +3. 其他配置不应暴露给匿名方。 + +前端调整规则: + +1. 登录页和 `site.ts` 中的未登录态品牌配置读取改走 `/public/config/get`。 +2. 登录后页面仍使用现有 `/config/get` 或 `/config/list`。 +3. 已登录页面中的配置读取逻辑不变,只是恢复为真正受 JWT 保护。 + +### 4. 配置写保护统一 + +当前 `updateConfigs()` 与 `updateSingleConfig()` 各自维护不同限制逻辑,是本次越权写入漏洞的根源。本轮把配置访问规则统一收口为一套辅助函数。 + +建议新增配置策略定义: + +```go +type ConfigAccessPolicy struct { + PublicReadable bool + Sensitive bool + CommercialOnly bool +} +``` + +由统一函数返回某个 key 的策略,再由: + +1. `public config get` +2. `config get` +3. `config list` +4. `updateConfigs()` +5. `updateSingleConfig()` + +共同复用。 + +敏感配置键至少包含: + +1. `jwt_secret` +2. `license_key` +3. `cloudflare_secret_key` + +这些键的写入规则: + +1. 不允许通过通用配置写接口改写。 +2. 不允许通过公开读取接口读取。 +3. 非管理员在配置列表接口中也不能获得。 + +商业版白名单键继续沿用现有语义,例如: + +1. `app_name` +2. `app_logo` +3. `app_favicon` +4. `hide_footer_brand` + +但其判断逻辑同样统一走同一套策略函数,避免再次出现单接口漏判。 + +### 5. 备份导出与导入脱敏 + +备份系统改为“默认安全导出”,而不是“完整明文镜像”。 + +#### 导出 + +`ExportAll()` 和 `ExportPartial(types=["configs"])` 在写入 `backup.Configs` 前都先经过统一过滤函数,移除敏感配置键。 + +敏感配置键与配置写保护列表保持一致: + +1. `jwt_secret` +2. `license_key` +3. `cloudflare_secret_key` + +#### 导入 + +导入配置时,即使旧备份中带有上述敏感键,也会在导入前被丢弃,不允许通过备份恢复路径覆盖在线安全配置。 + +该设计的取舍如下: + +1. 保留大部分业务配置、节点、转发、用户数据的恢复能力。 +2. 不再把备份文件当作核心密钥分发载体。 +3. `UserBackup.Pwd` 仍然保留,以维持用户恢复语义;在本轮密码升级后,这些值将是 bcrypt 哈希,而不是 MD5。 + +### 6. captcha 兼容行为 + +`captcha_enabled`、`cloudflare_site_key`、`cloudflare_secret_key` 的现有兼容行为保持不变。 + +明确保持以下现状: + +1. 当未完整配置 Cloudflare key 时,当前 contract test 约定的兼容路径继续存在。 +2. 本轮不把 captcha 兼容逻辑从“兼容旧行为”切换为“严格校验”。 + +这样可以避免把一轮安全修复扩展成登录流程行为变更,同时与用户已确认的范围保持一致。 + +## 错误处理与兼容行为 + +### 错误处理 + +保持现有 API envelope:`{code, msg, data, ts}`。 + +建议的接口行为: + +1. `POST /api/v1/public/config/get` 请求非公开 key 时返回 `403`。 +2. 受保护配置端点未登录时返回 `401`。 +3. 登录、订阅接口、改密接口继续返回通用认证失败,不暴露“用户名存在但密码错误”等细节。 +4. token 因签名错误、过期、改密、禁用或角色变化失效时,统一返回现有 `401` 语义。 +5. 备份导入中出现敏感配置键时,接口整体仍允许成功导入其他数据,敏感键静默忽略。 + +### 保留兼容 + +1. 登录页和未登录态品牌展示继续可用。 +2. 未配置 Cloudflare secret 时的 captcha 兼容逻辑继续保留。 +3. 历史 MD5 用户仍可继续认证,并在成功后自动迁移。 + +### 刻意收紧 + +1. 匿名方不再可读取任意配置。 +2. 通用配置写接口不再能写入敏感键。 +3. 备份不再导出敏感配置明文。 +4. 改密、禁用和角色变化会立即使旧 token 失效。 + +## 测试设计 + +本轮以 Go 单测和 contract test 为主,覆盖以下场景。 + +### 密码迁移 + +1. 历史 MD5 用户在网页登录成功后,数据库中的 `pwd` 被升级为 bcrypt。 +2. 历史 MD5 用户在 `open_api/sub_store` 成功鉴权后,同样触发迁移。 +3. 新建用户后落库的是 bcrypt,而不是 MD5。 +4. 管理员修改用户密码和用户自助改密后,落库的是 bcrypt。 + +### JWT 最小可撤销 + +1. 正常 token 仍可访问受保护接口。 +2. 改密后旧 token 失效。 +3. 用户被禁用后旧 token 失效。 +4. 用户角色变化后旧 token 失效。 +5. 过期 token 失效。 + +### 配置访问控制 + +1. 匿名访问公开配置成功。 +2. 匿名访问非公开配置失败。 +3. 已登录页面需要的普通配置读取仍然可用。 +4. `updateSingleConfig()` 无法修改敏感键。 +5. `updateConfigs()` 同样无法修改敏感键。 + +### 备份脱敏 + +1. `ExportAll()` 不包含敏感配置。 +2. `ExportPartial(types=["configs"])` 不包含敏感配置。 +3. 导入带敏感键的备份时,这些键不会被写回数据库。 +4. 非敏感配置和其他业务数据仍可正常导入导出。 + +### 迁移系统回归 + +1. 现有 `repository_migrate_test.go` 保持通过。 +2. 本轮不对 `autoMigrateAll()` 与 `migrateSchema()` 的职责边界做行为性改动。 + +## 验收标准 + +1. 数据库中不再新增 MD5 密码。 +2. 历史 MD5 用户可在首次成功认证后自动升级到 bcrypt。 +3. 匿名调用方不能再读取非公开配置。 +4. `updateSingleConfig()` 和 `updateConfigs()` 都无法改写敏感配置键。 +5. 备份导出默认不包含 `jwt_secret`、`license_key`、`cloudflare_secret_key`。 +6. 改密、禁用和角色变化后,旧 JWT 立即失效。 +7. 登录页品牌展示和 captcha 兼容行为不被破坏。 +8. 现有迁移测试和本轮新增安全测试全部通过。 + +## PR #502 处置 + +PR #502 的价值在于指出了真实问题,但其实现方式只是把讽刺性注释写进生产代码,并未修复漏洞。因此该 PR 不应合并。 + +执行阶段的处置方式: + +1. 关闭 PR #502。 +2. 在关闭说明中指出:问题成立,但修复将通过正式代码与测试提交完成,而不是通过向源文件加入讽刺性注释。 +3. 后续在新提交中按本设计逐项修复。 diff --git a/go-backend/internal/auth/jwt.go b/go-backend/internal/auth/jwt.go index 3bc6d9f..7c9ecf6 100644 --- a/go-backend/internal/auth/jwt.go +++ b/go-backend/internal/auth/jwt.go @@ -12,12 +12,13 @@ import ( const ( algorithm = "HmacSHA256" - expireTime = 90 * 24 * time.Hour + expireTime = 7 * 24 * time.Hour ) type Claims struct { Sub string `json:"sub"` Iat int64 `json:"iat"` + IatMs int64 `json:"iat_ms"` Exp int64 `json:"exp"` User string `json:"user"` Name string `json:"name"` @@ -30,11 +31,15 @@ type tokenHeader struct { } func GenerateToken(userID int64, username string, roleID int, secret string) (string, error) { - now := time.Now() + return GenerateTokenAt(userID, username, roleID, secret, time.Now()) +} + +func GenerateTokenAt(userID int64, username string, roleID int, secret string, now time.Time) (string, error) { header := tokenHeader{Alg: algorithm, Typ: "JWT"} claims := Claims{ Sub: strconv.FormatInt(userID, 10), Iat: now.Unix(), + IatMs: now.UnixMilli(), Exp: now.Add(expireTime).Unix(), User: username, Name: username, diff --git a/go-backend/internal/auth/user_state.go b/go-backend/internal/auth/user_state.go new file mode 100644 index 0000000..d005b91 --- /dev/null +++ b/go-backend/internal/auth/user_state.go @@ -0,0 +1,8 @@ +package auth + +type UserAuthState struct { + ID int64 + RoleID int + Status int + PasswordChangedAt int64 +} diff --git a/go-backend/internal/http/handler/config_access.go b/go-backend/internal/http/handler/config_access.go new file mode 100644 index 0000000..2ccd707 --- /dev/null +++ b/go-backend/internal/http/handler/config_access.go @@ -0,0 +1,44 @@ +package handler + +import ( + "net/http" + "strings" + + "go-backend/internal/http/response" + "go-backend/internal/store/repo" +) + +func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req nameRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("配置名称不能为空")) + return + } + + configName := strings.ToLower(strings.TrimSpace(req.Name)) + if configName == "" { + response.WriteJSON(w, response.ErrDefault("配置名称不能为空")) + return + } + if !repo.IsPublicConfigKey(configName) { + response.WriteJSON(w, response.Err(403, "禁止访问敏感配置")) + return + } + + cfg, err := h.repo.GetConfigByName(configName) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if cfg == nil { + response.WriteJSON(w, response.ErrDefault("配置不存在")) + return + } + + response.WriteJSON(w, response.OK(cfg)) +} diff --git a/go-backend/internal/http/handler/config_access_test.go b/go-backend/internal/http/handler/config_access_test.go new file mode 100644 index 0000000..fd0792c --- /dev/null +++ b/go-backend/internal/http/handler/config_access_test.go @@ -0,0 +1,141 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "go-backend/internal/auth" + "go-backend/internal/http/middleware" + "go-backend/internal/http/response" + "go-backend/internal/store/repo" +) + +func TestPublicConfigGetAllowsBrandKeys(t *testing.T) { + router, r := setupConfigAccessTestRouter(t) + seedConfigValue(t, r, "app_name", "FLVX Brand") + seedConfigValue(t, r, "app_logo", "logo-data") + seedConfigValue(t, r, "app_favicon", "favicon-data") + seedConfigValue(t, r, "app_bg_image", "bg-data") + seedConfigValue(t, r, "cloudflare_site_key", "site-key") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + assertHandlerCode(t, resp, 0) +} + +func TestPublicConfigGetRejectsSensitiveKeys(t *testing.T) { + router, _ := setupConfigAccessTestRouter(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置") +} + +func TestConfigGetNowRequiresAuth(t *testing.T) { + router, _ := setupConfigAccessTestRouter(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"app_name"}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + assertHandlerCodeMsg(t, resp, 401, "未登录或token已过期") +} + +func TestConfigUpdateRejectsSensitiveKeys(t *testing.T) { + router, _ := setupConfigAccessTestRouter(t) + adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", bytes.NewBufferString(`{"jwt_secret":"rotated-secret"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", adminToken) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置") +} + +func TestConfigUpdateSingleRejectsSensitiveKeys(t *testing.T) { + router, _ := setupConfigAccessTestRouter(t) + adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update-single", bytes.NewBufferString(`{"name":"jwt_secret","value":"rotated-secret"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", adminToken) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置") +} + +func setupConfigAccessTestRouter(t *testing.T) (http.Handler, *repo.Repository) { + t.Helper() + r, err := repo.Open(t.TempDir() + "/config-access.db") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + _ = r.Close() + }) + + h := New(r, "unit-test-secret") + mux := http.NewServeMux() + h.Register(mux) + wrapped := middleware.Recover(mux) + wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: "unit-test-secret", GetUserAuthState: h.GetUserAuthState})(wrapped) + wrapped = middleware.RequestLog(wrapped) + wrapped = middleware.CORS(wrapped) + return wrapped, r +} + +func seedConfigValue(t *testing.T, r *repo.Repository, name, value string) { + t.Helper() + if err := r.DB().Exec(`INSERT INTO vite_config(name, value, time) VALUES(?, ?, 0) ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time`, name, value).Error; err != nil { + t.Fatalf("seed config %s: %v", name, err) + } +} + +func mustGenerateConfigAccessToken(t *testing.T, userID int64, username string, roleID int) string { + t.Helper() + token, err := auth.GenerateToken(userID, username, roleID, "unit-test-secret") + if err != nil { + t.Fatalf("generate token: %v", err) + } + return token +} + +func assertHandlerCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) { + t.Helper() + var out response.R + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != expected { + t.Fatalf("expected code %d, got %d", expected, out.Code) + } +} + +func assertHandlerCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) { + t.Helper() + var out response.R + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != expectedCode || out.Msg != expectedMsg { + t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg) + } +} diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index c434b04..efdf721 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -136,6 +136,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler { } h.metrics.RecordNodeMetric(nodeID, metricInfo) }) + h.wsServer.SetUserAuthStateLookup(h.GetUserAuthState) return h } @@ -143,6 +144,10 @@ func (h *Handler) WebSocketHandler() http.Handler { return h.wsServer } +func (h *Handler) GetUserAuthState(userID int64) (*auth.UserAuthState, error) { + return h.repo.GetUserAuthState(userID) +} + func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/user/login", h.login) mux.HandleFunc("/api/v1/user/list", h.userList) @@ -152,6 +157,7 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/user/reset", h.userResetFlow) mux.HandleFunc("/api/v1/user/quota/reset", h.userQuotaReset) mux.HandleFunc("/api/v1/user/groups", h.userGroups) + mux.HandleFunc("/api/v1/public/config/get", h.getPublicConfigByName) mux.HandleFunc("/api/v1/config/get", h.getConfigByName) mux.HandleFunc("/api/v1/config/list", h.getConfigs) mux.HandleFunc("/api/v1/config/update", h.updateConfigs) @@ -334,7 +340,8 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("账号或密码错误")) return } - if user.Pwd != security.MD5(req.Password) { + passwordMatched, passwordWasLegacy := security.VerifyPassword(user.Pwd, req.Password) + if !passwordMatched { response.WriteJSON(w, response.ErrDefault("账号或密码错误")) return } @@ -342,8 +349,20 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("账号被停用")) return } + issueAt := time.Now() + if passwordWasLegacy { + updatedAt := time.Now().UnixMilli() + hashedPassword, err := security.HashPassword(req.Password) + if err != nil { + log.Printf("legacy password rehash skipped user_id=%d path=login err=%v", user.ID, err) + } else if err := h.repo.UpdateUserPassword(user.ID, hashedPassword, updatedAt); err != nil { + log.Printf("legacy password rehash update skipped user_id=%d path=login err=%v", user.ID, err) + } else { + issueAt = time.UnixMilli(updatedAt + 1) + } + } - token, err := auth.GenerateToken(user.ID, user.User, user.RoleID, h.jwtSecret) + token, err := auth.GenerateTokenAt(user.ID, user.User, user.RoleID, h.jwtSecret, issueAt) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return @@ -557,10 +576,27 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } - if user == nil || user.Pwd != security.MD5(password) { + if user == nil { response.WriteJSON(w, response.ErrDefault("鉴权失败")) return } + passwordMatched, passwordWasLegacy := security.VerifyPassword(user.Pwd, password) + if !passwordMatched { + response.WriteJSON(w, response.ErrDefault("鉴权失败")) + return + } + if user.Status == 0 { + response.WriteJSON(w, response.ErrDefault("账号被停用")) + return + } + if passwordWasLegacy { + hashedPassword, err := security.HashPassword(password) + if err != nil { + log.Printf("legacy password rehash skipped user_id=%d path=sub_store err=%v", user.ID, err) + } else if err := h.repo.UpdateUserPassword(user.ID, hashedPassword, time.Now().UnixMilli()); err != nil { + log.Printf("legacy password rehash update skipped user_id=%d path=sub_store err=%v", user.ID, err) + } + } const giga = int64(1024 * 1024 * 1024) headerValue := "" @@ -948,6 +984,10 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) { if key == "" { continue } + if repo.IsSensitiveConfigKey(key) { + response.WriteJSON(w, response.Err(403, "禁止访问敏感配置")) + return + } if protectedKeys[key] && isCommercial != "true" { response.WriteJSON(w, response.ErrDefault("需要商业版授权")) @@ -985,6 +1025,10 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("配置名称不能为空")) return } + if repo.IsSensitiveConfigKey(name) { + response.WriteJSON(w, response.Err(403, "禁止访问敏感配置")) + return + } isCommercial, _ := h.repo.GetViteConfigValue("is_commercial") if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" { @@ -1255,7 +1299,8 @@ func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) { return } - if user.Pwd != security.MD5(req.CurrentPassword) { + passwordMatched, _ := security.VerifyPassword(user.Pwd, req.CurrentPassword) + if !passwordMatched { response.WriteJSON(w, response.ErrDefault("当前密码错误")) return } @@ -1270,7 +1315,12 @@ func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) { return } - if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, security.MD5(req.NewPassword), time.Now().UnixMilli()); err != nil { + hashedPassword, err := security.HashPassword(req.NewPassword) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, hashedPassword, time.Now().UnixMilli()); err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return } diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 5ac689e..6bcfbc9 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -71,7 +71,12 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) { now := time.Now().UnixMilli() maxConn := asInt(req["maxConn"], 0) - userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, maxConn, now) + hashedPassword, err := security.HashPassword(pwd) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + userID, err := h.repo.CreateUser(username, hashedPassword, roleID, expTime, flow, flowResetTime, num, status, maxConn, now) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return @@ -176,7 +181,12 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) { return } } else { - if err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, maxConn, now); err != nil { + hashedPassword, err := security.HashPassword(pwd) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if err := h.repo.UpdateUserWithPassword(id, username, hashedPassword, flow, num, expTime, flowResetTime, status, maxConn, now); err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return } diff --git a/go-backend/internal/http/middleware/auth.go b/go-backend/internal/http/middleware/auth.go index c1ba1af..09fd77f 100644 --- a/go-backend/internal/http/middleware/auth.go +++ b/go-backend/internal/http/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "context" "net/http" + "strconv" "strings" "go-backend/internal/auth" @@ -14,7 +15,8 @@ type contextKey string const ClaimsContextKey contextKey = "claims" type AuthOptions struct { - JWTSecret string + JWTSecret string + GetUserAuthState func(userID int64) (*auth.UserAuthState, error) } func JWT(opts AuthOptions) func(http.Handler) http.Handler { @@ -42,6 +44,19 @@ func JWT(opts AuthOptions) func(http.Handler) http.Handler { return } + if opts.GetUserAuthState != nil { + userID, err := strconv.ParseInt(claims.Sub, 10, 64) + if err != nil { + response.WriteJSON(w, response.Err(401, "无效的token或token已过期")) + return + } + state, err := opts.GetUserAuthState(userID) + if err != nil || state == nil || state.Status != 1 || state.RoleID != claims.RoleID || claims.IatMs <= state.PasswordChangedAt { + response.WriteJSON(w, response.Err(401, "无效的token或token已过期")) + return + } + } + if requiresAdmin(r.URL.Path) && claims.RoleID != 0 { response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作")) return @@ -78,9 +93,11 @@ func shouldSkip(path string) bool { case strings.HasPrefix(path, "/api/v1/captcha/"): return true case path == "/api/v1/config/get": - return true + return false case path == "/api/v1/user/login": return true + case path == "/api/v1/public/config/get": + return true case path == "/api/v1/federation/connect": return true case path == "/api/v1/federation/tunnel/create": diff --git a/go-backend/internal/http/middleware/auth_test.go b/go-backend/internal/http/middleware/auth_test.go new file mode 100644 index 0000000..f80020c --- /dev/null +++ b/go-backend/internal/http/middleware/auth_test.go @@ -0,0 +1,243 @@ +package middleware + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "go-backend/internal/auth" + "go-backend/internal/http/response" +) + +func TestJWTRejectsPasswordChangedToken(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + claims, err := auth.ParseClaims(token, secret) + if err != nil { + t.Fatalf("parse claims: %v", err) + } + + wrapped := JWT(AuthOptions{ + JWTSecret: secret, + GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs + 1}, nil + }, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.WriteJSON(w, response.OK("pass")) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil) + req.Header.Set("Authorization", token) + res := httptest.NewRecorder() + wrapped.ServeHTTP(res, req) + assertAuthDenied(t, res) +} + +func TestJWTAcceptsCurrentUserState(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + claims, err := auth.ParseClaims(token, secret) + if err != nil { + t.Fatalf("parse claims: %v", err) + } + + wrapped := JWT(AuthOptions{ + JWTSecret: secret, + GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs - 1}, nil + }, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.WriteJSON(w, response.OK("pass")) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil) + req.Header.Set("Authorization", token) + res := httptest.NewRecorder() + wrapped.ServeHTTP(res, req) + assertCode(t, res, 0) +} + +func TestJWTRejectsDisabledUserToken(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + wrapped := JWT(AuthOptions{ + JWTSecret: secret, + GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 0, PasswordChangedAt: time.Now().Unix()}, nil + }, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.WriteJSON(w, response.OK("pass")) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil) + req.Header.Set("Authorization", token) + res := httptest.NewRecorder() + wrapped.ServeHTTP(res, req) + assertAuthDenied(t, res) +} + +func TestJWTRejectsPasswordChangedAtSameMillisecond(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + claims, err := auth.ParseClaims(token, secret) + if err != nil { + t.Fatalf("parse claims: %v", err) + } + + wrapped := JWT(AuthOptions{ + JWTSecret: secret, + GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs}, nil + }, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.WriteJSON(w, response.OK("pass")) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil) + req.Header.Set("Authorization", token) + res := httptest.NewRecorder() + wrapped.ServeHTTP(res, req) + assertAuthDenied(t, res) +} + +func TestJWTRejectsRoleMismatch(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + wrapped := JWT(AuthOptions{ + JWTSecret: secret, + GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil + }, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.WriteJSON(w, response.OK("pass")) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil) + req.Header.Set("Authorization", token) + res := httptest.NewRecorder() + wrapped.ServeHTTP(res, req) + assertAuthDenied(t, res) +} + +func TestJWTRejectsMissingUserState(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + wrapped := JWT(AuthOptions{ + JWTSecret: secret, + GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) { + return nil, nil + }, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.WriteJSON(w, response.OK("pass")) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil) + req.Header.Set("Authorization", token) + res := httptest.NewRecorder() + wrapped.ServeHTTP(res, req) + assertAuthDenied(t, res) +} + +func TestJWTRejectsAuthStateLookupError(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + wrapped := JWT(AuthOptions{ + JWTSecret: secret, + GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) { + return nil, errors.New("boom") + }, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.WriteJSON(w, response.OK("pass")) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil) + req.Header.Set("Authorization", token) + res := httptest.NewRecorder() + wrapped.ServeHTTP(res, req) + assertAuthDenied(t, res) +} + +func TestShouldSkipDoesNotBypassConfigGet(t *testing.T) { + if shouldSkip("/api/v1/config/get") { + t.Fatal("expected /api/v1/config/get to require auth") + } +} + +func TestShouldSkipBypassesPublicConfigGet(t *testing.T) { + if !shouldSkip("/api/v1/public/config/get") { + t.Fatal("expected /api/v1/public/config/get to remain public") + } +} + +func TestJWTExpiresAfterSevenDays(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + claims, err := auth.ParseClaims(token, secret) + if err != nil { + t.Fatalf("parse claims: %v", err) + } + if got := claims.Exp - claims.Iat; got != int64(7*24*time.Hour/time.Second) { + t.Fatalf("expected 7 day token lifetime, got %d seconds", got) + } + if claims.IatMs <= 0 { + t.Fatalf("expected millisecond issuance time to be populated, got %d", claims.IatMs) + } +} + +func assertCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) { + t.Helper() + var out response.R + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != expected { + t.Fatalf("expected code %d, got %d", expected, out.Code) + } +} + +func assertAuthDenied(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + assertCodeMsg(t, rec, 401, "无效的token或token已过期") +} + +func assertCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) { + t.Helper() + var out response.R + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != expectedCode || out.Msg != expectedMsg { + t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg) + } +} diff --git a/go-backend/internal/http/router.go b/go-backend/internal/http/router.go index a18ed47..5e04a25 100644 --- a/go-backend/internal/http/router.go +++ b/go-backend/internal/http/router.go @@ -13,7 +13,7 @@ func NewRouter(h *handler.Handler, jwtSecret string) http.Handler { mux.Handle("/system-info", h.WebSocketHandler()) wrapped := middleware.Recover(mux) - wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret})(wrapped) + wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret, GetUserAuthState: h.GetUserAuthState})(wrapped) wrapped = middleware.RequestLog(wrapped) wrapped = middleware.CORS(wrapped) return wrapped diff --git a/go-backend/internal/security/password.go b/go-backend/internal/security/password.go new file mode 100644 index 0000000..5ed5597 --- /dev/null +++ b/go-backend/internal/security/password.go @@ -0,0 +1,39 @@ +package security + +import ( + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(plain string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func VerifyPassword(storedHash, plain string) (bool, bool) { + if strings.HasPrefix(storedHash, "$2") { + return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) == nil, false + } + if MD5(plain) == storedHash { + return true, true + } + return false, false +} + +func IsLegacyPasswordHash(storedHash string) bool { + storedHash = strings.TrimSpace(storedHash) + if len(storedHash) != 32 { + return false + } + for _, r := range storedHash { + if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') { + continue + } + return false + } + return true +} diff --git a/go-backend/internal/security/password_test.go b/go-backend/internal/security/password_test.go new file mode 100644 index 0000000..eb8227e --- /dev/null +++ b/go-backend/internal/security/password_test.go @@ -0,0 +1,38 @@ +package security + +import ( + "strings" + "testing" +) + +func TestHashPasswordProducesBcrypt(t *testing.T) { + hash, err := HashPassword("admin_user") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if len(hash) < 50 || !strings.HasPrefix(hash, "$2") { + t.Fatalf("expected bcrypt hash, got %q", hash) + } + if ok, legacy := VerifyPassword(hash, "admin_user"); !ok || legacy { + t.Fatalf("VerifyPassword() = (%v,%v), want (true,false)", ok, legacy) + } +} + +func TestVerifyPasswordAcceptsLegacyMD5(t *testing.T) { + if ok, legacy := VerifyPassword("3c85cdebade1c51cf64ca9f3c09d182d", "admin_user"); !ok || !legacy { + t.Fatalf("VerifyPassword() = (%v,%v), want (true,true)", ok, legacy) + } +} + +func TestIsLegacyPasswordHash(t *testing.T) { + if !IsLegacyPasswordHash("3c85cdebade1c51cf64ca9f3c09d182d") { + t.Fatal("expected 32-char hex MD5 hash to be legacy") + } + hash, err := HashPassword("admin_user") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if IsLegacyPasswordHash(hash) { + t.Fatalf("expected bcrypt hash not to be legacy: %q", hash) + } +} diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index 965c6fe..488d139 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -10,20 +10,21 @@ import "database/sql" // User maps to the "user" table. PostgreSQL treats "user" as a reserved // word, so TableName() is required for correct quoting. type User struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - User string `gorm:"column:user;type:varchar(100);not null"` - Pwd string `gorm:"type:varchar(100);not null"` - RoleID int `gorm:"column:role_id;not null"` - ExpTime int64 `gorm:"column:exp_time;not null"` - Flow int64 `gorm:"not null"` - InFlow int64 `gorm:"column:in_flow;not null;default:0"` - OutFlow int64 `gorm:"column:out_flow;not null;default:0"` - FlowResetTime int64 `gorm:"column:flow_reset_time;not null"` - Num int `gorm:"not null"` - CreatedTime int64 `gorm:"column:created_time;not null"` - UpdatedTime sql.NullInt64 `gorm:"column:updated_time"` - Status int `gorm:"not null"` - MaxConn int `gorm:"column:max_conn;not null;default:0"` + ID int64 `gorm:"primaryKey;autoIncrement"` + User string `gorm:"column:user;type:varchar(100);not null"` + Pwd string `gorm:"type:varchar(100);not null"` + RoleID int `gorm:"column:role_id;not null"` + ExpTime int64 `gorm:"column:exp_time;not null"` + Flow int64 `gorm:"not null"` + InFlow int64 `gorm:"column:in_flow;not null;default:0"` + OutFlow int64 `gorm:"column:out_flow;not null;default:0"` + FlowResetTime int64 `gorm:"column:flow_reset_time;not null"` + Num int `gorm:"not null"` + CreatedTime int64 `gorm:"column:created_time;not null"` + UpdatedTime sql.NullInt64 `gorm:"column:updated_time"` + Status int `gorm:"not null"` + PasswordChangedAt int64 `gorm:"column:password_changed_at;not null;default:0"` + MaxConn int `gorm:"column:max_conn;not null;default:0"` } func (User) TableName() string { return "user" } diff --git a/go-backend/internal/store/repo/config_policy.go b/go-backend/internal/store/repo/config_policy.go new file mode 100644 index 0000000..df4c41c --- /dev/null +++ b/go-backend/internal/store/repo/config_policy.go @@ -0,0 +1,62 @@ +package repo + +import "strings" + +type ConfigAccessPolicy string + +const ( + ConfigAccessPublic ConfigAccessPolicy = "public" + ConfigAccessSensitive ConfigAccessPolicy = "sensitive" +) + +var publicConfigKeys = map[string]struct{}{ + "app_name": {}, + "app_logo": {}, + "app_favicon": {}, + "app_bg_image": {}, + "cloudflare_site_key": {}, +} + +var sensitiveConfigKeys = map[string]struct{}{ + "jwt_secret": {}, + "license_key": {}, + "cloudflare_secret_key": {}, +} + +func PolicyForConfig(name string) ConfigAccessPolicy { + if IsPublicConfigKey(name) { + return ConfigAccessPublic + } + if IsSensitiveConfigKey(name) { + return ConfigAccessSensitive + } + return ConfigAccessSensitive +} + +func IsPublicConfigKey(name string) bool { + _, ok := publicConfigKeys[normalizeConfigKey(name)] + return ok +} + +func IsSensitiveConfigKey(name string) bool { + _, ok := sensitiveConfigKeys[normalizeConfigKey(name)] + return ok +} + +func FilterSensitiveConfigs(in map[string]string) map[string]string { + if len(in) == 0 { + return map[string]string{} + } + out := make(map[string]string, len(in)) + for name, value := range in { + if IsSensitiveConfigKey(name) { + continue + } + out[name] = value + } + return out +} + +func normalizeConfigKey(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} diff --git a/go-backend/internal/store/repo/config_policy_test.go b/go-backend/internal/store/repo/config_policy_test.go new file mode 100644 index 0000000..4697cf2 --- /dev/null +++ b/go-backend/internal/store/repo/config_policy_test.go @@ -0,0 +1,69 @@ +package repo + +import "testing" + +func TestConfigPolicy(t *testing.T) { + tests := []struct { + name string + key string + want ConfigAccessPolicy + }{ + {name: "app_name is public", key: "app_name", want: ConfigAccessPublic}, + {name: "app_logo is public", key: "app_logo", want: ConfigAccessPublic}, + {name: "app_favicon is public", key: "app_favicon", want: ConfigAccessPublic}, + {name: "app_bg_image is public", key: "app_bg_image", want: ConfigAccessPublic}, + {name: "cloudflare_site_key is public", key: "cloudflare_site_key", want: ConfigAccessPublic}, + {name: "jwt_secret is sensitive", key: "jwt_secret", want: ConfigAccessSensitive}, + {name: "license_key is sensitive", key: "license_key", want: ConfigAccessSensitive}, + {name: "cloudflare_secret_key is sensitive", key: "cloudflare_secret_key", want: ConfigAccessSensitive}, + {name: "trimmed public key is public", key: " APP_NAME ", want: ConfigAccessPublic}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PolicyForConfig(tt.key); got != tt.want { + t.Fatalf("PolicyForConfig(%q) = %v, want %v", tt.key, got, tt.want) + } + }) + } +} + +func TestConfigPolicyHelpers(t *testing.T) { + publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "cloudflare_site_key"} + for _, key := range publicKeys { + if !IsPublicConfigKey(key) { + t.Fatalf("expected %q to be public", key) + } + } + + sensitiveKeys := []string{"jwt_secret", "license_key", "cloudflare_secret_key"} + for _, key := range sensitiveKeys { + if !IsSensitiveConfigKey(key) { + t.Fatalf("expected %q to be sensitive", key) + } + } + + input := map[string]string{ + "app_name": "FLVX", + "license_key": "secret-license", + "cloudflare_secret_key": "secret-cloudflare", + "jwt_secret": "secret-jwt", + "cloudflare_site_key": "site-key", + } + filtered := FilterSensitiveConfigs(input) + if len(filtered) != 2 { + t.Fatalf("expected 2 public configs, got %d", len(filtered)) + } + if filtered["app_name"] != "FLVX" || filtered["cloudflare_site_key"] != "site-key" { + t.Fatalf("unexpected filtered configs: %+v", filtered) + } + if _, ok := filtered["jwt_secret"]; ok { + t.Fatal("expected jwt_secret to be filtered out") + } + if _, ok := filtered["license_key"]; ok { + t.Fatal("expected license_key to be filtered out") + } + if _, ok := filtered["cloudflare_secret_key"]; ok { + t.Fatal("expected cloudflare_secret_key to be filtered out") + } +} diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 0debbd5..b4a5d2f 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -1,7 +1,9 @@ package repo import ( + "crypto/rand" "database/sql" + "encoding/hex" "errors" "fmt" "log" @@ -19,6 +21,7 @@ import ( "gorm.io/gorm/clause" "gorm.io/gorm/logger" + "go-backend/internal/security" "go-backend/internal/store/model" ) @@ -416,13 +419,23 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error { } func seedData(db *gorm.DB) { - adminUser := model.User{ - ID: 1, User: "admin_user", Pwd: "3c85cdebade1c51cf64ca9f3c09d182d", - RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0, - FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000, - UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1, + var adminCount int64 + if err := db.Model(&model.User{}).Where("id = ?", 1).Count(&adminCount).Error; err == nil && adminCount == 0 { + adminPwd, err := security.HashPassword("admin_user") + if err != nil { + log.Printf("seed admin password hash failed: %v", err) + } else { + adminUser := model.User{ + ID: 1, User: "admin_user", Pwd: adminPwd, + RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0, + FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000, + UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, + Status: 1, + PasswordChangedAt: 1748914865000, + } + db.Create(&adminUser) + } } - db.Where("id = ?", 1).FirstOrCreate(&adminUser) appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000} db.Where("id = ?", 1).FirstOrCreate(&appNameConfig) @@ -486,9 +499,10 @@ func (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordM return errors.New("repository not initialized") } return r.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ - "user": username, - "pwd": passwordMD5, - "updated_time": now, + "user": username, + "pwd": passwordMD5, + "password_changed_at": now, + "updated_time": now, }).Error } @@ -1890,7 +1904,7 @@ func (r *Repository) ExportAll() (*model.BackupData, error) { if err != nil { return nil, fmt.Errorf("export configs failed: %w", err) } - backup.Configs = configs + backup.Configs = FilterSensitiveConfigs(configs) return backup, nil } @@ -1970,7 +1984,7 @@ func (r *Repository) ExportPartial(types []string) (*model.BackupData, error) { if err != nil { return nil, fmt.Errorf("export configs failed: %w", err) } - backup.Configs = v + backup.Configs = FilterSensitiveConfigs(v) } return backup, nil } @@ -2352,26 +2366,31 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) { count := 0 for _, u := range users { - item := model.User{ - ID: u.ID, - User: u.User, - Pwd: u.Pwd, - RoleID: u.RoleID, - ExpTime: u.ExpTime, - Flow: u.Flow, - InFlow: u.InFlow, - OutFlow: u.OutFlow, - FlowResetTime: u.FlowResetTime, - Num: u.Num, - CreatedTime: u.CreatedTime, - UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, - Status: u.Status, + pwdHash, status, err := normalizeImportedUserPassword(u.Pwd, u.Status) + if err != nil { + return count, err } - err := tx.Clauses(clause.OnConflict{ + item := model.User{ + ID: u.ID, + User: u.User, + Pwd: pwdHash, + RoleID: u.RoleID, + ExpTime: u.ExpTime, + Flow: u.Flow, + InFlow: u.InFlow, + OutFlow: u.OutFlow, + FlowResetTime: u.FlowResetTime, + Num: u.Num, + CreatedTime: u.CreatedTime, + UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, + Status: status, + PasswordChangedAt: now, + } + err = tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ "user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow", - "flow_reset_time", "num", "updated_time", "status", + "flow_reset_time", "num", "updated_time", "status", "password_changed_at", }), }).Create(&item).Error if err != nil { @@ -2415,6 +2434,33 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) return count, nil } +func normalizeImportedUserPassword(password string, status int) (string, int, error) { + password = strings.TrimSpace(password) + if strings.HasPrefix(password, "$2") { + return password, status, nil + } + if security.IsLegacyPasswordHash(password) || password == "" { + replacement, err := randomPasswordHash() + if err != nil { + return "", status, err + } + return replacement, 0, nil + } + hash, err := security.HashPassword(password) + if err != nil { + return "", status, err + } + return hash, status, nil +} + +func randomPasswordHash() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return security.HashPassword(hex.EncodeToString(buf)) +} + func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) { count := 0 for _, n := range nodes { @@ -2726,6 +2772,7 @@ func importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int6 } func importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) { + configs = FilterSensitiveConfigs(configs) count := 0 for name, value := range configs { err := tx.Clauses(clause.OnConflict{ diff --git a/go-backend/internal/store/repo/repository_auth.go b/go-backend/internal/store/repo/repository_auth.go new file mode 100644 index 0000000..9f80823 --- /dev/null +++ b/go-backend/internal/store/repo/repository_auth.go @@ -0,0 +1,29 @@ +package repo + +import ( + "errors" + + "go-backend/internal/auth" + "go-backend/internal/store/model" +) + +func (r *Repository) GetUserAuthState(userID int64) (*auth.UserAuthState, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var user struct { + ID int64 `gorm:"column:id"` + RoleID int `gorm:"column:role_id"` + Status int `gorm:"column:status"` + PasswordChangedAt int64 `gorm:"column:password_changed_at"` + } + if err := r.db.Model(&model.User{}).Select("id", "role_id", "status", "password_changed_at").Where("id = ?", userID).First(&user).Error; err != nil { + return nil, normalizeNotFoundErr(err) + } + return &auth.UserAuthState{ + ID: user.ID, + RoleID: user.RoleID, + Status: user.Status, + PasswordChangedAt: user.PasswordChangedAt, + }, nil +} diff --git a/go-backend/internal/store/repo/repository_auth_test.go b/go-backend/internal/store/repo/repository_auth_test.go new file mode 100644 index 0000000..19a3949 --- /dev/null +++ b/go-backend/internal/store/repo/repository_auth_test.go @@ -0,0 +1,29 @@ +package repo + +import ( + "path/filepath" + "testing" + "time" +) + +func TestGetUserAuthStateReturnsPasswordChangedAt(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "auth.db")) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + userID, err := r.CreateUser("admin_user", "pwd", 0, 2727251700000, 99999, 1, 99999, 1, 0, now) + if err != nil { + t.Fatalf("CreateUser() error = %v", err) + } + + state, err := r.GetUserAuthState(userID) + if err != nil { + t.Fatalf("GetUserAuthState() error = %v", err) + } + if state == nil || state.PasswordChangedAt != now || state.Status != 1 || state.RoleID != 0 { + t.Fatalf("unexpected auth state: %+v", state) + } +} diff --git a/go-backend/internal/store/repo/repository_backup_test.go b/go-backend/internal/store/repo/repository_backup_test.go index e205459..f55d363 100644 --- a/go-backend/internal/store/repo/repository_backup_test.go +++ b/go-backend/internal/store/repo/repository_backup_test.go @@ -4,8 +4,33 @@ import ( "path/filepath" "testing" "time" + + "go-backend/internal/security" + "go-backend/internal/store/model" ) +func TestSeedDataDefaultAdminUsesBcrypt(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "seed.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + admin, err := r.GetUserByUsername("admin_user") + if err != nil { + t.Fatalf("get admin user: %v", err) + } + if admin == nil { + t.Fatal("expected seeded admin user") + } + if security.IsLegacyPasswordHash(admin.Pwd) { + t.Fatalf("seeded admin password is legacy MD5: %q", admin.Pwd) + } + if ok, legacy := security.VerifyPassword(admin.Pwd, "admin_user"); !ok || legacy { + t.Fatalf("VerifyPassword() = (%v,%v), want (true,false)", ok, legacy) + } +} + func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) { source, err := Open(filepath.Join(t.TempDir(), "source.db")) if err != nil { @@ -57,3 +82,157 @@ func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) { t.Fatalf("unexpected imported probe target: %+v", items[0]) } } + +func TestExportAllOmitsSensitiveConfigs(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "export.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + seedConfig(t, r, "app_name", "FLVX") + seedConfig(t, r, "app_logo", "logo") + seedConfig(t, r, "app_favicon", "favicon") + seedConfig(t, r, "app_bg_image", "bg") + seedConfig(t, r, "cloudflare_site_key", "site-key") + seedConfig(t, r, "jwt_secret", "jwt-secret") + seedConfig(t, r, "license_key", "license-secret") + seedConfig(t, r, "cloudflare_secret_key", "cloudflare-secret") + + for _, tc := range []struct { + name string + export func() (*model.BackupData, error) + }{ + {name: "ExportAll", export: r.ExportAll}, + {name: "ExportPartial", export: func() (*model.BackupData, error) { return r.ExportPartial([]string{"configs"}) }}, + } { + t.Run(tc.name, func(t *testing.T) { + backup, err := tc.export() + if err != nil { + t.Fatalf("export backup: %v", err) + } + if backup.Configs["app_name"] != "FLVX" { + t.Fatalf("expected public config in export, got %+v", backup.Configs) + } + if backup.Configs["cloudflare_site_key"] != "site-key" { + t.Fatalf("expected public config in export, got %+v", backup.Configs) + } + for _, key := range []string{"jwt_secret", "license_key", "cloudflare_secret_key"} { + if _, ok := backup.Configs[key]; ok { + t.Fatalf("expected %s to be omitted from export, got %+v", key, backup.Configs) + } + } + }) + } +} + +func TestImportIgnoresSensitiveConfigs(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "import.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + seedConfig(t, r, "app_name", "before") + seedConfig(t, r, "jwt_secret", "jwt-before") + seedConfig(t, r, "license_key", "license-before") + seedConfig(t, r, "cloudflare_secret_key", "cloudflare-before") + + backup := &model.BackupData{Configs: map[string]string{ + "app_name": "after", + "jwt_secret": "jwt-after", + "license_key": "license-after", + "cloudflare_secret_key": "cloudflare-after", + }} + + result, err := r.Import(backup, []string{"configs"}) + if err != nil { + t.Fatalf("import backup: %v", err) + } + if result.ConfigsImported != 1 { + t.Fatalf("expected one imported config, got %d", result.ConfigsImported) + } + + assertConfigValue(t, r, "app_name", "after") + assertConfigValue(t, r, "jwt_secret", "jwt-before") + assertConfigValue(t, r, "license_key", "license-before") + assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before") +} + +func TestImportUsersDoesNotStoreLegacyMD5Passwords(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "legacy-user-import.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + backup := &model.BackupData{ + Version: "1.0", + Users: []model.UserBackup{{ + ID: 55, + User: "legacy-import-user", + Pwd: "3c85cdebade1c51cf64ca9f3c09d182d", + RoleID: 1, + ExpTime: 2727251700000, + Flow: 99999, + InFlow: 0, + OutFlow: 0, + FlowResetTime: 1, + Num: 99999, + CreatedTime: now, + UpdatedTime: now, + Status: 1, + }}, + } + + result, err := r.Import(backup, []string{"users"}) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + if result.UsersImported != 1 { + t.Fatalf("UsersImported = %d, want 1", result.UsersImported) + } + + user, err := r.GetUserByUsername("legacy-import-user") + if err != nil { + t.Fatalf("get imported user: %v", err) + } + if user == nil { + t.Fatal("expected imported user") + } + if security.IsLegacyPasswordHash(user.Pwd) { + t.Fatalf("imported password remained legacy MD5: %q", user.Pwd) + } + if ok, _ := security.VerifyPassword(user.Pwd, "admin_user"); ok { + t.Fatal("legacy imported password should not remain usable") + } + if user.Status != 0 { + t.Fatalf("legacy imported user status = %d, want disabled status 0", user.Status) + } + if user.PasswordChangedAt <= 0 { + t.Fatalf("PasswordChangedAt = %d, want import revocation timestamp", user.PasswordChangedAt) + } +} + +func seedConfig(t *testing.T, r *Repository, name, value string) { + t.Helper() + if err := r.DB().Exec(` + INSERT INTO vite_config(name, value, time) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time + `, name, value, time.Now().UnixMilli()).Error; err != nil { + t.Fatalf("seed config %s: %v", name, err) + } +} + +func assertConfigValue(t *testing.T, r *Repository, name, want string) { + t.Helper() + cfg, err := r.GetConfigByName(name) + if err != nil { + t.Fatalf("get config %s: %v", name, err) + } + if cfg == nil || cfg.Value != want { + t.Fatalf("expected config %s=%q, got %+v", name, want, cfg) + } +} diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index 54f2cdc..6e8b53d 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -42,19 +42,20 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f return 0, errors.New("repository not initialized") } user := model.User{ - User: username, - Pwd: pwdHash, - RoleID: roleID, - ExpTime: expTime, - Flow: flow, - InFlow: 0, - OutFlow: 0, - FlowResetTime: flowResetTime, - Num: num, - MaxConn: maxConn, - CreatedTime: now, - UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, - Status: status, + User: username, + Pwd: pwdHash, + RoleID: roleID, + ExpTime: expTime, + Flow: flow, + InFlow: 0, + OutFlow: 0, + FlowResetTime: flowResetTime, + Num: num, + MaxConn: maxConn, + CreatedTime: now, + UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, + Status: status, + PasswordChangedAt: now, } if err := r.db.Create(&user).Error; err != nil { return 0, err @@ -81,15 +82,16 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, return r.db.Model(&model.User{}). Where("id = ?", id). Updates(map[string]interface{}{ - "user": username, - "pwd": pwdHash, - "flow": flow, - "num": num, - "exp_time": expTime, - "flow_reset_time": flowResetTime, - "status": status, - "max_conn": maxConn, - "updated_time": sql.NullInt64{Int64: now, Valid: true}, + "user": username, + "pwd": pwdHash, + "flow": flow, + "num": num, + "exp_time": expTime, + "flow_reset_time": flowResetTime, + "status": status, + "max_conn": maxConn, + "password_changed_at": now, + "updated_time": sql.NullInt64{Int64: now, Valid: true}, }).Error } @@ -111,6 +113,19 @@ func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow i }).Error } +func (r *Repository) UpdateUserPassword(userID int64, pwdHash string, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + return r.db.Model(&model.User{}). + Where("id = ?", userID). + Updates(map[string]interface{}{ + "pwd": pwdHash, + "password_changed_at": now, + "updated_time": sql.NullInt64{Int64: now, Valid: true}, + }).Error +} + func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num int, expTime, flowResetTime int64) { if r == nil || r.db == nil { return diff --git a/go-backend/internal/ws/server.go b/go-backend/internal/ws/server.go index 52ff3bf..82d5b2b 100644 --- a/go-backend/internal/ws/server.go +++ b/go-backend/internal/ws/server.go @@ -42,6 +42,12 @@ type nodeSession struct { crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建 } +type adminSession struct { + userID int64 + claims auth.Claims + conn *connWrap +} + type commandResponse struct { Type string `json:"type"` Success bool `json:"success"` @@ -74,9 +80,10 @@ type Server struct { upgrader websocket.Upgrader onNodeOnline func(nodeID int64) onNodeMetric func(nodeID int64, info SystemInfo) + getUserAuthState func(userID int64) (*auth.UserAuthState, error) mu sync.RWMutex - admins map[*connWrap]struct{} + admins map[*adminSession]struct{} nodes map[int64]*nodeSession byConn map[*websocket.Conn]*nodeSession pending map[string]pendingRequest @@ -123,13 +130,22 @@ func NewServer(repo *repo.Repository, jwtSecret string) *Server { upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, }, - admins: make(map[*connWrap]struct{}), + admins: make(map[*adminSession]struct{}), nodes: make(map[int64]*nodeSession), byConn: make(map[*websocket.Conn]*nodeSession), pending: make(map[string]pendingRequest), } } +func (s *Server) SetUserAuthStateLookup(fn func(userID int64) (*auth.UserAuthState, error)) { + if s == nil { + return + } + s.mu.Lock() + s.getUserAuthState = fn + s.mu.Unlock() +} + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() typeVal := query.Get("type") @@ -146,18 +162,32 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if typeVal == "0" { - if _, ok := auth.ValidateToken(secret, s.jwtSecret); !ok { + claims, ok := auth.ValidateToken(secret, s.jwtSecret) + if !ok { http.Error(w, "forbidden", http.StatusForbidden) return } - s.handleAdmin(w, r) + userID, err := strconv.ParseInt(claims.Sub, 10, 64) + if err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if claims.RoleID != 0 { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if !s.validateAdminSession(userID, claims) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + s.handleAdmin(w, r, userID, claims) return } http.Error(w, "bad request", http.StatusBadRequest) } -func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request, userID int64, claims auth.Claims) { conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { return @@ -168,16 +198,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { return conn.SetReadDeadline(time.Now().Add(wsPongWait)) }) done := make(chan struct{}) - go startKeepalive(cw, done) + session := &adminSession{userID: userID, claims: claims, conn: cw} + go startKeepalive(cw, done, func() bool { + return s.validateAdminSession(session.userID, session.claims) + }) s.mu.Lock() - s.admins[cw] = struct{}{} + s.admins[session] = struct{}{} s.mu.Unlock() defer func() { close(done) s.mu.Lock() - delete(s.admins, cw) + delete(s.admins, session) s.mu.Unlock() _ = conn.Close() }() @@ -200,7 +233,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64 return conn.SetReadDeadline(time.Now().Add(wsPongWait)) }) done := make(chan struct{}) - go startKeepalive(cw, done) + go startKeepalive(cw, done, nil) version := r.URL.Query().Get("version") httpVal := parseIntDefault(r.URL.Query().Get("http"), 0) @@ -554,18 +587,21 @@ func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) { func (s *Server) broadcastToAdmins(message string) { s.mu.RLock() - admins := make([]*connWrap, 0, len(s.admins)) + admins := make([]*adminSession, 0, len(s.admins)) for c := range s.admins { admins = append(admins, c) } s.mu.RUnlock() for _, c := range admins { - c.mu.Lock() - _ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) - err := c.conn.WriteMessage(websocket.TextMessage, []byte(message)) - _ = c.conn.SetWriteDeadline(time.Time{}) - c.mu.Unlock() + if c == nil || c.conn == nil || c.conn.conn == nil { + continue + } + c.conn.mu.Lock() + _ = c.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) + err := c.conn.conn.WriteMessage(websocket.TextMessage, []byte(message)) + _ = c.conn.conn.SetWriteDeadline(time.Time{}) + c.conn.mu.Unlock() if err != nil { log.Printf("websocket broadcast failed: %v", err) } @@ -602,7 +638,24 @@ func parseIntDefault(v string, fallback int) int { return x } -func startKeepalive(cw *connWrap, done <-chan struct{}) { +func (s *Server) validateAdminSession(userID int64, claims auth.Claims) bool { + if s == nil { + return false + } + if claims.Exp <= time.Now().Unix() { + return false + } + if s.getUserAuthState == nil { + return true + } + state, err := s.getUserAuthState(userID) + if err != nil || state == nil || state.Status != 1 || state.RoleID != claims.RoleID || claims.IatMs <= state.PasswordChangedAt { + return false + } + return true +} + +func startKeepalive(cw *connWrap, done <-chan struct{}, validate func() bool) { if cw == nil || cw.conn == nil { return } @@ -614,6 +667,10 @@ func startKeepalive(cw *connWrap, done <-chan struct{}) { case <-done: return case <-ticker.C: + if validate != nil && !validate() { + _ = cw.conn.Close() + return + } cw.mu.Lock() _ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) err := cw.conn.WriteMessage(websocket.PingMessage, nil) diff --git a/go-backend/internal/ws/server_test.go b/go-backend/internal/ws/server_test.go new file mode 100644 index 0000000..b1322ad --- /dev/null +++ b/go-backend/internal/ws/server_test.go @@ -0,0 +1,110 @@ +package ws + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "go-backend/internal/auth" +) + +func TestServeHTTPRejectsDisabledAdminToken(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + server := NewServer(nil, secret) + server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 0, PasswordChangedAt: 0}, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/system-info?type=0&secret="+url.QueryEscape(token), nil) + rec := httptest.NewRecorder() + + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected forbidden for disabled admin token, got %d", rec.Code) + } +} + +func TestServeHTTPRejectsNonAdminToken(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(2, "normal_user", 1, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + server := NewServer(nil, secret) + server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/system-info?type=0&secret="+url.QueryEscape(token), nil) + rec := httptest.NewRecorder() + + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected forbidden for non-admin token, got %d", rec.Code) + } +} + +func TestValidateAdminSessionRejectsAuthStateChanges(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + claims, err := auth.ParseClaims(token, secret) + if err != nil { + t.Fatalf("parse claims: %v", err) + } + + tests := []struct { + name string + state *auth.UserAuthState + }{ + {name: "disabled", state: &auth.UserAuthState{ID: 1, RoleID: 0, Status: 0, PasswordChangedAt: 0}}, + {name: "role changed", state: &auth.UserAuthState{ID: 1, RoleID: 1, Status: 1, PasswordChangedAt: 0}}, + {name: "password changed", state: &auth.UserAuthState{ID: 1, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs + 1}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := NewServer(nil, secret) + server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) { + return tt.state, nil + }) + + if ok := server.validateAdminSession(1, claims); ok { + t.Fatalf("expected session validation to fail for %s state", tt.name) + } + }) + } +} + +func TestValidateAdminSessionRejectsExpiredToken(t *testing.T) { + secret := "unit-test-secret" + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + claims, err := auth.ParseClaims(token, secret) + if err != nil { + t.Fatalf("parse claims: %v", err) + } + claims.Exp = 1 + + server := NewServer(nil, secret) + server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: 0}, nil + }) + + if ok := server.validateAdminSession(1, claims); ok { + t.Fatal("expected expired token to be rejected") + } +} diff --git a/go-backend/tests/contract/auth_contract_test.go b/go-backend/tests/contract/auth_contract_test.go index 43d01c8..9789994 100644 --- a/go-backend/tests/contract/auth_contract_test.go +++ b/go-backend/tests/contract/auth_contract_test.go @@ -1,6 +1,7 @@ package contract_test import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -9,6 +10,7 @@ import ( "go-backend/internal/auth" "go-backend/internal/http/middleware" "go-backend/internal/http/response" + "go-backend/internal/security" ) func TestJWTMiddlewareContracts(t *testing.T) { @@ -18,7 +20,9 @@ func TestJWTMiddlewareContracts(t *testing.T) { response.WriteJSON(w, response.OK("pass")) }) - wrapped := middleware.JWT(middleware.AuthOptions{JWTSecret: secret})(next) + wrapped := middleware.JWT(middleware.AuthOptions{JWTSecret: secret, GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: 0}, nil + }})(next) t.Run("login path is excluded", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil) @@ -59,6 +63,9 @@ func TestJWTMiddlewareContracts(t *testing.T) { if err != nil { t.Fatalf("generate token: %v", err) } + wrapped := middleware.JWT(middleware.AuthOptions{JWTSecret: secret, GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) { + return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil + }})(next) req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", nil) req.Header.Set("Authorization", token) res := httptest.NewRecorder() @@ -67,6 +74,83 @@ func TestJWTMiddlewareContracts(t *testing.T) { }) } +func TestLoginTokenValidatesThroughRouter(t *testing.T) { + router, r := setupContractRouter(t, "contract-jwt-secret") + seedLegacyUser(t, r, 9110, "router-login-user", "router-login-pass") + + body := bytes.NewBufferString(`{"username":"router-login-user","password":"router-login-pass","captchaId":""}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + var out response.R + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode login response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected login code 0, got %d (%s)", out.Code, out.Msg) + } + data, ok := out.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected login data map, got %T", out.Data) + } + token, _ := data["token"].(string) + if token == "" { + t.Fatal("expected login token") + } + + checkReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/tunnel", nil) + checkReq.Header.Set("Authorization", token) + checkResp := httptest.NewRecorder() + router.ServeHTTP(checkResp, checkReq) + assertCode(t, checkResp, 0) +} + +func TestLegacyPasswordMigratesOnLogin(t *testing.T) { + router, r := setupContractRouter(t, "contract-jwt-secret") + legacyChangedAt := seedLegacyUser(t, r, 9101, "legacy-login-user", "legacy-login-pass") + + body := bytes.NewBufferString(`{"username":"legacy-login-user","password":"legacy-login-pass","captchaId":""}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + assertCode(t, resp, 0) + assertUserPasswordIsBcrypt(t, r, "legacy-login-user", "legacy-login-pass") + if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "legacy-login-user"); changedAt <= legacyChangedAt { + t.Fatalf("expected password_changed_at to advance on login migration, got %d <= %d", changedAt, legacyChangedAt) + } +} + +func TestDisabledLegacyPasswordIsRejectedWithoutMigration(t *testing.T) { + router, r := setupContractRouter(t, "contract-jwt-secret") + legacyChangedAt := seedLegacyUserWithStatus(t, r, 9105, "disabled-legacy-user", "disabled-legacy-pass", 0) + + body := bytes.NewBufferString(`{"username":"disabled-legacy-user","password":"disabled-legacy-pass","captchaId":""}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + assertCodeMsg(t, resp, -1, "账号被停用") + + user, err := r.GetUserByUsername("disabled-legacy-user") + if err != nil { + t.Fatalf("get user: %v", err) + } + if user == nil { + t.Fatal("expected disabled user to exist") + } + if ok, migrated := security.VerifyPassword(user.Pwd, "disabled-legacy-pass"); !ok || !migrated { + t.Fatalf("expected disabled user to remain legacy MD5, got (%v,%v) with hash %q", ok, migrated, user.Pwd) + } + if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "disabled-legacy-user"); changedAt != legacyChangedAt { + t.Fatalf("expected password_changed_at to remain unchanged, got %d want %d", changedAt, legacyChangedAt) + } +} + func assertCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) { t.Helper() var out response.R diff --git a/go-backend/tests/contract/migration_contract_test.go b/go-backend/tests/contract/migration_contract_test.go index 8f5da8c..e518984 100644 --- a/go-backend/tests/contract/migration_contract_test.go +++ b/go-backend/tests/contract/migration_contract_test.go @@ -17,6 +17,7 @@ import ( httpserver "go-backend/internal/http" "go-backend/internal/http/handler" "go-backend/internal/http/response" + "go-backend/internal/security" "go-backend/internal/store/repo" "gorm.io/gorm" @@ -128,6 +129,46 @@ func TestCaptchaVerifyLoginContract(t *testing.T) { }) } +func TestPublicConfigGetAndAuthConfigContract(t *testing.T) { + router, r := setupContractRouter(t, "contract-jwt-secret") + + for name, value := range map[string]string{ + "app_name": "FLVX Public", + "app_logo": "logo", + "app_favicon": "favicon", + "app_bg_image": "bg", + "cloudflare_site_key": "site-key", + "cloudflare_secret_key": "secret-key", + "jwt_secret": "jwt-secret", + } { + if err := r.DB().Exec(` + INSERT INTO vite_config(name, value, time) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time + `, name, value, time.Now().UnixMilli()).Error; err != nil { + t.Fatalf("seed config %s: %v", name, err) + } + } + + publicReq := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`)) + publicReq.Header.Set("Content-Type", "application/json") + publicResp := httptest.NewRecorder() + router.ServeHTTP(publicResp, publicReq) + assertCode(t, publicResp, 0) + + secretReq := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`)) + secretReq.Header.Set("Content-Type", "application/json") + secretResp := httptest.NewRecorder() + router.ServeHTTP(secretResp, secretReq) + assertCodeMsg(t, secretResp, 403, "禁止访问敏感配置") + + configReq := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"app_name"}`)) + configReq.Header.Set("Content-Type", "application/json") + configResp := httptest.NewRecorder() + router.ServeHTTP(configResp, configReq) + assertCodeMsg(t, configResp, 401, "未登录或token已过期") +} + func TestOpenAPISubStoreContracts(t *testing.T) { router, r := setupContractRouter(t, "contract-jwt-secret") @@ -209,6 +250,122 @@ func TestOpenAPISubStoreContracts(t *testing.T) { }) } +func TestLegacyPasswordMigratesOnSubStore(t *testing.T) { + router, r := setupContractRouter(t, "contract-jwt-secret") + legacyChangedAt := seedLegacyUser(t, r, 9102, "legacy-substore-user", "legacy-substore-pass") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=legacy-substore-user&pwd=legacy-substore-pass", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + expected := "upload=0; download=0; total=107373108658176; expire=2727251700" + if string(body) != expected { + t.Fatalf("expected body %q, got %q", expected, string(body)) + } + assertUserPasswordIsBcrypt(t, r, "legacy-substore-user", "legacy-substore-pass") + if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "legacy-substore-user"); changedAt <= legacyChangedAt { + t.Fatalf("expected password_changed_at to advance on sub-store migration, got %d <= %d", changedAt, legacyChangedAt) + } +} + +func TestDisabledLegacyPasswordIsRejectedOnSubStoreWithoutMigration(t *testing.T) { + router, r := setupContractRouter(t, "contract-jwt-secret") + legacyChangedAt := seedLegacyUserWithStatus(t, r, 9106, "disabled-substore-user", "disabled-substore-pass", 0) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=disabled-substore-user&pwd=disabled-substore-pass", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + assertCodeMsg(t, resp, -1, "账号被停用") + + user, err := r.GetUserByUsername("disabled-substore-user") + if err != nil { + t.Fatalf("get user: %v", err) + } + if user == nil { + t.Fatal("expected disabled user to exist") + } + if ok, migrated := security.VerifyPassword(user.Pwd, "disabled-substore-pass"); !ok || !migrated { + t.Fatalf("expected disabled user to remain legacy MD5, got (%v,%v) with hash %q", ok, migrated, user.Pwd) + } + if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "disabled-substore-user"); changedAt != legacyChangedAt { + t.Fatalf("expected password_changed_at to remain unchanged, got %d want %d", changedAt, legacyChangedAt) + } +} + +func TestUserCreateStoresStrongHash(t *testing.T) { + router, r := setupContractRouter(t, "contract-jwt-secret") + startedAt := time.Now().UnixMilli() + adminToken, err := auth.GenerateToken(1, "admin_user", 0, "contract-jwt-secret") + if err != nil { + t.Fatalf("generate token: %v", err) + } + + body := bytes.NewBufferString(`{"user":"created-user","pwd":"created-pass"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/create", body) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + assertCode(t, resp, 0) + assertUserPasswordIsBcrypt(t, r, "created-user", "created-pass") + if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "created-user"); changedAt < startedAt { + t.Fatalf("expected password_changed_at to be set on create, got %d < %d", changedAt, startedAt) + } +} + +func TestUserUpdateStoresStrongHash(t *testing.T) { + router, r := setupContractRouter(t, "contract-jwt-secret") + seedLegacyUser(t, r, 9103, "legacy-update-user", "legacy-update-pass") + startedAt := time.Now().UnixMilli() + adminToken, err := auth.GenerateToken(1, "admin_user", 0, "contract-jwt-secret") + if err != nil { + t.Fatalf("generate token: %v", err) + } + + body := bytes.NewBufferString(`{"id":9103,"user":"updated-user","pwd":"updated-pass","flow":99999,"num":99999,"expTime":2727251700000,"flowResetTime":1,"status":1,"maxConn":0}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/update", body) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + assertCode(t, resp, 0) + assertUserPasswordIsBcrypt(t, r, "updated-user", "updated-pass") + if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "updated-user"); changedAt < startedAt { + t.Fatalf("expected password_changed_at to be updated on user update, got %d < %d", changedAt, startedAt) + } +} + +func TestUpdatePasswordStoresStrongHash(t *testing.T) { + router, r := setupContractRouter(t, "contract-jwt-secret") + seedLegacyUser(t, r, 9104, "legacy-self-user", "legacy-self-pass") + startedAt := time.Now().UnixMilli() + token, err := auth.GenerateToken(9104, "legacy-self-user", 1, "contract-jwt-secret") + if err != nil { + t.Fatalf("generate token: %v", err) + } + + body := bytes.NewBufferString(`{"newUsername":"self-updated-user","currentPassword":"legacy-self-pass","newPassword":"self-updated-pass","confirmPassword":"self-updated-pass"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/updatePassword", body) + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + assertCode(t, resp, 0) + assertUserPasswordIsBcrypt(t, r, "self-updated-user", "self-updated-pass") + if changedAt := mustQueryPasswordChangedAtByUsername(t, r, "self-updated-user"); changedAt < startedAt { + t.Fatalf("expected password_changed_at to be updated on password change, got %d < %d", changedAt, startedAt) + } +} + func TestSpeedLimitTunnelsRouteRemoved(t *testing.T) { secret := "contract-jwt-secret" router, _ := setupContractRouter(t, secret) @@ -232,6 +389,7 @@ func TestSpeedLimitTunnelsRouteRemoved(t *testing.T) { func TestBackupExportImportRestoreContracts(t *testing.T) { secret := "contract-jwt-secret" router, r := setupContractRouter(t, secret) + seedContractUser(t, r, 2, "normal_user", 1, 1) adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) if err != nil { @@ -559,6 +717,71 @@ func TestBackupExportImportRestoreContracts(t *testing.T) { }) } +func TestBackupConfigFilteringContract(t *testing.T) { + secret := "contract-jwt-secret" + router, r := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + configs := map[string]string{ + "app_name": "contract-before", + "cloudflare_site_key": "site-key-before", + "jwt_secret": "jwt-before", + "license_key": "license-before", + "cloudflare_secret_key": "cloudflare-before", + } + for name, value := range configs { + if err := r.DB().Exec(` + INSERT INTO vite_config(name, value, time) + VALUES(?, ?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time + `, name, value, time.Now().UnixMilli()).Error; err != nil { + t.Fatalf("seed config %s: %v", name, err) + } + } + + payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken) + for _, key := range []string{"jwt_secret", "license_key", "cloudflare_secret_key"} { + if _, ok := payload.Configs[key]; ok { + t.Fatalf("expected %s to be omitted from exported configs: %+v", key, payload.Configs) + } + } + if payload.Configs["app_name"] != "contract-before" { + t.Fatalf("expected public config to be exported, got %+v", payload.Configs) + } + + payload.Configs["app_name"] = "contract-after" + payload.Configs["jwt_secret"] = "jwt-after" + payload.Configs["license_key"] = "license-after" + payload.Configs["cloudflare_secret_key"] = "cloudflare-after" + raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload}) + if err != nil { + t.Fatalf("marshal import payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(raw)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + var out response.R + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode import response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected import code 0, got %d (%s)", out.Code, out.Msg) + } + + assertConfigValue(t, r, "app_name", "contract-after") + assertConfigValue(t, r, "jwt_secret", "jwt-before") + assertConfigValue(t, r, "license_key", "license-before") + assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before") +} + type backupExportPayload struct { Version string `json:"version"` ExportedAt int64 `json:"exportedAt"` @@ -619,6 +842,66 @@ func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Re return httpserver.NewRouter(h, jwtSecret), r } +func assertConfigValue(t *testing.T, r *repo.Repository, name, want string) { + t.Helper() + cfg, err := r.GetConfigByName(name) + if err != nil { + t.Fatalf("get config %s: %v", name, err) + } + if cfg == nil || cfg.Value != want { + t.Fatalf("expected config %s=%q, got %+v", name, want, cfg) + } +} + +func seedLegacyUser(t *testing.T, r *repo.Repository, id int64, username, password string) int64 { + return seedLegacyUserWithStatus(t, r, id, username, password, 1) +} + +func seedContractUser(t *testing.T, r *repo.Repository, id int64, username string, roleID, status int) int64 { + t.Helper() + now := time.Now().UnixMilli() + passwordChangedAt := now - 10_000 + if err := r.DB().Exec(` + INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, max_conn, created_time, updated_time, status, password_changed_at) + VALUES(?, ?, ?, ?, 2727251700000, 99999, 0, 0, 1, 99999, 0, ?, ?, ?, ?) + `, id, username, security.MD5("contract-pass"), roleID, now, now, status, passwordChangedAt).Error; err != nil { + t.Fatalf("seed contract user %s: %v", username, err) + } + return passwordChangedAt +} + +func seedLegacyUserWithStatus(t *testing.T, r *repo.Repository, id int64, username, password string, status int) int64 { + t.Helper() + now := time.Now().UnixMilli() + legacyChangedAt := now - 10_000 + if err := r.DB().Exec(` + INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, max_conn, created_time, updated_time, status, password_changed_at) + VALUES(?, ?, ?, 1, 2727251700000, 99999, 0, 0, 1, 99999, 0, ?, ?, ?, ?) + `, id, username, security.MD5(password), now, now, status, legacyChangedAt).Error; err != nil { + t.Fatalf("seed legacy user %s: %v", username, err) + } + return legacyChangedAt +} + +func mustQueryPasswordChangedAtByUsername(t *testing.T, r *repo.Repository, username string) int64 { + t.Helper() + return mustQueryInt64(t, r, `SELECT password_changed_at FROM user WHERE user = ?`, username) +} + +func assertUserPasswordIsBcrypt(t *testing.T, r *repo.Repository, username, password string) { + t.Helper() + user, err := r.GetUserByUsername(username) + if err != nil { + t.Fatalf("get user %s: %v", username, err) + } + if user == nil { + t.Fatalf("expected user %s to exist", username) + } + if ok, migrated := security.VerifyPassword(user.Pwd, password); !ok || migrated { + t.Fatalf("expected bcrypt password for %s, got %q", username, user.Pwd) + } +} + func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "legacy-2.0.7-beta.db") legacyDB, err := sql.Open("sqlite", dbPath) diff --git a/go-backend/tests/contract/monitoring_contract_test.go b/go-backend/tests/contract/monitoring_contract_test.go index 584203d..4a799bc 100644 --- a/go-backend/tests/contract/monitoring_contract_test.go +++ b/go-backend/tests/contract/monitoring_contract_test.go @@ -16,6 +16,7 @@ import ( func TestNodeMetricsEndpoints(t *testing.T) { secret := "monitoring-jwt-secret" router, repo := setupContractRouter(t, secret) + seedContractUser(t, repo, 2, "normal_user", 1, 1) adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) if err != nil { @@ -1302,6 +1303,7 @@ func TestMonitoringAuthRequired(t *testing.T) { func TestMonitorAccessEndpoint(t *testing.T) { secret := "monitoring-jwt-secret" router, repo := setupContractRouter(t, secret) + seedContractUser(t, repo, 2, "normal_user", 1, 1) adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) if err != nil { @@ -1357,6 +1359,7 @@ func TestMonitorAccessEndpoint(t *testing.T) { func TestMonitoringPermissionRequired(t *testing.T) { secret := "monitoring-jwt-secret" router, repo := setupContractRouter(t, secret) + seedContractUser(t, repo, 2, "normal_user", 1, 1) userToken, err := auth.GenerateToken(2, "normal_user", 1, secret) if err != nil { diff --git a/go-backend/tests/contract/storage_contract_test.go b/go-backend/tests/contract/storage_contract_test.go index efa50eb..c658b2a 100644 --- a/go-backend/tests/contract/storage_contract_test.go +++ b/go-backend/tests/contract/storage_contract_test.go @@ -12,7 +12,8 @@ import ( func TestStorageSummaryRequiresAdminAndReturnsSize(t *testing.T) { secret := "storage-contract-secret" - router, _ := setupContractRouter(t, secret) + router, r := setupContractRouter(t, secret) + seedContractUser(t, r, 2, "normal_user", 1, 1) adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) if err != nil { diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 8c42f9d..0265bc5 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -253,6 +253,8 @@ export const getConfigs = () => Network.post>("/config/list"); export const getConfigByName = (name: string) => Network.post<{ name: string; value: string }>("/config/get", { name }); +export const getPublicConfigByName = (name: string) => + Network.post<{ name: string; value: string }>("/public/config/get", { name }); export const updateConfigs = (configMap: Record) => Network.post("/config/update", configMap); export const updateConfig = (name: string, value: string) => diff --git a/vite-frontend/src/config/site.ts b/vite-frontend/src/config/site.ts index 6af42e6..efd39fc 100644 --- a/vite-frontend/src/config/site.ts +++ b/vite-frontend/src/config/site.ts @@ -1,4 +1,5 @@ -import { getConfigByName, getConfigs } from "@/api"; +import { getConfigByName, getConfigs, getPublicConfigByName } from "@/api"; +import { isLoggedIn } from "@/utils/auth"; export type SiteConfig = typeof siteConfig; @@ -8,9 +9,73 @@ const VERSION = import.meta.env.VITE_APP_VERSION || "dev"; const APP_VERSION = "1.0.3"; const DEFAULT_FAVICON = "/favicon.ico"; const FAVICON_LINK_ID = "app-favicon"; +const PUBLIC_BRAND_CONFIG_KEYS = [ + "app_name", + "app_logo", + "app_favicon", + "app_bg_image", +] as const; +const SENSITIVE_CONFIG_KEYS = new Set([ + "jwt_secret", + "license_key", + "cloudflare_secret_key", +]); const GITHUB_REPO = import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel"; +const shouldPersistConfigKey = (key: string) => { + return !SENSITIVE_CONFIG_KEYS.has(key.trim().toLowerCase()); +}; + +const purgeSensitiveConfigCache = () => { + SENSITIVE_CONFIG_KEYS.forEach((key) => { + localStorage.removeItem(CACHE_PREFIX + key); + }); +}; + +const readCachedConfigs = (keys: readonly string[]) => { + const cachedConfigs: Record = {}; + let hasCachedData = false; + + keys.forEach((key) => { + const cachedValue = configCache.get(key); + + if (cachedValue !== null) { + cachedConfigs[key] = cachedValue; + hasCachedData = true; + } + }); + + return { cachedConfigs, hasCachedData }; +}; + +const fetchPublicBrandConfigs = async (): Promise> => { + const publicConfigMap: Record = {}; + + await Promise.all( + PUBLIC_BRAND_CONFIG_KEYS.map(async (key) => { + try { + const response = await getPublicConfigByName(key); + + if ( + response.code === 0 && + response.data && + typeof response.data.value === "string" + ) { + const value = response.data.value; + + publicConfigMap[key] = value; + configCache.set(key, value); + } + } catch { + // ignore single key fetch error + } + }), + ); + + return publicConfigMap; +}; + const getInitialConfig = () => { if (typeof window === "undefined") { return { @@ -26,6 +91,8 @@ const getInitialConfig = () => { }; } + purgeSensitiveConfigCache(); + const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name"); const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || ""; const cachedAppFavicon = @@ -77,7 +144,15 @@ export const configCache = { // 设置缓存的配置 set: (key: string, value: string): void => { - const cacheKey = CACHE_PREFIX + key; + const normalizedKey = key.trim().toLowerCase(); + + if (!shouldPersistConfigKey(normalizedKey)) { + configCache.remove(normalizedKey); + + return; + } + + const cacheKey = CACHE_PREFIX + normalizedKey; localStorage.setItem(cacheKey, value); }, @@ -119,7 +194,9 @@ export const getCachedConfig = async (key: string): Promise => { ) { const value = response.data.value; - configCache.set(key, value); + if (shouldPersistConfigKey(key)) { + configCache.set(key, value); + } return value; } @@ -129,46 +206,19 @@ export const getCachedConfig = async (key: string): Promise => { // 获取所有配置(优先从缓存) export const getCachedConfigs = async (): Promise> => { - // 尝试从缓存获取所有配置 - const configKeys = ["app_name", "app_logo", "app_favicon", "app_bg_image"]; - const cachedConfigs: Record = {}; - let hasCachedData = false; + const { cachedConfigs, hasCachedData } = readCachedConfigs( + PUBLIC_BRAND_CONFIG_KEYS, + ); - configKeys.forEach((key) => { - const cachedValue = configCache.get(key); + if (!isLoggedIn()) { + const publicConfigs = await fetchPublicBrandConfigs(); - if (cachedValue !== null) { - cachedConfigs[key] = cachedValue; - hasCachedData = true; + if (Object.keys(publicConfigs).length > 0) { + return { ...cachedConfigs, ...publicConfigs }; } - }); - const fetchPublicConfigs = async (): Promise> => { - const publicConfigMap: Record = {}; - - await Promise.all( - configKeys.map(async (key) => { - try { - const response = await getConfigByName(key); - - if ( - response.code === 0 && - response.data && - typeof response.data.value === "string" - ) { - const value = response.data.value; - - publicConfigMap[key] = value; - configCache.set(key, value); - } - } catch { - // ignore single key fetch error - } - }), - ); - - return publicConfigMap; - }; + return cachedConfigs; + } // 从API获取最新配置 try { @@ -177,9 +227,19 @@ export const getCachedConfigs = async (): Promise> => { if (response.code === 0 && response.data) { const configs = response.data; - // 将所有配置存入缓存 + // 仅将安全配置存入缓存,敏感项会从 localStorage 中移除 Object.entries(configs).forEach(([key, value]) => { - configCache.set(key, value as string); + const normalizedKey = key.trim().toLowerCase(); + + if (SENSITIVE_CONFIG_KEYS.has(normalizedKey)) { + configCache.remove(normalizedKey); + + return; + } + + if (shouldPersistConfigKey(normalizedKey)) { + configCache.set(normalizedKey, value as string); + } }); return configs; @@ -189,14 +249,14 @@ export const getCachedConfigs = async (): Promise> => { return cachedConfigs; } - return await fetchPublicConfigs(); + return await fetchPublicBrandConfigs(); } catch { // API失败时返回缓存的数据 if (hasCachedData) { return cachedConfigs; } - return await fetchPublicConfigs(); + return await fetchPublicBrandConfigs(); } }; @@ -255,7 +315,17 @@ export const updateSiteConfig = async (configMap?: Record) => { const resolvedConfigMap = configMap ?? (await getCachedConfigs()); Object.entries(resolvedConfigMap).forEach(([key, value]) => { - configCache.set(key, String(value)); + const normalizedKey = key.trim().toLowerCase(); + + if (SENSITIVE_CONFIG_KEYS.has(normalizedKey)) { + configCache.remove(normalizedKey); + + return; + } + + if (shouldPersistConfigKey(normalizedKey)) { + configCache.set(normalizedKey, String(value)); + } }); const hasAppName = Object.prototype.hasOwnProperty.call( diff --git a/vite-frontend/src/pages/index.tsx b/vite-frontend/src/pages/index.tsx index 6e2e948..a4d5644 100644 --- a/vite-frontend/src/pages/index.tsx +++ b/vite-frontend/src/pages/index.tsx @@ -10,7 +10,7 @@ import { Button } from "@/shadcn-bridge/heroui/button"; import { siteConfig } from "@/config/site"; import { VersionFooter } from "@/components/version-footer"; import { BrandLogo } from "@/components/brand-logo"; -import { login, LoginData, checkCaptcha, getConfigByName } from "@/api"; +import { login, LoginData, checkCaptcha, getPublicConfigByName } from "@/api"; import { writeLoginSession } from "@/utils/session"; import { useWebViewMode } from "@/hooks/useWebViewMode"; @@ -128,7 +128,7 @@ export default function IndexPage() { if (checkResponse.data === 0) { await performLogin(); } else { - const configResp = await getConfigByName("cloudflare_site_key"); + const configResp = await getPublicConfigByName("cloudflare_site_key"); if (configResp.code === 0 && configResp.data && configResp.data.value) { setSiteKey(configResp.data.value);