diff --git a/Sense/scripts/runtime/sense-common.ps1 b/Sense/scripts/runtime/sense-common.ps1 index e4cd351..03b32bc 100644 --- a/Sense/scripts/runtime/sense-common.ps1 +++ b/Sense/scripts/runtime/sense-common.ps1 @@ -228,7 +228,7 @@ function Initialize-SenseRuntime { ' enableddb: false', ' jwt:', " secret: $(ConvertTo-SenseYamlString $jwtSecret)", - ' timeout: 3600', + ' timeout: 2592000', ' database:', ' driver: postgres', " source: $(ConvertTo-SenseYamlString $databaseURL)", diff --git a/Sense/server/common/middleware/auth.go b/Sense/server/common/middleware/auth.go index 927743d..fdfbea6 100644 --- a/Sense/server/common/middleware/auth.go +++ b/Sense/server/common/middleware/auth.go @@ -8,16 +8,11 @@ import ( jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" ) +const defaultLoginValidity = 30 * 24 * time.Hour + // AuthInit jwt验证new func AuthInit() (*jwt.GinJWTMiddleware, error) { - timeout := time.Hour - if config.ApplicationConfig.Mode == "dev" { - timeout = time.Duration(876010) * time.Hour - } else { - if config.JwtConfig.Timeout != 0 { - timeout = time.Duration(config.JwtConfig.Timeout) * time.Second - } - } + timeout := resolveJWTTimeout(config.ApplicationConfig.Mode, config.JwtConfig.Timeout) return jwt.New(&jwt.GinJWTMiddleware{ Realm: "Sense", Key: []byte(config.JwtConfig.Secret), @@ -34,3 +29,13 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) { }) } + +func resolveJWTTimeout(mode string, configuredSeconds int64) time.Duration { + if mode == "dev" { + return time.Duration(876010) * time.Hour + } + if configuredSeconds > 0 { + return time.Duration(configuredSeconds) * time.Second + } + return defaultLoginValidity +} diff --git a/Sense/server/common/middleware/auth_test.go b/Sense/server/common/middleware/auth_test.go new file mode 100644 index 0000000..0d26b7f --- /dev/null +++ b/Sense/server/common/middleware/auth_test.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "testing" + "time" + + "github.com/go-admin-team/go-admin-core/sdk/config" +) + +func TestResolveJWTTimeoutDefaultsToThirtyDays(t *testing.T) { + for _, mode := range []string{"prod", "test", "demo"} { + if got := resolveJWTTimeout(mode, 0); got != 30*24*time.Hour { + t.Errorf("mode %s timeout=%s, want 720h", mode, got) + } + } +} + +func TestResolveJWTTimeoutHonorsExplicitConfiguration(t *testing.T) { + if got := resolveJWTTimeout("prod", 3600); got != time.Hour { + t.Fatalf("timeout=%s, want 1h", got) + } +} + +func TestResolveJWTTimeoutKeepsUpstreamDevelopmentBehavior(t *testing.T) { + if got := resolveJWTTimeout("dev", 1); got != 876010*time.Hour { + t.Fatalf("development timeout=%s", got) + } +} + +func TestAuthInitSignsTokenExpiringAfterThirtyDays(t *testing.T) { + oldMode := config.ApplicationConfig.Mode + oldTimeout := config.JwtConfig.Timeout + oldSecret := config.JwtConfig.Secret + t.Cleanup(func() { + config.ApplicationConfig.Mode = oldMode + config.JwtConfig.Timeout = oldTimeout + config.JwtConfig.Secret = oldSecret + }) + + config.ApplicationConfig.Mode = "prod" + config.JwtConfig.Timeout = 0 + config.JwtConfig.Secret = "task-104-test-secret-not-for-production" + + auth, err := AuthInit() + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC) + auth.TimeFunc = func() time.Time { return now } + _, expiresAt, err := auth.TokenGenerator(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + if got := expiresAt.Sub(now); got != 30*24*time.Hour { + t.Fatalf("signed token validity=%s, want 720h", got) + } +} diff --git a/Sense/server/config/READMEN.md b/Sense/server/config/READMEN.md index 7923904..bd03ad6 100644 --- a/Sense/server/config/READMEN.md +++ b/Sense/server/config/READMEN.md @@ -20,7 +20,7 @@ settings: # JWT加密字符串 secret: "" # 过期时间单位:秒 - timeout: 3600 + timeout: 2592000 database: # 数据库名称 name: dbname diff --git a/Sense/server/config/settings.demo.yml b/Sense/server/config/settings.demo.yml index 2750d18..9cc760c 100644 --- a/Sense/server/config/settings.demo.yml +++ b/Sense/server/config/settings.demo.yml @@ -16,7 +16,7 @@ settings: frontpath: ../ui/src jwt: secret: "" - timeout: 3600 + timeout: 2592000 logger: # 日志存放路径 path: temp/logs diff --git a/Sense/server/config/settings.full.yml b/Sense/server/config/settings.full.yml index 4383d4f..3b5aa2b 100644 --- a/Sense/server/config/settings.full.yml +++ b/Sense/server/config/settings.full.yml @@ -34,7 +34,7 @@ settings: # token 密钥,生产环境时及的修改 secret: "" # token 过期时间 单位:秒 - timeout: 3600 + timeout: 2592000 database: # Sense 仅支持 PostgreSQL;连接信息由仓库外配置提供。 driver: postgres diff --git a/Sense/server/config/settings.sqlite.yml b/Sense/server/config/settings.sqlite.yml index 714c374..84187f1 100644 --- a/Sense/server/config/settings.sqlite.yml +++ b/Sense/server/config/settings.sqlite.yml @@ -25,7 +25,7 @@ settings: # token 密钥,生产环境时及的修改 secret: "" # token 过期时间 单位:秒 - timeout: 3600 + timeout: 2592000 database: # 文件名为上游兼容名称;Sense 仍只支持 PostgreSQL。 driver: postgres diff --git a/Sense/server/config/settings.yml b/Sense/server/config/settings.yml index b429001..e721a41 100644 --- a/Sense/server/config/settings.yml +++ b/Sense/server/config/settings.yml @@ -25,7 +25,7 @@ settings: # 必填。生产环境至少 32 个字符;不得提交真实值。 secret: "" # token 过期时间 单位:秒 - timeout: 3600 + timeout: 2592000 database: # 数据库类型 mysql, sqlite3, postgres, sqlserver # sqlserver: sqlserver://用户名:密码@地址?database=数据库名 diff --git a/Sense/tests/package/run-tests.ps1 b/Sense/tests/package/run-tests.ps1 index 3b2c269..814d07f 100644 --- a/Sense/tests/package/run-tests.ps1 +++ b/Sense/tests/package/run-tests.ps1 @@ -65,6 +65,7 @@ try { Assert-Equal 18071 $state.Port 'non-empty process environment must override sense.env' Assert-True (-not (Test-Path -LiteralPath $marker)) 'sense.env content must never execute' $generatedSettings = Get-Content -LiteralPath $state.SettingsPath -Raw + Assert-True $generatedSettings.Contains('timeout: 2592000') 'generated runtime settings must keep login valid for 30 days' Assert-True ((Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL').Contains('p#&;=x')) 'special characters must survive env parsing' Assert-True ($generatedSettings.Contains('password=p#\u0026;=x')) 'special characters must be safely JSON-escaped in YAML' Assert-True (-not ((Get-SenseDatabaseInfo (Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL')).Sanitized.Contains('password='))) 'PostgreSQL tool arguments must not contain password' diff --git a/Sense/ui/src/utils/auth.js b/Sense/ui/src/utils/auth.js index cbea20b..7ee3723 100644 --- a/Sense/ui/src/utils/auth.js +++ b/Sense/ui/src/utils/auth.js @@ -1,13 +1,14 @@ import Cookies from 'js-cookie' const TokenKey = 'Sense-Admin-Token' +const LoginValidityDays = 30 export function getToken() { return Cookies.get(TokenKey) } export function setToken(token) { - return Cookies.set(TokenKey, token) + return Cookies.set(TokenKey, token, { expires: LoginValidityDays }) } export function removeToken() { diff --git a/Sense/ui/tests/unit/utils/auth.spec.js b/Sense/ui/tests/unit/utils/auth.spec.js new file mode 100644 index 0000000..011106d --- /dev/null +++ b/Sense/ui/tests/unit/utils/auth.spec.js @@ -0,0 +1,32 @@ +import Cookies from 'js-cookie' +import { getToken, removeToken, setToken } from '@/utils/auth' + +jest.mock('js-cookie', () => ({ + get: jest.fn(), + set: jest.fn(), + remove: jest.fn() +})) + +describe('Sense login token cookie', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('persists the token for 30 days', () => { + setToken('test-token') + + expect(Cookies.set).toHaveBeenCalledWith( + 'Sense-Admin-Token', + 'test-token', + { expires: 30 } + ) + }) + + it('reads and removes the same product-specific cookie', () => { + getToken() + removeToken() + + expect(Cookies.get).toHaveBeenCalledWith('Sense-Admin-Token') + expect(Cookies.remove).toHaveBeenCalledWith('Sense-Admin-Token') + }) +}) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index d0938db..78b4974 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: 8608db441b21964aba7ba78e2c1581b4f5f910aa -synchronized_at: 2026-08-16T15:22:21Z +wiki_revision: 8df12aa3e136b1faee9c2dffb58ca39b045bc49f +synchronized_at: 2026-08-17T02:54:44Z # 架构与代码地图 @@ -89,7 +89,7 @@ Sense 业务菜单必须遵循冻结 GoAdmin 的路由树:顶层目录使用 ` 工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。 -Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置管理 CRUD、configKey、set-config、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。登录外壳必需的匿名只读 `GET /api/v1/app-config` 是唯一例外:它复用 GoAdmin `SysConfig.Get2SysApp`,只投影标记为前端可见的配置,不提供写入或管理能力。 +Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。Sense 默认登录有效期为固定 30 天:后端 JWT 使用 2,592,000 秒,前端 Token Cookie 使用 30 天持久化期限;不做滑动续期、Refresh Token 或服务端单 Token 撤销,退出只删除本机 Cookie。部署新版本后已有 Token 不会自动延长,用户必须重新登录取得新有效期。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置管理 CRUD、configKey、set-config、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。登录外壳必需的匿名只读 `GET /api/v1/app-config` 是唯一例外:它复用 GoAdmin `SysConfig.Get2SysApp`,只投影标记为前端可见的配置,不提供写入或管理能力。 工单 #65 新增设备台账入口:后端按 `models → dto → service → api → router` 分层位于 `Sense/server/app/sense/device/`,管理路由在 `Sense/server/app/admin/router/sense_device.go`,前端页面位于 `Sense/ui/src/views/sense/device/index.vue`。设备凭据由 `Sense/server/app/sense/credential/` 独立存储和 AES-256-GCM 加密,HTTP 只返回是否已配置,不提供凭据读取接口。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index a45e4e2..372052d 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.- -wiki_revision: ee4819a84dd40846bc8fe1865e08590e1cf9cb81 -synchronized_at: 2026-08-15T09:58:37Z +wiki_revision: 84a086640dc3618e43f834c488080f1e0cb5b063 +synchronized_at: 2026-08-17T02:54:49Z # 本地开发与验证 @@ -148,6 +148,8 @@ Remove-Variable bootstrapToken, bootstrapBody 初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。Sense 在 production、test、dev 模式均只提交账号和密码,不显示、不请求也不校验验证码;`/api/v1/captcha` 暂时保留作上游兼容接口,但登录页和登录 API 不依赖它。登录成功、错误密码和未认证拒绝仍必须写入脱敏身份审计,密码继续执行 6–72 字节策略。 +Sense 默认登录有效期为固定 30 天。仓库配置和 Windows 运行脚本生成的 `jwt.timeout` 均为 `2592000` 秒,前端 `Sense-Admin-Token` Cookie 使用 30 天持久化期限。更新该版本后必须重新登录,已有 Token 不会自动延长。该有效期不是滑动续期;退出登录会删除本机 Cookie,但当前无状态 JWT 架构不提供服务端单 Token 撤销。如需立即使全部已签发 Token 失效,应在受控维护窗口轮换仓库外 JWT secret,并明确通知所有用户重新登录。 + 身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。 详细来源与安全约束见 `Sense/LICENSES/SOURCES.md` 和 `Sense/README.md`。 diff --git a/docs/09-product-requirements.md b/docs/09-product-requirements.md index 594418d..28a678b 100644 --- a/docs/09-product-requirements.md +++ b/docs/09-product-requirements.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Product-Requirements wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.- -wiki_revision: 221fd392c943caab13ee097c19a3d19d4410b65a -synchronized_at: 2026-08-15T09:59:15Z +wiki_revision: e6c6e0d7f658a7c1040fe569026e7d0ec581701a +synchronized_at: 2026-08-17T02:54:53Z # 产品需求 @@ -48,7 +48,7 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险 ### P0 -- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。首期采用账号密码直接登录,所有运行模式均不使用验证码;密码保持 6–72 字节且不强制字符复杂度,登录成功与失败必须记录不含秘密的身份审计。 +- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。首期采用账号密码直接登录,所有运行模式均不使用验证码;密码保持 6–72 字节且不强制字符复杂度,登录成功与失败必须记录不含秘密的身份审计。默认登录有效期为固定 30 天,后端 JWT 与浏览器持久 Cookie 必须一致;版本更新后已有 Token 不自动延长,不提供滑动续期或服务端单 Token 撤销。 - **SEN-002 设备台账**:以 Device 为根实体,通过 `modality` 和 `capabilities` 表达 video/radar/contact/button/wearable/other;首期只完整实现 video,未实现适配器显示 `adapter_not_ready`。 - **SEN-003 ONVIF/RTSP 接入**:支持发现或手工添加、Profiles、StreamUri、主/子码流、校时、认证失败、重新探测和凭据更新。 - **SEN-004 批量开通**:默认 16 路可导入、预校验、待激活、逐项成功/失败、仅重试失败项;部分成功不做整体回滚。 diff --git a/docs/task/104-Sense-30天登录有效期.md b/docs/task/104-Sense-30天登录有效期.md new file mode 100644 index 0000000..591637e --- /dev/null +++ b/docs/task/104-Sense-30天登录有效期.md @@ -0,0 +1,80 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-104-Sense-30天登录有效期 +wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-104-Sense-30%E5%A4%A9%E7%99%BB%E5%BD%95%E6%9C%89%E6%95%88%E6%9C%9F.- +wiki_revision: 2b4df23ec321de868840da61d87d8d6fce8537a7 +synchronized_at: 2026-08-17T03:46:13Z + + +# 104 Sense-30天登录有效期 + +- 类型:需求 +- 所属 Epic:#7 +- 所属 MVP / 版本:#8 +- 状态:已完成 +- 日期:2026-08-17 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/104 +- Wiki 页面:Task-104-Sense-30天登录有效期 +- Wiki revision:见本地镜像头 + +## 背景与目标 + +Sense 原生产、demo、SQLite 和完整配置默认 JWT 有效期为 3600 秒,前端 Token 使用浏览器会话 Cookie。按用户确认,将默认登录有效期统一调整为固定 30 天,使浏览器重启后在 Token 未过期时仍可保持登录。 + +## 最终方案 + +- 保留 go-admin JWT 中间件和 go-admin-ui `js-cookie` 封装;非开发模式未显式配置时默认使用 30 天,合法显式配置仍优先,开发模式保持上游行为。 +- 生产、demo、SQLite、full 与 Windows 运行时生成配置统一使用 2,592,000 秒。 +- `Sense-Admin-Token` Cookie 使用 `expires: 30`;退出登录仍删除 Cookie。 +- 不是滑动续期,不增加 Refresh Token、服务端会话表或 Token 黑名单。已有 Token 不自动延长,部署后需重新登录。 +- Token 泄露窗口扩大到 30 天;紧急失效需要轮换 JWT secret,这会使全部现有登录失效。 + +## 修改文件 + +- `Sense/server/common/middleware/auth.go`、`auth_test.go`:实现并验证默认 30 天 JWT。 +- `Sense/server/config/settings*.yml`、`READMEN.md`:统一配置值和示例。 +- `Sense/scripts/runtime/sense-common.ps1`、`Sense/tests/package/run-tests.ps1`:统一并验证 Windows 运行时配置。 +- `Sense/ui/src/utils/auth.js`、`Sense/ui/tests/unit/utils/auth.spec.js`:持久化 Cookie 30 天并验证设置、读取和删除。 +- `docs/02-architecture-and-code-map.md`、`docs/04-local-development-and-verification.md`、`docs/09-product-requirements.md`:由 Wiki 同步的长期文档镜像。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| 非开发模式签发 JWT 的到期时间为签发后 30 天 | 通过 | +| Token Cookie 使用 30 天持久化期限,退出仍删除 | 通过 | +| 浏览器关闭后重新打开仍保持登录 | 通过(用户验收) | +| 所有运行配置及 Windows 生成值为 2,592,000 秒 | 通过 | +| Go、Vue、Windows 包测试与 PostgreSQL 启动回归 | 通过 | +| ZIP 仅含模板配置,不包含现场秘密或数据 | 通过 | +| 已有 Token 不自动延长并记录重新登录要求 | 通过 | + +## 测试 + +- `go test ./...`:通过。 +- `go vet ./...`:通过。 +- `go build ./...`:通过。 +- `pnpm run lint`:0 error,32 条上游既有 warning。 +- `pnpm exec vue-cli-service test:unit --runInBand`:18 suites、48 tests 通过。 +- `Sense/tests/package/run-tests.bat -PackageRoot Sense/dist/sense-windows-amd64`:22 assertions 通过。 +- Windows amd64 生产构建、资源审计、包审计:通过;4 条既有构建 warning。 +- 独立 #104 包使用现场 PostgreSQL 完成迁移和启动,`127.0.0.1:18080` 监听成功,生成配置确认 `timeout: 2592000`。 +- 包:`Sense/dist/sense-windows-amd64-104.zip`,56,457,546 bytes,SHA-256 `BCE3EDD3F5BD79C75A953CB7673D1C9867CE3BB89755D6A188C7CA1226D05CE3`,来源提交 `adc227f110bf0c456d1523eac151ed1d8360cc83`。 +- 一次错误的 Vue 测试参数把 `--runInBand` 识别为模式而未找到测试,已改用正确命令并通过;一次临时运行数据恢复把日志目录复制成同名文件,修正验证环境后从失败点重试并通过,均非产品代码缺陷。 +- **未验证部分**:无法用真实等待 30 天验证自然到期;以固定时钟 JWT 测试和 Cookie 参数单测覆盖。用户已于 2026-08-17 明确验收通过,浏览器关闭/重开行为由人工确认。#104 独立包不包含仍待验收的 #101;两项进入 `dev` 后才能生成最终组合包。 + +## 遗留问题 + +- 当前服务已恢复为用户原有的 #101 验证包,避免应用外壳回退;#104 独立包保留为具名 ZIP,未覆盖当前标准包。 +- 当前架构没有服务端单 Token 撤销能力,这是确认范围内的安全限制。 + +## 验收确认 + +- 用户于 2026-08-17 明确回复“#104 验收通过”。 +- PR #105 按仓库门禁合入 `dev`;`main` 不变。 + +## 相关提交 + +- `857ba45` feat: 将 Sense 登录有效期调整为30天 (#104) +- `a843121` docs: 记录 Sense 30天登录有效期 (#104) +- `adc227f` test: 验证 Sense 30天 JWT 到期时间 (#104) diff --git a/wiki-docs.json b/wiki-docs.json index c4d16c7..9fcd78f 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -151,6 +151,10 @@ { "page": "Task-99-Sense登录页只读配置接口", "path": "docs/task/99-Sense登录页只读配置接口.md" + }, + { + "page": "Task-104-Sense-30天登录有效期", + "path": "docs/task/104-Sense-30天登录有效期.md" } ] }