mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-06 18:06:36 +08:00
fix: harden auth, config access, and backups (#503)
## Summary - Migrate password storage to bcrypt with legacy MD5 verification-only support and best-effort upgrade on successful auth. - Revoke JWTs on auth-state changes, align WebSocket admin auth, and split public config reads from protected config reads. - Filter sensitive configs from backup export/import and update contract coverage for the new security boundaries. ## Test Plan - [x] `go test ./... -count=1` - [x] `pnpm run lint` - [x] `pnpm run build`
This commit is contained in:
@@ -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. 后续在新提交中按本设计逐项修复。
|
||||||
@@ -12,12 +12,13 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
algorithm = "HmacSHA256"
|
algorithm = "HmacSHA256"
|
||||||
expireTime = 90 * 24 * time.Hour
|
expireTime = 7 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
Sub string `json:"sub"`
|
Sub string `json:"sub"`
|
||||||
Iat int64 `json:"iat"`
|
Iat int64 `json:"iat"`
|
||||||
|
IatMs int64 `json:"iat_ms"`
|
||||||
Exp int64 `json:"exp"`
|
Exp int64 `json:"exp"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -30,11 +31,15 @@ type tokenHeader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GenerateToken(userID int64, username string, roleID int, secret string) (string, error) {
|
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"}
|
header := tokenHeader{Alg: algorithm, Typ: "JWT"}
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
Sub: strconv.FormatInt(userID, 10),
|
Sub: strconv.FormatInt(userID, 10),
|
||||||
Iat: now.Unix(),
|
Iat: now.Unix(),
|
||||||
|
IatMs: now.UnixMilli(),
|
||||||
Exp: now.Add(expireTime).Unix(),
|
Exp: now.Add(expireTime).Unix(),
|
||||||
User: username,
|
User: username,
|
||||||
Name: username,
|
Name: username,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
type UserAuthState struct {
|
||||||
|
ID int64
|
||||||
|
RoleID int
|
||||||
|
Status int
|
||||||
|
PasswordChangedAt int64
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
|
|||||||
}
|
}
|
||||||
h.metrics.RecordNodeMetric(nodeID, metricInfo)
|
h.metrics.RecordNodeMetric(nodeID, metricInfo)
|
||||||
})
|
})
|
||||||
|
h.wsServer.SetUserAuthStateLookup(h.GetUserAuthState)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +144,10 @@ func (h *Handler) WebSocketHandler() http.Handler {
|
|||||||
return h.wsServer
|
return h.wsServer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetUserAuthState(userID int64) (*auth.UserAuthState, error) {
|
||||||
|
return h.repo.GetUserAuthState(userID)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) Register(mux *http.ServeMux) {
|
func (h *Handler) Register(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/api/v1/user/login", h.login)
|
mux.HandleFunc("/api/v1/user/login", h.login)
|
||||||
mux.HandleFunc("/api/v1/user/list", h.userList)
|
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/reset", h.userResetFlow)
|
||||||
mux.HandleFunc("/api/v1/user/quota/reset", h.userQuotaReset)
|
mux.HandleFunc("/api/v1/user/quota/reset", h.userQuotaReset)
|
||||||
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
|
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/get", h.getConfigByName)
|
||||||
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
||||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
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("账号或密码错误"))
|
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user.Pwd != security.MD5(req.Password) {
|
passwordMatched, passwordWasLegacy := security.VerifyPassword(user.Pwd, req.Password)
|
||||||
|
if !passwordMatched {
|
||||||
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
|
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -342,8 +349,20 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("账号被停用"))
|
response.WriteJSON(w, response.ErrDefault("账号被停用"))
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -557,10 +576,27 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user == nil || user.Pwd != security.MD5(password) {
|
if user == nil {
|
||||||
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
|
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
|
||||||
return
|
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)
|
const giga = int64(1024 * 1024 * 1024)
|
||||||
headerValue := ""
|
headerValue := ""
|
||||||
@@ -948,6 +984,10 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if repo.IsSensitiveConfigKey(key) {
|
||||||
|
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if protectedKeys[key] && isCommercial != "true" {
|
if protectedKeys[key] && isCommercial != "true" {
|
||||||
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
||||||
@@ -985,6 +1025,10 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if repo.IsSensitiveConfigKey(name) {
|
||||||
|
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||||
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Pwd != security.MD5(req.CurrentPassword) {
|
passwordMatched, _ := security.VerifyPassword(user.Pwd, req.CurrentPassword)
|
||||||
|
if !passwordMatched {
|
||||||
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
|
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1270,7 +1315,12 @@ func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,12 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
maxConn := asInt(req["maxConn"], 0)
|
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 {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -176,7 +181,12 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} 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()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go-backend/internal/auth"
|
"go-backend/internal/auth"
|
||||||
@@ -14,7 +15,8 @@ type contextKey string
|
|||||||
const ClaimsContextKey contextKey = "claims"
|
const ClaimsContextKey contextKey = "claims"
|
||||||
|
|
||||||
type AuthOptions struct {
|
type AuthOptions struct {
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
GetUserAuthState func(userID int64) (*auth.UserAuthState, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func JWT(opts AuthOptions) func(http.Handler) http.Handler {
|
func JWT(opts AuthOptions) func(http.Handler) http.Handler {
|
||||||
@@ -42,6 +44,19 @@ func JWT(opts AuthOptions) func(http.Handler) http.Handler {
|
|||||||
return
|
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 {
|
if requiresAdmin(r.URL.Path) && claims.RoleID != 0 {
|
||||||
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
|
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
|
||||||
return
|
return
|
||||||
@@ -78,9 +93,11 @@ func shouldSkip(path string) bool {
|
|||||||
case strings.HasPrefix(path, "/api/v1/captcha/"):
|
case strings.HasPrefix(path, "/api/v1/captcha/"):
|
||||||
return true
|
return true
|
||||||
case path == "/api/v1/config/get":
|
case path == "/api/v1/config/get":
|
||||||
return true
|
return false
|
||||||
case path == "/api/v1/user/login":
|
case path == "/api/v1/user/login":
|
||||||
return true
|
return true
|
||||||
|
case path == "/api/v1/public/config/get":
|
||||||
|
return true
|
||||||
case path == "/api/v1/federation/connect":
|
case path == "/api/v1/federation/connect":
|
||||||
return true
|
return true
|
||||||
case path == "/api/v1/federation/tunnel/create":
|
case path == "/api/v1/federation/tunnel/create":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ func NewRouter(h *handler.Handler, jwtSecret string) http.Handler {
|
|||||||
mux.Handle("/system-info", h.WebSocketHandler())
|
mux.Handle("/system-info", h.WebSocketHandler())
|
||||||
|
|
||||||
wrapped := middleware.Recover(mux)
|
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.RequestLog(wrapped)
|
||||||
wrapped = middleware.CORS(wrapped)
|
wrapped = middleware.CORS(wrapped)
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,20 +10,21 @@ import "database/sql"
|
|||||||
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
||||||
// word, so TableName() is required for correct quoting.
|
// word, so TableName() is required for correct quoting.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
User string `gorm:"column:user;type:varchar(100);not null"`
|
User string `gorm:"column:user;type:varchar(100);not null"`
|
||||||
Pwd string `gorm:"type:varchar(100);not null"`
|
Pwd string `gorm:"type:varchar(100);not null"`
|
||||||
RoleID int `gorm:"column:role_id;not null"`
|
RoleID int `gorm:"column:role_id;not null"`
|
||||||
ExpTime int64 `gorm:"column:exp_time;not null"`
|
ExpTime int64 `gorm:"column:exp_time;not null"`
|
||||||
Flow int64 `gorm:"not null"`
|
Flow int64 `gorm:"not null"`
|
||||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
||||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||||
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
|
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
|
||||||
Num int `gorm:"not null"`
|
Num int `gorm:"not null"`
|
||||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||||
Status int `gorm:"not null"`
|
Status int `gorm:"not null"`
|
||||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
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" }
|
func (User) TableName() string { return "user" }
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"go-backend/internal/security"
|
||||||
"go-backend/internal/store/model"
|
"go-backend/internal/store/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -416,13 +419,23 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seedData(db *gorm.DB) {
|
func seedData(db *gorm.DB) {
|
||||||
adminUser := model.User{
|
var adminCount int64
|
||||||
ID: 1, User: "admin_user", Pwd: "3c85cdebade1c51cf64ca9f3c09d182d",
|
if err := db.Model(&model.User{}).Where("id = ?", 1).Count(&adminCount).Error; err == nil && adminCount == 0 {
|
||||||
RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,
|
adminPwd, err := security.HashPassword("admin_user")
|
||||||
FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,
|
if err != nil {
|
||||||
UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,
|
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}
|
appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000}
|
||||||
db.Where("id = ?", 1).FirstOrCreate(&appNameConfig)
|
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 errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
return r.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
return r.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||||
"user": username,
|
"user": username,
|
||||||
"pwd": passwordMD5,
|
"pwd": passwordMD5,
|
||||||
"updated_time": now,
|
"password_changed_at": now,
|
||||||
|
"updated_time": now,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1890,7 +1904,7 @@ func (r *Repository) ExportAll() (*model.BackupData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||||
}
|
}
|
||||||
backup.Configs = configs
|
backup.Configs = FilterSensitiveConfigs(configs)
|
||||||
|
|
||||||
return backup, nil
|
return backup, nil
|
||||||
}
|
}
|
||||||
@@ -1970,7 +1984,7 @@ func (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||||
}
|
}
|
||||||
backup.Configs = v
|
backup.Configs = FilterSensitiveConfigs(v)
|
||||||
}
|
}
|
||||||
return backup, nil
|
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) {
|
func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {
|
||||||
count := 0
|
count := 0
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
item := model.User{
|
pwdHash, status, err := normalizeImportedUserPassword(u.Pwd, u.Status)
|
||||||
ID: u.ID,
|
if err != nil {
|
||||||
User: u.User,
|
return count, err
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
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"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
"user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow",
|
"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
|
}).Create(&item).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2415,6 +2434,33 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
|||||||
return count, nil
|
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) {
|
func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {
|
||||||
count := 0
|
count := 0
|
||||||
for _, n := range nodes {
|
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) {
|
func importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {
|
||||||
|
configs = FilterSensitiveConfigs(configs)
|
||||||
count := 0
|
count := 0
|
||||||
for name, value := range configs {
|
for name, value := range configs {
|
||||||
err := tx.Clauses(clause.OnConflict{
|
err := tx.Clauses(clause.OnConflict{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,33 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) {
|
||||||
source, err := Open(filepath.Join(t.TempDir(), "source.db"))
|
source, err := Open(filepath.Join(t.TempDir(), "source.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,3 +82,157 @@ func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) {
|
|||||||
t.Fatalf("unexpected imported probe target: %+v", items[0])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,19 +42,20 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
|
|||||||
return 0, errors.New("repository not initialized")
|
return 0, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
user := model.User{
|
user := model.User{
|
||||||
User: username,
|
User: username,
|
||||||
Pwd: pwdHash,
|
Pwd: pwdHash,
|
||||||
RoleID: roleID,
|
RoleID: roleID,
|
||||||
ExpTime: expTime,
|
ExpTime: expTime,
|
||||||
Flow: flow,
|
Flow: flow,
|
||||||
InFlow: 0,
|
InFlow: 0,
|
||||||
OutFlow: 0,
|
OutFlow: 0,
|
||||||
FlowResetTime: flowResetTime,
|
FlowResetTime: flowResetTime,
|
||||||
Num: num,
|
Num: num,
|
||||||
MaxConn: maxConn,
|
MaxConn: maxConn,
|
||||||
CreatedTime: now,
|
CreatedTime: now,
|
||||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||||
Status: status,
|
Status: status,
|
||||||
|
PasswordChangedAt: now,
|
||||||
}
|
}
|
||||||
if err := r.db.Create(&user).Error; err != nil {
|
if err := r.db.Create(&user).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -81,15 +82,16 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string,
|
|||||||
return r.db.Model(&model.User{}).
|
return r.db.Model(&model.User{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"user": username,
|
"user": username,
|
||||||
"pwd": pwdHash,
|
"pwd": pwdHash,
|
||||||
"flow": flow,
|
"flow": flow,
|
||||||
"num": num,
|
"num": num,
|
||||||
"exp_time": expTime,
|
"exp_time": expTime,
|
||||||
"flow_reset_time": flowResetTime,
|
"flow_reset_time": flowResetTime,
|
||||||
"status": status,
|
"status": status,
|
||||||
"max_conn": maxConn,
|
"max_conn": maxConn,
|
||||||
"updated_time": sql.NullInt64{Int64: now, Valid: true},
|
"password_changed_at": now,
|
||||||
|
"updated_time": sql.NullInt64{Int64: now, Valid: true},
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +113,19 @@ func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow i
|
|||||||
}).Error
|
}).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) {
|
func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num int, expTime, flowResetTime int64) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ type nodeSession struct {
|
|||||||
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
|
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminSession struct {
|
||||||
|
userID int64
|
||||||
|
claims auth.Claims
|
||||||
|
conn *connWrap
|
||||||
|
}
|
||||||
|
|
||||||
type commandResponse struct {
|
type commandResponse struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -74,9 +80,10 @@ type Server struct {
|
|||||||
upgrader websocket.Upgrader
|
upgrader websocket.Upgrader
|
||||||
onNodeOnline func(nodeID int64)
|
onNodeOnline func(nodeID int64)
|
||||||
onNodeMetric func(nodeID int64, info SystemInfo)
|
onNodeMetric func(nodeID int64, info SystemInfo)
|
||||||
|
getUserAuthState func(userID int64) (*auth.UserAuthState, error)
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
admins map[*connWrap]struct{}
|
admins map[*adminSession]struct{}
|
||||||
nodes map[int64]*nodeSession
|
nodes map[int64]*nodeSession
|
||||||
byConn map[*websocket.Conn]*nodeSession
|
byConn map[*websocket.Conn]*nodeSession
|
||||||
pending map[string]pendingRequest
|
pending map[string]pendingRequest
|
||||||
@@ -123,13 +130,22 @@ func NewServer(repo *repo.Repository, jwtSecret string) *Server {
|
|||||||
upgrader: websocket.Upgrader{
|
upgrader: websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
},
|
},
|
||||||
admins: make(map[*connWrap]struct{}),
|
admins: make(map[*adminSession]struct{}),
|
||||||
nodes: make(map[int64]*nodeSession),
|
nodes: make(map[int64]*nodeSession),
|
||||||
byConn: make(map[*websocket.Conn]*nodeSession),
|
byConn: make(map[*websocket.Conn]*nodeSession),
|
||||||
pending: make(map[string]pendingRequest),
|
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) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
typeVal := query.Get("type")
|
typeVal := query.Get("type")
|
||||||
@@ -146,18 +162,32 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if typeVal == "0" {
|
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)
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
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)
|
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -168,16 +198,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
})
|
})
|
||||||
done := make(chan struct{})
|
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.mu.Lock()
|
||||||
s.admins[cw] = struct{}{}
|
s.admins[session] = struct{}{}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
close(done)
|
close(done)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.admins, cw)
|
delete(s.admins, session)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
_ = conn.Close()
|
_ = 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))
|
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
})
|
})
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go startKeepalive(cw, done)
|
go startKeepalive(cw, done, nil)
|
||||||
|
|
||||||
version := r.URL.Query().Get("version")
|
version := r.URL.Query().Get("version")
|
||||||
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
|
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) {
|
func (s *Server) broadcastToAdmins(message string) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
admins := make([]*connWrap, 0, len(s.admins))
|
admins := make([]*adminSession, 0, len(s.admins))
|
||||||
for c := range s.admins {
|
for c := range s.admins {
|
||||||
admins = append(admins, c)
|
admins = append(admins, c)
|
||||||
}
|
}
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
for _, c := range admins {
|
for _, c := range admins {
|
||||||
c.mu.Lock()
|
if c == nil || c.conn == nil || c.conn.conn == nil {
|
||||||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
continue
|
||||||
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
}
|
||||||
_ = c.conn.SetWriteDeadline(time.Time{})
|
c.conn.mu.Lock()
|
||||||
c.mu.Unlock()
|
_ = 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 {
|
if err != nil {
|
||||||
log.Printf("websocket broadcast failed: %v", err)
|
log.Printf("websocket broadcast failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -602,7 +638,24 @@ func parseIntDefault(v string, fallback int) int {
|
|||||||
return x
|
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 {
|
if cw == nil || cw.conn == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -614,6 +667,10 @@ func startKeepalive(cw *connWrap, done <-chan struct{}) {
|
|||||||
case <-done:
|
case <-done:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
if validate != nil && !validate() {
|
||||||
|
_ = cw.conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
cw.mu.Lock()
|
cw.mu.Lock()
|
||||||
_ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
_ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||||
err := cw.conn.WriteMessage(websocket.PingMessage, nil)
|
err := cw.conn.WriteMessage(websocket.PingMessage, nil)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package contract_test
|
package contract_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"go-backend/internal/auth"
|
"go-backend/internal/auth"
|
||||||
"go-backend/internal/http/middleware"
|
"go-backend/internal/http/middleware"
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestJWTMiddlewareContracts(t *testing.T) {
|
func TestJWTMiddlewareContracts(t *testing.T) {
|
||||||
@@ -18,7 +20,9 @@ func TestJWTMiddlewareContracts(t *testing.T) {
|
|||||||
response.WriteJSON(w, response.OK("pass"))
|
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) {
|
t.Run("login path is excluded", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil)
|
||||||
@@ -59,6 +63,9 @@ func TestJWTMiddlewareContracts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generate token: %v", err)
|
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 := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", nil)
|
||||||
req.Header.Set("Authorization", token)
|
req.Header.Set("Authorization", token)
|
||||||
res := httptest.NewRecorder()
|
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) {
|
func assertCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var out response.R
|
var out response.R
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
httpserver "go-backend/internal/http"
|
httpserver "go-backend/internal/http"
|
||||||
"go-backend/internal/http/handler"
|
"go-backend/internal/http/handler"
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/security"
|
||||||
"go-backend/internal/store/repo"
|
"go-backend/internal/store/repo"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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) {
|
func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
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) {
|
func TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, _ := setupContractRouter(t, secret)
|
router, _ := setupContractRouter(t, secret)
|
||||||
@@ -232,6 +389,7 @@ func TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {
|
|||||||
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, r := 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)
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
if err != nil {
|
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 {
|
type backupExportPayload struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
ExportedAt int64 `json:"exportedAt"`
|
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
|
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) {
|
func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "legacy-2.0.7-beta.db")
|
dbPath := filepath.Join(t.TempDir(), "legacy-2.0.7-beta.db")
|
||||||
legacyDB, err := sql.Open("sqlite", dbPath)
|
legacyDB, err := sql.Open("sqlite", dbPath)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
func TestNodeMetricsEndpoints(t *testing.T) {
|
func TestNodeMetricsEndpoints(t *testing.T) {
|
||||||
secret := "monitoring-jwt-secret"
|
secret := "monitoring-jwt-secret"
|
||||||
router, repo := setupContractRouter(t, secret)
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
seedContractUser(t, repo, 2, "normal_user", 1, 1)
|
||||||
|
|
||||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1302,6 +1303,7 @@ func TestMonitoringAuthRequired(t *testing.T) {
|
|||||||
func TestMonitorAccessEndpoint(t *testing.T) {
|
func TestMonitorAccessEndpoint(t *testing.T) {
|
||||||
secret := "monitoring-jwt-secret"
|
secret := "monitoring-jwt-secret"
|
||||||
router, repo := setupContractRouter(t, secret)
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
seedContractUser(t, repo, 2, "normal_user", 1, 1)
|
||||||
|
|
||||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1357,6 +1359,7 @@ func TestMonitorAccessEndpoint(t *testing.T) {
|
|||||||
func TestMonitoringPermissionRequired(t *testing.T) {
|
func TestMonitoringPermissionRequired(t *testing.T) {
|
||||||
secret := "monitoring-jwt-secret"
|
secret := "monitoring-jwt-secret"
|
||||||
router, repo := setupContractRouter(t, secret)
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
seedContractUser(t, repo, 2, "normal_user", 1, 1)
|
||||||
|
|
||||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
|
|
||||||
func TestStorageSummaryRequiresAdminAndReturnsSize(t *testing.T) {
|
func TestStorageSummaryRequiresAdminAndReturnsSize(t *testing.T) {
|
||||||
secret := "storage-contract-secret"
|
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)
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -253,6 +253,8 @@ export const getConfigs = () =>
|
|||||||
Network.post<Record<string, string>>("/config/list");
|
Network.post<Record<string, string>>("/config/list");
|
||||||
export const getConfigByName = (name: string) =>
|
export const getConfigByName = (name: string) =>
|
||||||
Network.post<{ name: string; value: string }>("/config/get", { name });
|
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<string, string>) =>
|
export const updateConfigs = (configMap: Record<string, string>) =>
|
||||||
Network.post("/config/update", configMap);
|
Network.post("/config/update", configMap);
|
||||||
export const updateConfig = (name: string, value: string) =>
|
export const updateConfig = (name: string, value: string) =>
|
||||||
|
|||||||
@@ -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;
|
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 APP_VERSION = "1.0.3";
|
||||||
const DEFAULT_FAVICON = "/favicon.ico";
|
const DEFAULT_FAVICON = "/favicon.ico";
|
||||||
const FAVICON_LINK_ID = "app-favicon";
|
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 =
|
const GITHUB_REPO =
|
||||||
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
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<string, string> = {};
|
||||||
|
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<Record<string, string>> => {
|
||||||
|
const publicConfigMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
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 = () => {
|
const getInitialConfig = () => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return {
|
return {
|
||||||
@@ -26,6 +91,8 @@ const getInitialConfig = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
purgeSensitiveConfigCache();
|
||||||
|
|
||||||
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
|
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
|
||||||
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
|
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
|
||||||
const cachedAppFavicon =
|
const cachedAppFavicon =
|
||||||
@@ -77,7 +144,15 @@ export const configCache = {
|
|||||||
|
|
||||||
// 设置缓存的配置
|
// 设置缓存的配置
|
||||||
set: (key: string, value: string): void => {
|
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);
|
localStorage.setItem(cacheKey, value);
|
||||||
},
|
},
|
||||||
@@ -119,7 +194,9 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
|||||||
) {
|
) {
|
||||||
const value = response.data.value;
|
const value = response.data.value;
|
||||||
|
|
||||||
configCache.set(key, value);
|
if (shouldPersistConfigKey(key)) {
|
||||||
|
configCache.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -129,46 +206,19 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
|||||||
|
|
||||||
// 获取所有配置(优先从缓存)
|
// 获取所有配置(优先从缓存)
|
||||||
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||||
// 尝试从缓存获取所有配置
|
const { cachedConfigs, hasCachedData } = readCachedConfigs(
|
||||||
const configKeys = ["app_name", "app_logo", "app_favicon", "app_bg_image"];
|
PUBLIC_BRAND_CONFIG_KEYS,
|
||||||
const cachedConfigs: Record<string, string> = {};
|
);
|
||||||
let hasCachedData = false;
|
|
||||||
|
|
||||||
configKeys.forEach((key) => {
|
if (!isLoggedIn()) {
|
||||||
const cachedValue = configCache.get(key);
|
const publicConfigs = await fetchPublicBrandConfigs();
|
||||||
|
|
||||||
if (cachedValue !== null) {
|
if (Object.keys(publicConfigs).length > 0) {
|
||||||
cachedConfigs[key] = cachedValue;
|
return { ...cachedConfigs, ...publicConfigs };
|
||||||
hasCachedData = true;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const fetchPublicConfigs = async (): Promise<Record<string, string>> => {
|
return cachedConfigs;
|
||||||
const publicConfigMap: Record<string, string> = {};
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从API获取最新配置
|
// 从API获取最新配置
|
||||||
try {
|
try {
|
||||||
@@ -177,9 +227,19 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
|||||||
if (response.code === 0 && response.data) {
|
if (response.code === 0 && response.data) {
|
||||||
const configs = response.data;
|
const configs = response.data;
|
||||||
|
|
||||||
// 将所有配置存入缓存
|
// 仅将安全配置存入缓存,敏感项会从 localStorage 中移除
|
||||||
Object.entries(configs).forEach(([key, value]) => {
|
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;
|
return configs;
|
||||||
@@ -189,14 +249,14 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
|||||||
return cachedConfigs;
|
return cachedConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await fetchPublicConfigs();
|
return await fetchPublicBrandConfigs();
|
||||||
} catch {
|
} catch {
|
||||||
// API失败时返回缓存的数据
|
// API失败时返回缓存的数据
|
||||||
if (hasCachedData) {
|
if (hasCachedData) {
|
||||||
return cachedConfigs;
|
return cachedConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await fetchPublicConfigs();
|
return await fetchPublicBrandConfigs();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,7 +315,17 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
|||||||
const resolvedConfigMap = configMap ?? (await getCachedConfigs());
|
const resolvedConfigMap = configMap ?? (await getCachedConfigs());
|
||||||
|
|
||||||
Object.entries(resolvedConfigMap).forEach(([key, value]) => {
|
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(
|
const hasAppName = Object.prototype.hasOwnProperty.call(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Button } from "@/shadcn-bridge/heroui/button";
|
|||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
import { VersionFooter } from "@/components/version-footer";
|
import { VersionFooter } from "@/components/version-footer";
|
||||||
import { BrandLogo } from "@/components/brand-logo";
|
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 { writeLoginSession } from "@/utils/session";
|
||||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ export default function IndexPage() {
|
|||||||
if (checkResponse.data === 0) {
|
if (checkResponse.data === 0) {
|
||||||
await performLogin();
|
await performLogin();
|
||||||
} else {
|
} else {
|
||||||
const configResp = await getConfigByName("cloudflare_site_key");
|
const configResp = await getPublicConfigByName("cloudflare_site_key");
|
||||||
|
|
||||||
if (configResp.code === 0 && configResp.data && configResp.data.value) {
|
if (configResp.code === 0 && configResp.data && configResp.data.value) {
|
||||||
setSiteKey(configResp.data.value);
|
setSiteKey(configResp.data.value);
|
||||||
|
|||||||
Reference in New Issue
Block a user