feat(auth): extend admin session to 30 minutes

This commit is contained in:
anonsaber 2026-10-01 09:19:49 +08:00
parent b410dc4257
commit af89a17f7f
11 changed files with 21 additions and 22 deletions

View File

@ -42,7 +42,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
``` ```
Then open `http://localhost:8080` in a browser and enter your `CONFIG_ENCRYPTION_KEY` Then open `http://localhost:8080` in a browser and enter your `CONFIG_ENCRYPTION_KEY`
value. The SPA exchanges it with `POST /api/admin/session` for a 900-second admin session, value. The SPA exchanges it with `POST /api/admin/session` for a 1,800-second admin session,
then writes the business secrets to Redis with `PUT /api/business-config`. No environment then writes the business secrets to Redis with `PUT /api/business-config`. No environment
variable is read or written again for business configuration. variable is read or written again for business configuration.

View File

@ -36,7 +36,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
``` ```
然后在浏览器打开 `http://localhost:8080`,输入你的 `CONFIG_ENCRYPTION_KEY` 值。SPA 用它 然后在浏览器打开 `http://localhost:8080`,输入你的 `CONFIG_ENCRYPTION_KEY` 值。SPA 用它
经 `POST /api/admin/session` 换取 900 秒的 admin session,再用 `PUT /api/business-config` 经 `POST /api/admin/session` 换取 1,800 秒(30 分钟)的管理会话,再用 `PUT /api/business-config`
把业务密钥写入 Redis。此后业务配置不再读取或写入任何环境变量。 把业务密钥写入 Redis。此后业务配置不再读取或写入任何环境变量。
不用 Docker 的本地构建:`cargo build --locked` 产出 `message-weave` 二进制。 不用 Docker 的本地构建:`cargo build --locked` 产出 `message-weave` 二进制。

View File

@ -421,7 +421,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
### 7.3.1 Configuration Management API ### 7.3.1 Configuration Management API
- `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`). - `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`).
- `GET /api/status` publicly returns `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string }`, which only lists the missing environment variable names. When `REDIS_URL` or `CONFIG_ENCRYPTION_KEY` is missing, SPA only displays the configuration boot status and missing variables; the management session authorization area is displayed only after the service status is confirmed to be ready=true. `version` is the `BUILD_VERSION` (`<git-sha or nogit>+<UTC build time>`) baked by `build.rs` during compilation. The SPA footer uses this to confirm whether the deployment is actually implemented. - `GET /api/status` publicly returns `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string }`, which only lists the missing environment variable names. When `REDIS_URL` or `CONFIG_ENCRYPTION_KEY` is missing, SPA only displays the configuration boot status and missing variables; the management session authorization area is displayed only after the service status is confirmed to be ready=true. `version` is the `BUILD_VERSION` (`<git-sha or nogit>+<UTC build time>`) baked by `build.rs` during compilation. The SPA footer uses this to confirm whether the deployment is actually implemented.
- `POST /api/admin/session` accepts `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>` and returns `{ "session": "<opaque>", "expires_in": 900 }`; the admin session only saves the digest in Redis and expires after 900 seconds. `POST /api/admin/session/revoke` revokes the current session and returns `204` successfully. - `POST /api/admin/session` accepts `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>` and returns `{ "session": "<opaque>", "expires_in": 1800 }`; the admin session only saves the digest in Redis and expires after 1,800 seconds. `POST /api/admin/session/revoke` revokes the current session and returns `204` successfully.
- The admin page only saves the session in JavaScript memory. Request settings `credentials: omit`, `cache: no-store`, without using cookies, localStorage or sessionStorage. The admin API accepts a valid admin session; the compatibility path also accepts `WORKER_TOKEN`. Worker transparently transmits the authentication header (`SAF-LB-PASSTHRU`) as it is. - The admin page only saves the session in JavaScript memory. Request settings `credentials: omit`, `cache: no-store`, without using cookies, localStorage or sessionStorage. The admin API accepts a valid admin session; the compatibility path also accepts `WORKER_TOKEN`. Worker transparently transmits the authentication header (`SAF-LB-PASSTHRU`) as it is.
- `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters: - `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters:
```json ```json

