From 576eb8055afe080c881dcfb1a81cd4c2d678f296 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 19 Oct 2026 17:26:06 +0800 Subject: [PATCH] fix(auth): require secure JWT configuration and persist desktop secret (#666) Require explicitly configured secure JWT secrets for server deployments and persist a random per-installation desktop secret across restarts. Preserve explicit environment overrides for strict server validation. Fixes #665. --- .env.example | 4 +- docker-compose.yml | 2 +- mateclaw-desktop/electron/main/index.ts | 2 + .../electron/main/jwtSecret.d.mts | 1 + mateclaw-desktop/electron/main/jwtSecret.mjs | 35 +++++++ .../scripts/__tests__/jwt-secret.test.mjs | 43 +++++++++ .../vip/mate/auth/service/AuthService.java | 9 +- .../mate/auth/service/JwtSecretValidator.java | 28 ++++++ .../vip/mate/auth/sso/SsoStateService.java | 10 +- .../channel/webchat/WebChatController.java | 2 +- .../mate/config/SecurityStartupValidator.java | 24 ++--- .../src/main/resources/application.yml | 2 +- .../src/main/resources/docs/en/config.md | 13 +-- .../src/main/resources/docs/en/security.md | 6 +- .../src/main/resources/docs/zh/config.md | 13 +-- .../src/main/resources/docs/zh/security.md | 6 +- .../service/AuthServiceJwtSecretTest.java | 93 +++++++++++++++++++ .../mate/auth/sso/SsoStateServiceTest.java | 13 +++ .../WebChatApprovalInteractionTest.java | 4 +- .../webchat/WebChatArchivePinTest.java | 4 +- .../webchat/WebChatAttachmentE2ETest.java | 4 +- .../webchat/WebChatAuditTrailTest.java | 4 +- .../webchat/WebChatCreateSessionTest.java | 4 +- .../webchat/WebChatRegenerateTest.java | 4 +- .../webchat/WebChatSchemaFieldsTest.java | 4 +- .../webchat/WebChatSessionManagementTest.java | 4 +- .../channel/webchat/WebChatSkillListTest.java | 4 +- .../webchat/WebChatStopStreamTest.java | 4 +- .../channel/webchat/WebChatStreamE2ETest.java | 4 +- .../webchat/WebChatTokenRevocationTest.java | 4 +- .../webchat/WebChatWikiPageListTest.java | 4 +- .../config/SecurityStartupValidatorTest.java | 46 +++++++++ .../search/SessionSearchIsolationTest.java | 2 +- .../src/test/resources/application.properties | 1 + 34 files changed, 329 insertions(+), 78 deletions(-) create mode 100644 mateclaw-desktop/electron/main/jwtSecret.d.mts create mode 100644 mateclaw-desktop/electron/main/jwtSecret.mjs create mode 100644 mateclaw-desktop/scripts/__tests__/jwt-secret.test.mjs create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/service/JwtSecretValidator.java create mode 100644 mateclaw-server/src/test/java/vip/mate/auth/service/AuthServiceJwtSecretTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/config/SecurityStartupValidatorTest.java create mode 100644 mateclaw-server/src/test/resources/application.properties diff --git a/.env.example b/.env.example index b9ba79a1..69feb818 100644 --- a/.env.example +++ b/.env.example @@ -36,8 +36,8 @@ TAVILY_API_KEY= # ==================== 安全(强烈建议覆盖) ==================== -# JWT 签名密钥。若留空,服务器会用内置默认值并在启动日志里 WARN。 -# 生产部署必须设置,至少 32 位随机字符串:openssl rand -base64 48 +# 必须设置唯一的随机 JWT 签名密钥:openssl rand -base64 48。 +# 密钥至少 32 个 UTF-8 字节;更换密钥会使现有登录 token 失效。 JWT_SECRET= # CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com)。 diff --git a/docker-compose.yml b/docker-compose.yml index 6a8a0411..e79589f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,7 +107,7 @@ services: # Keys are stored in mate_model_provider and hot-reloaded. SERPER_API_KEY: ${SERPER_API_KEY:-} TAVILY_API_KEY: ${TAVILY_API_KEY:-} - JWT_SECRET: ${JWT_SECRET:-} + JWT_SECRET: "${JWT_SECRET:?Set JWT_SECRET in .env; generate one with openssl rand -base64 48}" MATECLAW_CORS_ALLOWED_ORIGINS: ${MATECLAW_CORS_ALLOWED_ORIGINS:-} # SearXNG: tell the app where to reach the sidecar container SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-http://searxng:8080} diff --git a/mateclaw-desktop/electron/main/index.ts b/mateclaw-desktop/electron/main/index.ts index 1d6c36c3..f9de8468 100644 --- a/mateclaw-desktop/electron/main/index.ts +++ b/mateclaw-desktop/electron/main/index.ts @@ -20,6 +20,7 @@ import { expandPath, type LocalToolsConfig, } from './localToolsConfig' +import { getLocalJwtSecret } from './jwtSecret.mjs' import { LocalBridge } from './localBridge' // ─── Constants ─────────────────────────────────────────────────────────────── @@ -227,6 +228,7 @@ async function startJavaBackend(): Promise { // Prepare environment variables — inherit current env + override const env = { ...process.env, + JWT_SECRET: getLocalJwtSecret(app.getPath('userData'), process.env.JWT_SECRET), // Ensure H2 database is stored in userData SPRING_DATASOURCE_URL: `jdbc:h2:file:${join(workingDir, 'data', 'mateclaw')};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE`, } diff --git a/mateclaw-desktop/electron/main/jwtSecret.d.mts b/mateclaw-desktop/electron/main/jwtSecret.d.mts new file mode 100644 index 00000000..f27ed35c --- /dev/null +++ b/mateclaw-desktop/electron/main/jwtSecret.d.mts @@ -0,0 +1 @@ +export function getLocalJwtSecret(userDataPath: string, explicitSecret?: string): string diff --git a/mateclaw-desktop/electron/main/jwtSecret.mjs b/mateclaw-desktop/electron/main/jwtSecret.mjs new file mode 100644 index 00000000..0714d280 --- /dev/null +++ b/mateclaw-desktop/electron/main/jwtSecret.mjs @@ -0,0 +1,35 @@ +import { randomBytes } from 'node:crypto' +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const SECRET_FILE = 'jwt-secret' + +function readSecret(path) { + const secret = readFileSync(path, 'utf8') + chmodSync(path, 0o600) + return secret +} + +export function getLocalJwtSecret(userDataPath, explicitSecret) { + if (explicitSecret !== undefined) return explicitSecret + + mkdirSync(userDataPath, { recursive: true, mode: 0o700 }) + const secretPath = join(userDataPath, SECRET_FILE) + + try { + return readSecret(secretPath) + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + + const secret = randomBytes(48).toString('base64url') + + try { + writeFileSync(secretPath, secret, { encoding: 'utf8', flag: 'wx', mode: 0o600 }) + chmodSync(secretPath, 0o600) + return secret + } catch (error) { + if (error.code !== 'EEXIST') throw error + return readSecret(secretPath) + } +} diff --git a/mateclaw-desktop/scripts/__tests__/jwt-secret.test.mjs b/mateclaw-desktop/scripts/__tests__/jwt-secret.test.mjs new file mode 100644 index 00000000..3fb83798 --- /dev/null +++ b/mateclaw-desktop/scripts/__tests__/jwt-secret.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { getLocalJwtSecret } from '../../electron/main/jwtSecret.mjs' + +function makeUserDataPath(t) { + const userDataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-jwt-secret-')) + t.after(() => fs.rmSync(userDataPath, { recursive: true, force: true })) + return userDataPath +} + +test('creates a random per-installation secret with private file permissions', (t) => { + const userDataPath = makeUserDataPath(t) + const secretPath = path.join(userDataPath, 'jwt-secret') + const secret = getLocalJwtSecret(userDataPath) + + assert.match(secret, /^[A-Za-z0-9_-]{64}$/) + assert.equal(Buffer.from(secret, 'base64url').length, 48) + assert.equal(fs.readFileSync(secretPath, 'utf8'), secret) + + if (process.platform !== 'win32') { + assert.equal(fs.statSync(secretPath).mode & 0o777, 0o600) + } +}) + +test('reuses the persisted secret after restart', (t) => { + const userDataPath = makeUserDataPath(t) + const first = getLocalJwtSecret(userDataPath) + const afterRestart = getLocalJwtSecret(userDataPath) + + assert.equal(afterRestart, first) +}) + +test('preserves explicit overrides, including invalid values for server-side rejection', (t) => { + const userDataPath = makeUserDataPath(t) + + assert.equal(getLocalJwtSecret(userDataPath, 'explicit-secret-with-more-than-32-bytes'), 'explicit-secret-with-more-than-32-bytes') + assert.equal(getLocalJwtSecret(userDataPath, ''), '') + assert.equal(getLocalJwtSecret(userDataPath, 'short'), 'short') + assert.equal(fs.existsSync(path.join(userDataPath, 'jwt-secret')), false) +}) diff --git a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java index 1bf490e3..52ade8f7 100644 --- a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java +++ b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java @@ -33,7 +33,7 @@ public class AuthService { private final UserMapper userMapper; private final BCryptPasswordEncoder passwordEncoder; - @Value("${mateclaw.jwt.secret:MateClaw-Secret-Key-2024-Very-Long-String}") + @Value("${mateclaw.jwt.secret}") private String jwtSecret; @Value("${mateclaw.jwt.expiration:86400000}") @@ -228,13 +228,8 @@ public class AuthService { } private SecretKey getSignKey() { + JwtSecretValidator.validate(jwtSecret); byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8); - // 确保密钥长度至少 32 字节(HMAC-SHA256) - if (keyBytes.length < 32) { - byte[] padded = new byte[32]; - System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length); - keyBytes = padded; - } return Keys.hmacShaKeyFor(keyBytes); } } diff --git a/mateclaw-server/src/main/java/vip/mate/auth/service/JwtSecretValidator.java b/mateclaw-server/src/main/java/vip/mate/auth/service/JwtSecretValidator.java new file mode 100644 index 00000000..590dd375 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/service/JwtSecretValidator.java @@ -0,0 +1,28 @@ +package vip.mate.auth.service; + +import java.nio.charset.StandardCharsets; +import java.util.Set; + +public final class JwtSecretValidator { + + private static final int MINIMUM_KEY_LENGTH_BYTES = 32; + private static final Set BUILT_IN_SECRETS = Set.of( + "MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production", + "MateClaw-Secret-Key-2024-Very-Long-String" + ); + + private JwtSecretValidator() { + } + + public static void validate(String secret) { + if (secret == null || secret.isBlank()) { + throw new IllegalArgumentException("JWT secret must be configured and non-blank"); + } + if (BUILT_IN_SECRETS.contains(secret.strip())) { + throw new IllegalArgumentException("JWT secret must not use a built-in value"); + } + if (secret.getBytes(StandardCharsets.UTF_8).length < MINIMUM_KEY_LENGTH_BYTES) { + throw new IllegalArgumentException("JWT secret must contain at least 32 UTF-8 bytes"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java index 6e5e7597..9391c15a 100644 --- a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java @@ -12,6 +12,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.dao.DuplicateKeyException; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import vip.mate.auth.service.JwtSecretValidator; import vip.mate.auth.sso.model.SsoStateEntity; import vip.mate.auth.sso.provider.SsoUserInfo; import vip.mate.auth.sso.repository.SsoStateMapper; @@ -59,7 +60,7 @@ public class SsoStateService { private final SsoStateMapper stateMapper; - @Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}") + @Value("${mateclaw.jwt.secret}") private String jwtSecret; // ==================== State (OAuth2 CSRF) ==================== @@ -206,13 +207,8 @@ public class SsoStateService { // ==================== helpers ==================== private SecretKey getSignKey() { + JwtSecretValidator.validate(jwtSecret); byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8); - // HMAC-SHA 需要 >= 256 bit (32 byte); 短 secret 用 0x00 填充到 32 byte (与 AuthService 一致) - if (keyBytes.length < 32) { - byte[] padded = new byte[32]; - System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length); - keyBytes = padded; - } return Keys.hmacShaKeyFor(keyBytes); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 08794503..eacfaf9f 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -102,7 +102,7 @@ public class WebChatController { * Server-only secret used to sign per-visitor tokens. Reuses the JWT secret so no extra * config/migration is needed; it is never sent to the client (unlike the public channel API key). */ - @Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}") + @Value("${mateclaw.jwt.secret}") private String visitorTokenSecret; /** diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityStartupValidator.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityStartupValidator.java index ec64e509..17acee1d 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/SecurityStartupValidator.java +++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityStartupValidator.java @@ -6,9 +6,10 @@ import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; +import vip.mate.auth.service.JwtSecretValidator; /** - * Startup security validator — warns about insecure default configurations. + * Startup security validator — rejects insecure JWT configuration and warns about other risks. * * @author MateClaw Team */ @@ -17,29 +18,24 @@ import org.springframework.stereotype.Component; @Order(1) public class SecurityStartupValidator implements ApplicationRunner { - private static final String DEFAULT_JWT_SECRET = "MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production"; - - @Value("${mateclaw.jwt.secret}") - private String jwtSecret; - @Value("${spring.h2.console.enabled:false}") private boolean h2ConsoleEnabled; @Value("${mateclaw.cors.allowed-origins:*}") private String corsOrigins; + public SecurityStartupValidator(@Value("${mateclaw.jwt.secret}") String jwtSecret) { + try { + JwtSecretValidator.validate(jwtSecret); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Invalid JWT secret configuration: " + e.getMessage(), e); + } + } + @Override public void run(ApplicationArguments args) { boolean hasWarnings = false; - if (DEFAULT_JWT_SECRET.equals(jwtSecret)) { - log.warn("╔══════════════════════════════════════════════════════════════╗"); - log.warn("║ SECURITY WARNING: Using default JWT secret! ║"); - log.warn("║ Set JWT_SECRET environment variable for production. ║"); - log.warn("╚══════════════════════════════════════════════════════════════╝"); - hasWarnings = true; - } - if (h2ConsoleEnabled) { log.warn("[Security] H2 Console is enabled at /h2-console. Set H2_CONSOLE_ENABLED=false in production."); hasWarnings = true; diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index c71bcc6a..cbafdbf6 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -186,7 +186,7 @@ mateclaw: # 生产数据库 profile(mysql/kingbase/postgres)默认覆盖为 false。 expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:true} jwt: - secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production} + secret: ${JWT_SECRET:} expiration: 86400000 sso: # 全局开关(默认关闭,不影响现有部署) diff --git a/mateclaw-server/src/main/resources/docs/en/config.md b/mateclaw-server/src/main/resources/docs/en/config.md index ad6993de..fd37bf65 100644 --- a/mateclaw-server/src/main/resources/docs/en/config.md +++ b/mateclaw-server/src/main/resources/docs/en/config.md @@ -175,12 +175,12 @@ Environment overrides: `MATECLAW_WORKSPACE_SANDBOX_ENABLED` / `MATECLAW_WORKSPAC ```yaml mateclaw: jwt: - secret: ${JWT_SECRET:your-secret-key-at-least-32-characters-long} + secret: ${JWT_SECRET:} expiration: 86400000 ``` ::: warning -Change the default JWT secret in production. Must be at least 32 characters. Use an environment variable; never commit it. +`JWT_SECRET` is required at startup in every environment. Set a unique random value of at least 32 UTF-8 bytes; generate one with `openssl rand -base64 48`. Never commit it. Rotating it invalidates existing tokens. ::: ### Skill workspace @@ -225,7 +225,7 @@ Provider, key, and model rows in `Settings → Models` are primary. `DASHSCOPE_A |----------|----------|---------| | `SERPER_API_KEY` | — | Google Serper search key (search tools not yet UI-managed) | | `TAVILY_API_KEY` | — | Tavily search key (same as above) | -| `JWT_SECRET` | — | JWT signing secret (recommended in production) | +| `JWT_SECRET` | Yes | JWT signing secret | | `MATECLAW_CORS_ALLOWED_ORIGINS` | — | CORS allowlist (recommended in production) | | `DB_PASSWORD` / `DB_ADMIN_PASSWORD` | Docker | PostgreSQL application / bootstrap-admin passwords (must differ) | | `DB_USERNAME` / `DB_ADMIN_USERNAME` | — | PostgreSQL application / bootstrap-admin usernames | @@ -237,14 +237,14 @@ Provider, key, and model rows in `Settings → Models` are primary. `DASHSCOPE_A **Linux / macOS:** ```bash -export JWT_SECRET=your-production-secret-at-least-32-chars +export JWT_SECRET="$(openssl rand -base64 48)" export SERPER_API_KEY=your-serper-key # optional ``` **Windows (PowerShell):** ```powershell -$env:JWT_SECRET = "your-production-secret-at-least-32-chars" +$env:JWT_SECRET = openssl rand -base64 48 ``` **Docker (`.env` file):** @@ -252,7 +252,8 @@ $env:JWT_SECRET = "your-production-secret-at-least-32-chars" ```properties DB_PASSWORD=secure-password-here DB_ADMIN_PASSWORD=different-secure-password-here -JWT_SECRET=your-production-secret-at-least-32-chars +# Generate with: openssl rand -base64 48 +JWT_SECRET=replace-with-generated-value ``` After startup, open `http://localhost:18080`, sign in as `admin / admin123`, and add your first LLM provider under `Settings → Models → Add Provider`. diff --git a/mateclaw-server/src/main/resources/docs/en/security.md b/mateclaw-server/src/main/resources/docs/en/security.md index 16dc5df3..52769a6e 100644 --- a/mateclaw-server/src/main/resources/docs/en/security.md +++ b/mateclaw-server/src/main/resources/docs/en/security.md @@ -80,7 +80,7 @@ mateclaw: ``` ::: warning -**Change the default JWT secret in production.** At least 32 characters. Set via env var (`JWT_SECRET=...`), never commit. +**`JWT_SECRET` is required at startup in every environment.** Set a unique random value of at least 32 UTF-8 bytes; generate one with `openssl rand -base64 48`. Never commit it. Rotating it invalidates existing tokens. ::: ### Error codes @@ -564,7 +564,7 @@ The browser tool also has a master switch `mateclaw.browser.ssrf-check-enabled` ## Security best practices 1. **Change the default password.** Right now. On every deployment. -2. **Set a real JWT secret.** At least 32 characters, via environment variable, never committed. +2. **Set a unique random JWT secret.** At least 32 UTF-8 bytes, generated with `openssl rand -base64 48`; never commit it. Rotating it invalidates existing tokens. 3. **Least privilege.** Only enable the tools agents actually need. 4. **Default to `require_approval`.** Flip the Tool Guard default policy, then add `allow` rules for safe cases. Newly added tools default to safe. 5. **Configure File Guard.** Lock down allowed/denied paths before any agent touches the filesystem in anger. @@ -582,7 +582,7 @@ application.yml carries **three** security-related blocks — JWT, the filesyste ```yaml mateclaw: jwt: - secret: ${JWT_SECRET:your-secret-key-at-least-32-chars} + secret: ${JWT_SECRET:} expiration: 86400000 # token lifetime (milliseconds) renewal-threshold: 7200000 # sliding renewal when remaining lifetime drops below this (ms) diff --git a/mateclaw-server/src/main/resources/docs/zh/config.md b/mateclaw-server/src/main/resources/docs/zh/config.md index fe7c38b7..48bd27e0 100644 --- a/mateclaw-server/src/main/resources/docs/zh/config.md +++ b/mateclaw-server/src/main/resources/docs/zh/config.md @@ -175,12 +175,12 @@ mateclaw: ```yaml mateclaw: jwt: - secret: ${JWT_SECRET:your-secret-key-at-least-32-characters-long} + secret: ${JWT_SECRET:} expiration: 86400000 ``` ::: warning -生产环境必须改默认 JWT secret。至少 32 字符。用环境变量,**不要 commit**。 +所有环境启动时都必须配置 `JWT_SECRET`。使用唯一的随机值,至少 32 个 UTF-8 字节;可用 `openssl rand -base64 48` 生成。不要 commit。更换密钥会使现有 token 失效。 ::: ### 技能工作空间 @@ -225,7 +225,7 @@ mate: |------|------|------| | `SERPER_API_KEY` | — | Google Serper 搜索 key(搜索工具暂未迁到 UI) | | `TAVILY_API_KEY` | — | Tavily 搜索 key(同上) | -| `JWT_SECRET` | — | JWT 签名密钥(生产推荐) | +| `JWT_SECRET` | 是 | JWT 签名密钥 | | `MATECLAW_CORS_ALLOWED_ORIGINS` | — | CORS 白名单(生产推荐) | | `DB_PASSWORD` / `DB_ADMIN_PASSWORD` | Docker | PostgreSQL 应用账号 / 引导管理员密码(必须不同) | | `DB_USERNAME` / `DB_ADMIN_USERNAME` | — | PostgreSQL 应用账号 / 引导管理员账号 | @@ -237,14 +237,14 @@ mate: **Linux / macOS:** ```bash -export JWT_SECRET=your-production-secret-at-least-32-chars +export JWT_SECRET="$(openssl rand -base64 48)" export SERPER_API_KEY=your-serper-key # 可选 ``` **Windows(PowerShell):** ```powershell -$env:JWT_SECRET = "your-production-secret-at-least-32-chars" +$env:JWT_SECRET = openssl rand -base64 48 ``` **Docker(`.env` 文件):** @@ -252,7 +252,8 @@ $env:JWT_SECRET = "your-production-secret-at-least-32-chars" ```properties DB_PASSWORD=secure-password-here DB_ADMIN_PASSWORD=different-secure-password-here -JWT_SECRET=your-production-secret-at-least-32-chars +# 生成命令:openssl rand -base64 48 +JWT_SECRET=replace-with-generated-value ``` 启动后到 `http://localhost:18080`,`admin / admin123` 登录,「设置 → 模型 → 添加供应商」配第一家 LLM。 diff --git a/mateclaw-server/src/main/resources/docs/zh/security.md b/mateclaw-server/src/main/resources/docs/zh/security.md index a894f87e..0958fbda 100644 --- a/mateclaw-server/src/main/resources/docs/zh/security.md +++ b/mateclaw-server/src/main/resources/docs/zh/security.md @@ -80,7 +80,7 @@ mateclaw: ``` ::: warning -**生产环境必须改默认 JWT secret。** 至少 32 字符。用环境变量(`JWT_SECRET=...`)设置,**不要 commit**。 +**所有环境启动时都必须配置 `JWT_SECRET`。** 使用唯一的随机值,至少 32 个 UTF-8 字节;可用 `openssl rand -base64 48` 生成。不要 commit。更换密钥会使现有 token 失效。 ::: ### 错误码 @@ -564,7 +564,7 @@ mateclaw: ## 安全最佳实践 1. **改默认密码。** 现在就改。每一个部署都改。 -2. **设一个真正的 JWT secret。** 至少 32 字符,通过环境变量,永远不要 commit。 +2. **设置唯一的随机 JWT secret。** 至少 32 个 UTF-8 字节,用 `openssl rand -base64 48` 生成,永远不要 commit。更换密钥会使现有 token 失效。 3. **最小权限。** 只启用 Agent 真的需要的工具。 4. **默认 `require_approval`。** 把 Tool Guard 的 `default-policy` 翻成 `require_approval`,然后为安全场景加 `allow` 规则。**新加的工具默认安全**。 5. **配好 File Guard。** 在任何 Agent 真的碰文件系统之前把 allowed/denied 路径锁死。 @@ -582,7 +582,7 @@ application.yml 里有**三块**安全相关配置——JWT、文件沙箱,以 ```yaml mateclaw: jwt: - secret: ${JWT_SECRET:your-secret-key-at-least-32-chars} + secret: ${JWT_SECRET:} expiration: 86400000 # token 有效期(毫秒) renewal-threshold: 7200000 # 剩余有效期低于此值时滑动续期(毫秒) diff --git a/mateclaw-server/src/test/java/vip/mate/auth/service/AuthServiceJwtSecretTest.java b/mateclaw-server/src/test/java/vip/mate/auth/service/AuthServiceJwtSecretTest.java new file mode 100644 index 00000000..3c355028 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/auth/service/AuthServiceJwtSecretTest.java @@ -0,0 +1,93 @@ +package vip.mate.auth.service; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.repository.UserMapper; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +class AuthServiceJwtSecretTest { + + private static final String ROTATED_SECRET = "0123456789abcdef0123456789abcdef"; + + @Test + void rejectsTokensSignedWithThePreviouslyPaddedEmptySecretAfterRotation() { + AuthService authService = authServiceWithSecret(ROTATED_SECRET); + String token = forgeAdminToken(new byte[32]); + + assertNull(authService.parseClaims(token)); + } + + @Test + void rejectsTokensSignedWithThePreviousApplicationDefaultAfterRotation() { + String previousSecret = "MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production"; + AuthService authService = authServiceWithSecret(ROTATED_SECRET); + + assertNull(authService.parseClaims(forgeAdminToken(previousSecret.getBytes(StandardCharsets.UTF_8)))); + } + + @Test + void rejectsTokensSignedWithThePreviousServiceFallbackAfterRotation() { + String previousSecret = "MateClaw-Secret-Key-2024-Very-Long-String"; + AuthService authService = authServiceWithSecret(ROTATED_SECRET); + + assertNull(authService.parseClaims(forgeAdminToken(previousSecret.getBytes(StandardCharsets.UTF_8)))); + } + + @Test + void doesNotPadShortSecretsWhenSigningTokens() { + AuthService authService = authServiceWithSecret("too-short"); + UserEntity user = adminUser(); + + assertThrows(IllegalArgumentException.class, () -> authService.generateToken(user)); + } + + @Test + void signsAndParsesTokensWithAConfiguredSecret() { + AuthService authService = authServiceWithSecret(ROTATED_SECRET); + UserEntity user = adminUser(); + + var claims = authService.parseClaims(authService.generateToken(user)); + + assertNotNull(claims); + assertEquals("admin", claims.getSubject()); + assertEquals(1L, claims.get("userId", Long.class)); + } + + private static AuthService authServiceWithSecret(String secret) { + AuthService authService = new AuthService(mock(UserMapper.class), mock(BCryptPasswordEncoder.class)); + ReflectionTestUtils.setField(authService, "jwtSecret", secret); + ReflectionTestUtils.setField(authService, "jwtExpiration", 86_400_000L); + return authService; + } + + private static UserEntity adminUser() { + UserEntity user = new UserEntity(); + user.setId(1L); + user.setUsername("admin"); + user.setRole("admin"); + return user; + } + + private static String forgeAdminToken(byte[] secret) { + long now = System.currentTimeMillis(); + return Jwts.builder() + .subject("admin") + .claim("userId", 1L) + .issuedAt(new Date(now)) + .expiration(new Date(now + 86_400_000L)) + .signWith(Keys.hmacShaKeyFor(secret)) + .compact(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java b/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java index b98c57e2..7b36d811 100644 --- a/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java @@ -141,6 +141,19 @@ class SsoStateServiceTest { assertThat(token.split("\\.")).hasSize(3); } + @Test + @DisplayName("issueBindToken rejects a short JWT secret") + void issueBindTokenRejectsShortJwtSecret() throws Exception { + Field jwtSecret = SsoStateService.class.getDeclaredField("jwtSecret"); + jwtSecret.setAccessible(true); + jwtSecret.set(service, "short-secret"); + SsoUserInfo info = new SsoUserInfo("ou_123", null, "Test User", null, null, null); + + assertThatThrownBy(() -> service.issueBindToken("feishu", info)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("32 UTF-8 bytes"); + } + @Test @DisplayName("verifyBindToken succeeds on first consume, inserts jti into sso_state") void verifyBindTokenSucceedsOnFirstConsume() { diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java index 8bc946d9..12f8de7a 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java @@ -46,12 +46,12 @@ import static org.mockito.ArgumentMatchers.eq; "spring.datasource.url=jdbc:h2:mem:webchat_approve_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789", + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef", "mateclaw.webchat.orphan-grace-sec=-1" }) class WebChatApprovalInteractionTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" private static final long CHANNEL_ID = 9_147_310L; private static final long AGENT_ID = 9_147_3101L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java index dad31bdc..762e6bea 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java @@ -32,11 +32,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_pinarch_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatArchivePinTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; private static final long CHANNEL_ID = 9_147_501L; private static final long AGENT_ID = 9_147_5011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java index 85c1356c..7977757c 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java @@ -66,12 +66,12 @@ import static org.mockito.ArgumentMatchers.isNull; @TestPropertySource(properties = { "spring.datasource.url=jdbc:h2:mem:webchat_att_e2e_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", - "mateclaw.jwt.secret=webchat-it-secret-0123456789", + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef", "mateclaw.feature-flag.refresh-ms=999999" }) class WebChatAttachmentE2ETest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1atte2e01"; // key8 = "testkey1" private static final long CHANNEL_ID = 9_148_101L; private static final long AGENT_ID = 9_148_1011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java index fea9513c..de26cdbe 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java @@ -31,11 +31,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_audit_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatAuditTrailTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; private static final long CHANNEL_ID = 9_147_701L; private static final long AGENT_ID = 9_147_7011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java index 03b568c8..09e8bb5b 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java @@ -40,11 +40,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_create_sess_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatCreateSessionTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" private static final long CHANNEL_ID = 9_147_101L; private static final long AGENT_ID = 9_147_1011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java index fa163957..5a9762af 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java @@ -34,11 +34,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_regen_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatRegenerateTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; private static final long CHANNEL_ID = 9_147_601L; private static final long AGENT_ID = 9_147_6011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java index c27856db..b66f5ea3 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java @@ -40,11 +40,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_schema_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatSchemaFieldsTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; private static final long CHANNEL_ID = 9_147_301L; private static final long AGENT_ID = 9_147_3011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java index 80210a3c..1df1e761 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java @@ -33,11 +33,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_sess_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatSessionManagementTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" private static final String VISITOR = "visitorAAAA"; private static final long CHANNEL_ID = 9_147_001L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java index d552c2bf..97c1d1a2 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java @@ -30,11 +30,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_skills_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatSkillListTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; private static final long CHANNEL_ID = 9_300_001L; private static final long AGENT_ID = 9_300_011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java index f2561a88..bf019e28 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java @@ -37,11 +37,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_stop_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatStopStreamTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" private static final long CHANNEL_ID = 9_147_201L; private static final long AGENT_ID = 9_147_2011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java index fba86a24..ca980b53 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java @@ -79,14 +79,14 @@ import static org.mockito.ArgumentMatchers.isNull; @TestPropertySource(properties = { "spring.datasource.url=jdbc:h2:mem:webchat_stream_e2e_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", - "mateclaw.jwt.secret=webchat-it-secret-0123456789", + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef", "mateclaw.webchat.orphan-grace-sec=-1", "mateclaw.feature-flag.refresh-ms=999999" }) class WebChatStreamE2ETest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1e2etest01"; // key8 = "testkey1" private static final long CHANNEL_ID = 9_148_001L; private static final long AGENT_ID = 9_148_0011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java index 88a531e9..d9a91160 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java @@ -38,11 +38,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_revoke_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatTokenRevocationTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; private static final long CHANNEL_ID = 9_147_401L; private static final long AGENT_ID = 9_147_4011L; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java index b7927e4b..6508671f 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java @@ -31,11 +31,11 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:webchat_wiki_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=webchat-it-secret-0123456789" + "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef" }) class WebChatWikiPageListTest { - private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String SECRET = "webchat-it-secret-0123456789abcdef"; private static final String API_KEY = "testkey1abcdefgh"; private static final long CHANNEL_ID = 9_400_001L; private static final long AGENT_ID = 9_400_011L; diff --git a/mateclaw-server/src/test/java/vip/mate/config/SecurityStartupValidatorTest.java b/mateclaw-server/src/test/java/vip/mate/config/SecurityStartupValidatorTest.java new file mode 100644 index 00000000..c1bb5276 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/config/SecurityStartupValidatorTest.java @@ -0,0 +1,46 @@ +package vip.mate.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SecurityStartupValidatorTest { + + @ParameterizedTest(name = "rejects {0} JWT secrets") + @MethodSource("invalidSecrets") + void rejectsInvalidJwtSecretsAtStartup(String description, String secret) { + assertThrows(IllegalStateException.class, () -> new SecurityStartupValidator(secret)); + } + + @Test + void acceptsAConfiguredHmacSha256Secret() { + SecurityStartupValidator validator = validatorWithSecret("0123456789abcdef0123456789abcdef"); + + assertDoesNotThrow(() -> validator.run(new DefaultApplicationArguments(new String[0]))); + } + + private static Stream invalidSecrets() { + return Stream.of( + Arguments.of("missing", null), + Arguments.of("empty", ""), + Arguments.of("blank", " \t\n"), + Arguments.of("short", "0123456789abcdef0123456789abcde"), + Arguments.of("built-in application.yml", "MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production"), + Arguments.of("built-in AuthService fallback", "MateClaw-Secret-Key-2024-Very-Long-String") + ); + } + + private static SecurityStartupValidator validatorWithSecret(String secret) { + SecurityStartupValidator validator = new SecurityStartupValidator(secret); + ReflectionTestUtils.setField(validator, "corsOrigins", "http://localhost:3000"); + return validator; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/search/SessionSearchIsolationTest.java b/mateclaw-server/src/test/java/vip/mate/memory/search/SessionSearchIsolationTest.java index 74f0d576..6a6bdf80 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/search/SessionSearchIsolationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/search/SessionSearchIsolationTest.java @@ -31,7 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.datasource.url=jdbc:h2:mem:session_search_iso_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", - "mateclaw.jwt.secret=session-search-iso-secret-0123456789" + "mateclaw.jwt.secret=session-search-iso-secret-0123456789abcdef" }) class SessionSearchIsolationTest { diff --git a/mateclaw-server/src/test/resources/application.properties b/mateclaw-server/src/test/resources/application.properties new file mode 100644 index 00000000..fea54578 --- /dev/null +++ b/mateclaw-server/src/test/resources/application.properties @@ -0,0 +1 @@ +mateclaw.jwt.secret=mateclaw-test-only-jwt-secret-32-bytes