View File

@ -421,7 +421,7 @@ message-weave/
### 7.3.1 配置管理 API ### 7.3.1 配置管理 API
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 - `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`<git-sha 或 nogit>+<UTC 构建时间>`),SPA 页脚据此确认部署是否真正落地。 - `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`<git-sha 或 nogit>+<UTC 构建时间>`),SPA 页脚据此确认部署是否真正落地。
- `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。 - `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "expires_in": 1800 }`;Redis 中保存的是会话令牌的摘要,30 分钟后过期。`POST /api/admin/session/revoke` 可立即撤销会话,成功返回 `204`。
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。 - 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数: - `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
```json ```json

View File

@ -59,7 +59,7 @@ Contract baseline (see `docs/reference.md` §1 for the complete semantics of key
| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 300 | `push:subscription:{id}` / `push:subscription-code:{code}` | | `ttl::PUSH_SUBSCRIPTION_SECONDS` | 300 | `push:subscription:{id}` / `push:subscription-code:{code}` |
| `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` | | `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` |
| `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` | | `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` |
| `ttl::ADMIN_SESSION_SECONDS` | 900 | `admin-session:{token}` (also the `expires_in` returned by `/admin/session`) | | `ttl::ADMIN_SESSION_SECONDS` | 1,800 | `admin-session:{token}` (also the `expires_in` returned by `/admin/session`) |
| `ttl::RETRY_COUNTER_SECONDS` | 86_400 | `retry:{stream}:{message.id}` | | `ttl::RETRY_COUNTER_SECONDS` | 86_400 | `retry:{stream}:{message.id}` |
| `ttl::CONSENT_TEMPORARY_SECONDS` | 3_600 | `consent:ai:{chat_id}` ("temporary/once" shared with explicit `/ai on`) | | `ttl::CONSENT_TEMPORARY_SECONDS` | 3_600 | `consent:ai:{chat_id}` ("temporary/once" shared with explicit `/ai on`) |
| `ttl::CONSENT_TODAY_SECONDS` | 86_400 | `consent:ai:{chat_id}` ("Today") | | `ttl::CONSENT_TODAY_SECONDS` | 86_400 | `consent:ai:{chat_id}` ("Today") |

View File

@ -59,7 +59,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 300 | `push:subscription:{id}` / `push:subscription-code:{code}` | | `ttl::PUSH_SUBSCRIPTION_SECONDS` | 300 | `push:subscription:{id}` / `push:subscription-code:{code}` |
| `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` | | `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` |
| `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` | | `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` |
| `ttl::ADMIN_SESSION_SECONDS` | 900 | `admin-session:{token}`(同时是 `/admin/session` 返回的 `expires_in`) | | `ttl::ADMIN_SESSION_SECONDS` | 1,800 | `admin-session:{token}`(同时是 `/admin/session` 返回的 `expires_in`) |
| `ttl::RETRY_COUNTER_SECONDS` | 86_400 | `retry:{stream}:{message.id}` | | `ttl::RETRY_COUNTER_SECONDS` | 86_400 | `retry:{stream}:{message.id}` |
| `ttl::CONSENT_TEMPORARY_SECONDS` | 3_600 | `consent:ai:{chat_id}`(「临时 / 一次」与显式 `/ai on` 共用) | | `ttl::CONSENT_TEMPORARY_SECONDS` | 3_600 | `consent:ai:{chat_id}`(「临时 / 一次」与显式 `/ai on` 共用) |
| `ttl::CONSENT_TODAY_SECONDS` | 86_400 | `consent:ai:{chat_id}`(「今天」) | | `ttl::CONSENT_TODAY_SECONDS` | 86_400 | `consent:ai:{chat_id}`(「今天」) |

View File

@ -38,7 +38,7 @@ Conventions used throughout:
| `config:business:revision` | No EX | atomic `INCR` on every config write; `SET 1 NX` on first-time init | `business_config_revision` | | `config:business:revision` | No EX | atomic `INCR` on every config write; `SET 1 NX` on first-time init | `business_config_revision` |
| `config:outbound` | No EX | `set_outbound_config` | `get_outbound_config` | | `config:outbound` | No EX | `set_outbound_config` | `get_outbound_config` |
| `config:enabled` | No EX | `set_enabled` | `is_enabled` | | `config:enabled` | No EX | `set_enabled` | `is_enabled` |
| `config:admin_session` | EX 900 | `put_admin_session` | `admin_session_valid`; cleared by `revoke_admin_session` | | `config:admin_session` | EX 1800 (30 minutes) | `put_admin_session` | `admin_session_valid`; cleared by `revoke_admin_session` |
Notes: Notes:
@ -46,8 +46,8 @@ Notes:
request-bearing entry point calls `refresh_business_config`, which only request-bearing entry point calls `refresh_business_config`, which only
rebuilds the worker when the remote revision exceeds the cached local value — that rebuilds the worker when the remote revision exceeds the cached local value — that
guard is what keeps a stale or malformed snapshot from turning into a rebuild loop. guard is what keeps a stale or malformed snapshot from turning into a rebuild loop.
- `config:admin_session` carries no TTL metadata beyond the literal `EX 900` passed at write - `config:admin_session` carries no TTL metadata beyond the literal `EX 1800` passed at write
time; the handler reports the same window in its response body as `expires_in: 900` time; the handler reports the same window in its response body as `expires_in: 1800`
on the handler side. on the handler side.
- `config:enabled` is consulted as a gate; missing or `false` keeps business processing off. - `config:enabled` is consulted as a gate; missing or `false` keeps business processing off.

View File

@ -38,7 +38,7 @@
| `配置:业务:修订版` |没有 EX |每次配置写入时原子“INCR”;首次初始化时“SET 1 NX”| `business_config_revision` | | `配置:业务:修订版` |没有 EX |每次配置写入时原子“INCR”;首次初始化时“SET 1 NX”| `business_config_revision` |
| `配置:出站` |没有 EX | `set_outbound_config` | `get_outbound_config` | | `配置:出站` |没有 EX | `set_outbound_config` | `get_outbound_config` |
| `配置:启用` |没有 EX | `设置启用` | `已启用` | | `配置:启用` |没有 EX | `设置启用` | `已启用` |
| `配置:admin_session` |前 900 | `put_admin_session` | `admin_session_valid`;由 `revoke_admin_session` 清除 | | `配置:admin_session` | EX 1800(30 分钟) | `put_admin_session` | `admin_session_valid`;由 `revoke_admin_session` 清除 |
笔记: 笔记:
@ -46,9 +46,8 @@
承载请求的入口点调用“refresh_business_config”,这仅 承载请求的入口点调用“refresh_business_config”,这仅
当远程修订超出缓存的本地值时重建工作程序 - 即 当远程修订超出缓存的本地值时重建工作程序 - 即
Guard 可以防止陈旧或格式错误的快照变成重建循环。 Guard 可以防止陈旧或格式错误的快照变成重建循环。
- `config:admin_session` 不携带超出写入时传递的文字 `EX 900` 的 TTL 元数据 - `config:admin_session` 的 TTL 由写入时传入的 `EX 1800` 决定;处理程序也在响应正文中
时间;处理程序在其响应正文中报告相同的窗口“expires_in: 900” 通过 `expires_in: 1800` 返回相同的有效期。
在处理程序一侧。
- `config:enabled` 被视为一个门;缺失或“错误”会导致业务处理中断。 - `config:enabled` 被视为一个门;缺失或“错误”会导致业务处理中断。
### 1.2 AI 授权 ### 1.2 AI 授权

View File

@ -65,7 +65,7 @@ pub(crate) mod ttl {
pub const PUSH_ORPHAN_SECONDS: u64 = WEEK; pub const PUSH_ORPHAN_SECONDS: u64 = WEEK;
/// `/admin` digest session. Must match the `expires_in` reported to the /// `/admin` digest session. Must match the `expires_in` reported to the
/// admin client. /// admin client.
pub const ADMIN_SESSION_SECONDS: u64 = 900; pub const ADMIN_SESSION_SECONDS: u64 = 1_800;
/// Retry counter retention. `retry_or_dlq` interpolates this into its Lua /// Retry counter retention. `retry_or_dlq` interpolates this into its Lua
/// script, so there is exactly one literal for it. /// script, so there is exactly one literal for it.
pub const RETRY_COUNTER_SECONDS: u64 = DAY; pub const RETRY_COUNTER_SECONDS: u64 = DAY;
@ -1523,7 +1523,7 @@ mod tests {
assert_eq!(ttl::PUSH_SUBSCRIPTION_SECONDS, 300); assert_eq!(ttl::PUSH_SUBSCRIPTION_SECONDS, 300);
assert_eq!(ttl::PUSH_REGISTRATION_SECONDS, 604_800); assert_eq!(ttl::PUSH_REGISTRATION_SECONDS, 604_800);
assert_eq!(ttl::PUSH_ORPHAN_SECONDS, 604_800); assert_eq!(ttl::PUSH_ORPHAN_SECONDS, 604_800);
assert_eq!(ttl::ADMIN_SESSION_SECONDS, 900); assert_eq!(ttl::ADMIN_SESSION_SECONDS, 1_800);
assert_eq!(ttl::DEDUP_TG_SECONDS, 86_400); assert_eq!(ttl::DEDUP_TG_SECONDS, 86_400);
assert_eq!(ttl::DEDUP_JMAP_SECONDS, 86_400); assert_eq!(ttl::DEDUP_JMAP_SECONDS, 86_400);

View File

@ -30,7 +30,7 @@
"auth.eyebrow": "Authorization required", "auth.eyebrow": "Authorization required",
"auth.title": "Create admin session", "auth.title": "Create admin session",
"auth.bodyPre": "Enter ", "auth.bodyPre": "Enter ",
"auth.bodyPost": ". The session lasts 15 minutes and lives only in this page's memory.", "auth.bodyPost": ". The session lasts 30 minutes and lives only in this page's memory.",
"auth.label": "Business encryption key", "auth.label": "Business encryption key",
"auth.placeholder": "Paste the value", "auth.placeholder": "Paste the value",
"auth.submit": "Create session", "auth.submit": "Create session",
@ -144,7 +144,7 @@
"runtime.savedToast": "Runtime parameters saved to Redis and applied immediately to subsequent requests.", "runtime.savedToast": "Runtime parameters saved to Redis and applied immediately to subsequent requests.",
"runtime.defaultsToast": "Runtime parameters restored to default-value preview. They are written to Redis only after clicking \"Save runtime parameters\".", "runtime.defaultsToast": "Runtime parameters restored to default-value preview. They are written to Redis only after clicking \"Save runtime parameters\".",
"privacy.title": "Secrets are never echoed or persisted to the browser.", "privacy.title": "Secrets are never echoed or persisted to the browser.",
"privacy.body": "Business configuration is submitted only to the same-origin API and encrypted into Redis by the backend; this page never reads mailbox contents and uses no local files or database.", "privacy.body": "Your settings are encrypted before they are saved on the server. Email contents are not shown here.",
"footer.note": "Configuration managed by your server", "footer.note": "Configuration managed by your server",
"common.required": "Required", "common.required": "Required",
"common.optional": "Optional", "common.optional": "Optional",
@ -247,7 +247,7 @@
"auth.eyebrow": "需要授权", "auth.eyebrow": "需要授权",
"auth.title": "管理员授权", "auth.title": "管理员授权",
"auth.bodyPre": "输入 ", "auth.bodyPre": "输入 ",
"auth.bodyPost": ",会话有效 15 分钟,仅保存在当前页面。", "auth.bodyPost": ",会话有效 30 分钟,仅保存在当前页面。",
"auth.label": "业务密钥", "auth.label": "业务密钥",
"auth.placeholder": "粘贴部署密钥", "auth.placeholder": "粘贴部署密钥",
"auth.submit": "授权", "auth.submit": "授权",
@ -357,8 +357,8 @@
"runtime.readToast": "运行参数已读取。业务配置仍需手动完整填写。", "runtime.readToast": "运行参数已读取。业务配置仍需手动完整填写。",
"runtime.savedToast": "运行参数已保存到 Redis,并立即应用于后续请求。", "runtime.savedToast": "运行参数已保存到 Redis,并立即应用于后续请求。",
"runtime.defaultsToast": "运行参数已恢复为默认值预览。点击“保存运行参数”后才会更新 Redis。", "runtime.defaultsToast": "运行参数已恢复为默认值预览。点击“保存运行参数”后才会更新 Redis。",
"privacy.title": "密钥不会显示或保存到浏览器。", "privacy.title": "密钥不会显示或保存在浏览器里。",
"privacy.body": "配置仅经同源 API 提交,并由后端加密保存。", "privacy.body": "你的设置会加密后保存在服务器上。这里不会显示邮件内容。",
"footer.note": "配置由你的服务端管理", "footer.note": "配置由你的服务端管理",
"common.required": "必填", "common.required": "必填",
"common.optional": "可选", "common.optional": "可选",
@ -836,7 +836,7 @@
function scheduleSessionExpiry(seconds) { function scheduleSessionExpiry(seconds) {
if (expiryTimer) window.clearTimeout(expiryTimer); if (expiryTimer) window.clearTimeout(expiryTimer);
const duration = Math.max(1, Math.min(Number(seconds) || 900, 900)); const duration = Math.max(1, Math.min(Number(seconds) || 1_800, 1_800));
sessionExpiry.textContent = t("session.expiry", { n: Math.ceil(duration / 60) }); sessionExpiry.textContent = t("session.expiry", { n: Math.ceil(duration / 60) });
expiryTimer = window.setTimeout(() => { expiryTimer = window.setTimeout(() => {
expireSession(t("session.expired")); expireSession(t("session.expired"));

View File

@ -53,7 +53,7 @@
</div> </div>
<div><p class="eyebrow" data-i18n="auth.eyebrow">Authorization required</p><h2 id="auth-title" data-i18n="auth.title">Create admin session</h2></div> <div><p class="eyebrow" data-i18n="auth.eyebrow">Authorization required</p><h2 id="auth-title" data-i18n="auth.title">Create admin session</h2></div>
</div> </div>
<p class="panel-copy"><span data-i18n="auth.bodyPre">Enter </span><code>CONFIG_ENCRYPTION_KEY</code><span data-i18n="auth.bodyPost">. The session lasts 15 minutes and lives only in this page's memory.</span></p> <p class="panel-copy"><span data-i18n="auth.bodyPre">Enter </span><code>CONFIG_ENCRYPTION_KEY</code><span data-i18n="auth.bodyPost">. The session lasts 30 minutes and lives only in this page's memory.</span></p>
<form id="auth-form" class="auth-form" autocomplete="off"> <form id="auth-form" class="auth-form" autocomplete="off">
<label for="bootstrap-input" data-i18n="auth.label">Business encryption key</label> <label for="bootstrap-input" data-i18n="auth.label">Business encryption key</label>
<div class="token-row"> <div class="token-row">