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.
This commit is contained in:
mikemikimike 2026-10-19 17:26:06 +08:00 committed by GitHub
parent 614740459a
commit 576eb8055a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 329 additions and 78 deletions

View File

@ -36,8 +36,8 @@ TAVILY_API_KEY=
# ==================== 安全(强烈建议覆盖) ==================== # ==================== 安全(强烈建议覆盖) ====================
# JWT 签名密钥。若留空,服务器会用内置默认值并在启动日志里 WARN。 # 必须设置唯一的随机 JWT 签名密钥:openssl rand -base64 48。
# 生产部署必须设置,至少 32 位随机字符串:openssl rand -base64 48 # 密钥至少 32 个 UTF-8 字节;更换密钥会使现有登录 token 失效。
JWT_SECRET= JWT_SECRET=
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com)。 # CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com)。

View File

@ -107,7 +107,7 @@ services:
# Keys are stored in mate_model_provider and hot-reloaded. # Keys are stored in mate_model_provider and hot-reloaded.
SERPER_API_KEY: ${SERPER_API_KEY:-} SERPER_API_KEY: ${SERPER_API_KEY:-}
TAVILY_API_KEY: ${TAVILY_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:-} MATECLAW_CORS_ALLOWED_ORIGINS: ${MATECLAW_CORS_ALLOWED_ORIGINS:-}
# SearXNG: tell the app where to reach the sidecar container # SearXNG: tell the app where to reach the sidecar container
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-http://searxng:8080} SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-http://searxng:8080}

View File

@ -20,6 +20,7 @@ import {
expandPath, expandPath,
type LocalToolsConfig, type LocalToolsConfig,
} from './localToolsConfig' } from './localToolsConfig'
import { getLocalJwtSecret } from './jwtSecret.mjs'
import { LocalBridge } from './localBridge' import { LocalBridge } from './localBridge'
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
@ -227,6 +228,7 @@ async function startJavaBackend(): Promise<void> {
// Prepare environment variables — inherit current env + override // Prepare environment variables — inherit current env + override
const env = { const env = {
...process.env, ...process.env,
JWT_SECRET: getLocalJwtSecret(app.getPath('userData'), process.env.JWT_SECRET),
// Ensure H2 database is stored in userData // 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`, SPRING_DATASOURCE_URL: `jdbc:h2:file:${join(workingDir, 'data', 'mateclaw')};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE`,
} }

View File

@ -0,0 +1 @@
export function getLocalJwtSecret(userDataPath: string, explicitSecret?: string): string

View File

@ -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)
}
}

View File

@ -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)
})

View File

@ -33,7 +33,7 @@ public class AuthService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final BCryptPasswordEncoder passwordEncoder; private final BCryptPasswordEncoder passwordEncoder;
@Value("${mateclaw.jwt.secret:MateClaw-Secret-Key-2024-Very-Long-String}") @Value("${mateclaw.jwt.secret}")
private String jwtSecret; private String jwtSecret;
@Value("${mateclaw.jwt.expiration:86400000}") @Value("${mateclaw.jwt.expiration:86400000}")
@ -228,13 +228,8 @@ public class AuthService {
} }
private SecretKey getSignKey() { private SecretKey getSignKey() {
JwtSecretValidator.validate(jwtSecret);
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8); 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); return Keys.hmacShaKeyFor(keyBytes);
} }
} }

View File

@ -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<String> 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");
}
}
}

View File

@ -12,6 +12,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import vip.mate.auth.service.JwtSecretValidator;
import vip.mate.auth.sso.model.SsoStateEntity; import vip.mate.auth.sso.model.SsoStateEntity;
import vip.mate.auth.sso.provider.SsoUserInfo; import vip.mate.auth.sso.provider.SsoUserInfo;
import vip.mate.auth.sso.repository.SsoStateMapper; import vip.mate.auth.sso.repository.SsoStateMapper;
@ -59,7 +60,7 @@ public class SsoStateService {
private final SsoStateMapper stateMapper; private final SsoStateMapper stateMapper;
@Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}") @Value("${mateclaw.jwt.secret}")
private String jwtSecret; private String jwtSecret;
// ==================== State (OAuth2 CSRF) ==================== // ==================== State (OAuth2 CSRF) ====================
@ -206,13 +207,8 @@ public class SsoStateService {
// ==================== helpers ==================== // ==================== helpers ====================
private SecretKey getSignKey() { private SecretKey getSignKey() {
JwtSecretValidator.validate(jwtSecret);
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8); 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); return Keys.hmacShaKeyFor(keyBytes);
} }

View File

@ -102,7 +102,7 @@ public class WebChatController {
* Server-only secret used to sign per-visitor tokens. Reuses the JWT secret so no extra * 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). * 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; private String visitorTokenSecret;
/** /**

View File

@ -6,9 +6,10 @@ import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component; 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 * @author MateClaw Team
*/ */
@ -17,29 +18,24 @@ import org.springframework.stereotype.Component;
@Order(1) @Order(1)
public class SecurityStartupValidator implements ApplicationRunner { 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}") @Value("${spring.h2.console.enabled:false}")
private boolean h2ConsoleEnabled; private boolean h2ConsoleEnabled;
@Value("${mateclaw.cors.allowed-origins:*}") @Value("${mateclaw.cors.allowed-origins:*}")
private String corsOrigins; 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 @Override
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
boolean hasWarnings = false; 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) { if (h2ConsoleEnabled) {
log.warn("[Security] H2 Console is enabled at /h2-console. Set H2_CONSOLE_ENABLED=false in production."); log.warn("[Security] H2 Console is enabled at /h2-console. Set H2_CONSOLE_ENABLED=false in production.");
hasWarnings = true; hasWarnings = true;

View File

@ -186,7 +186,7 @@ mateclaw:
# 生产数据库 profile(mysql/kingbase/postgres)默认覆盖为 false。 # 生产数据库 profile(mysql/kingbase/postgres)默认覆盖为 false。
expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:true} expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:true}
jwt: jwt:
secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production} secret: ${JWT_SECRET:}
expiration: 86400000 expiration: 86400000
sso: sso:
# 全局开关(默认关闭,不影响现有部署) # 全局开关(默认关闭,不影响现有部署)

View File

@ -175,12 +175,12 @@ Environment overrides: `MATECLAW_WORKSPACE_SANDBOX_ENABLED` / `MATECLAW_WORKSPAC
```yaml ```yaml
mateclaw: mateclaw:
jwt: jwt:
secret: ${JWT_SECRET:your-secret-key-at-least-32-characters-long} secret: ${JWT_SECRET:}
expiration: 86400000 expiration: 86400000
``` ```
::: warning ::: 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 ### 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) | | `SERPER_API_KEY` | — | Google Serper search key (search tools not yet UI-managed) |
| `TAVILY_API_KEY` | — | Tavily search key (same as above) | | `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) | | `MATECLAW_CORS_ALLOWED_ORIGINS` | — | CORS allowlist (recommended in production) |
| `DB_PASSWORD` / `DB_ADMIN_PASSWORD` | Docker | PostgreSQL application / bootstrap-admin passwords (must differ) | | `DB_PASSWORD` / `DB_ADMIN_PASSWORD` | Docker | PostgreSQL application / bootstrap-admin passwords (must differ) |
| `DB_USERNAME` / `DB_ADMIN_USERNAME` | — | PostgreSQL application / bootstrap-admin usernames | | `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:** **Linux / macOS:**
```bash ```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 export SERPER_API_KEY=your-serper-key # optional
``` ```
**Windows (PowerShell):** **Windows (PowerShell):**
```powershell ```powershell
$env:JWT_SECRET = "your-production-secret-at-least-32-chars" $env:JWT_SECRET = openssl rand -base64 48
``` ```
**Docker (`.env` file):** **Docker (`.env` file):**
@ -252,7 +252,8 @@ $env:JWT_SECRET = "your-production-secret-at-least-32-chars"
```properties ```properties
DB_PASSWORD=secure-password-here DB_PASSWORD=secure-password-here
DB_ADMIN_PASSWORD=different-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`. After startup, open `http://localhost:18080`, sign in as `admin / admin123`, and add your first LLM provider under `Settings → Models → Add Provider`.

View File

@ -80,7 +80,7 @@ mateclaw:
``` ```
::: warning ::: 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 ### Error codes
@ -564,7 +564,7 @@ The browser tool also has a master switch `mateclaw.browser.ssrf-check-enabled`
## Security best practices ## Security best practices
1. **Change the default password.** Right now. On every deployment. 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. 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. 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. 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 ```yaml
mateclaw: mateclaw:
jwt: jwt:
secret: ${JWT_SECRET:your-secret-key-at-least-32-chars} secret: ${JWT_SECRET:}
expiration: 86400000 # token lifetime (milliseconds) expiration: 86400000 # token lifetime (milliseconds)
renewal-threshold: 7200000 # sliding renewal when remaining lifetime drops below this (ms) renewal-threshold: 7200000 # sliding renewal when remaining lifetime drops below this (ms)

View File

@ -175,12 +175,12 @@ mateclaw:
```yaml ```yaml
mateclaw: mateclaw:
jwt: jwt:
secret: ${JWT_SECRET:your-secret-key-at-least-32-characters-long} secret: ${JWT_SECRET:}
expiration: 86400000 expiration: 86400000
``` ```
::: warning ::: 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) | | `SERPER_API_KEY` | — | Google Serper 搜索 key(搜索工具暂未迁到 UI) |
| `TAVILY_API_KEY` | — | Tavily 搜索 key(同上) | | `TAVILY_API_KEY` | — | Tavily 搜索 key(同上) |
| `JWT_SECRET` | — | JWT 签名密钥(生产推荐) | | `JWT_SECRET` | 是 | JWT 签名密钥 |
| `MATECLAW_CORS_ALLOWED_ORIGINS` | — | CORS 白名单(生产推荐) | | `MATECLAW_CORS_ALLOWED_ORIGINS` | — | CORS 白名单(生产推荐) |
| `DB_PASSWORD` / `DB_ADMIN_PASSWORD` | Docker | PostgreSQL 应用账号 / 引导管理员密码(必须不同) | | `DB_PASSWORD` / `DB_ADMIN_PASSWORD` | Docker | PostgreSQL 应用账号 / 引导管理员密码(必须不同) |
| `DB_USERNAME` / `DB_ADMIN_USERNAME` | — | PostgreSQL 应用账号 / 引导管理员账号 | | `DB_USERNAME` / `DB_ADMIN_USERNAME` | — | PostgreSQL 应用账号 / 引导管理员账号 |
@ -237,14 +237,14 @@ mate:
**Linux / macOS:** **Linux / macOS:**
```bash ```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 # 可选 export SERPER_API_KEY=your-serper-key # 可选
``` ```
**Windows(PowerShell):** **Windows(PowerShell):**
```powershell ```powershell
$env:JWT_SECRET = "your-production-secret-at-least-32-chars" $env:JWT_SECRET = openssl rand -base64 48
``` ```
**Docker(`.env` 文件):** **Docker(`.env` 文件):**
@ -252,7 +252,8 @@ $env:JWT_SECRET = "your-production-secret-at-least-32-chars"
```properties ```properties
DB_PASSWORD=secure-password-here DB_PASSWORD=secure-password-here
DB_ADMIN_PASSWORD=different-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。 启动后到 `http://localhost:18080`,`admin / admin123` 登录,「设置 → 模型 → 添加供应商」配第一家 LLM。

View File

@ -80,7 +80,7 @@ mateclaw:
``` ```
::: warning ::: warning
**生产环境必须改默认 JWT secret。** 至少 32 字符。用环境变量(`JWT_SECRET=...`)设置,**不要 commit**。 **所有环境启动时都必须配置 `JWT_SECRET`。** 使用唯一的随机值,至少 32 个 UTF-8 字节;可用 `openssl rand -base64 48` 生成。不要 commit。更换密钥会使现有 token 失效。
::: :::
### 错误码 ### 错误码
@ -564,7 +564,7 @@ mateclaw:
## 安全最佳实践 ## 安全最佳实践
1. **改默认密码。** 现在就改。每一个部署都改。 1. **改默认密码。** 现在就改。每一个部署都改。
2. **设一个真正的 JWT secret。** 至少 32 字符,通过环境变量,永远不要 commit。 2. **设置唯一的随机 JWT secret。** 至少 32 个 UTF-8 字节,用 `openssl rand -base64 48` 生成,永远不要 commit。更换密钥会使现有 token 失效。
3. **最小权限。** 只启用 Agent 真的需要的工具。 3. **最小权限。** 只启用 Agent 真的需要的工具。
4. **默认 `require_approval`。** 把 Tool Guard 的 `default-policy` 翻成 `require_approval`,然后为安全场景加 `allow` 规则。**新加的工具默认安全**。 4. **默认 `require_approval`。** 把 Tool Guard 的 `default-policy` 翻成 `require_approval`,然后为安全场景加 `allow` 规则。**新加的工具默认安全**。
5. **配好 File Guard。** 在任何 Agent 真的碰文件系统之前把 allowed/denied 路径锁死。 5. **配好 File Guard。** 在任何 Agent 真的碰文件系统之前把 allowed/denied 路径锁死。
@ -582,7 +582,7 @@ application.yml 里有**三块**安全相关配置——JWT、文件沙箱,以
```yaml ```yaml
mateclaw: mateclaw:
jwt: jwt:
secret: ${JWT_SECRET:your-secret-key-at-least-32-chars} secret: ${JWT_SECRET:}
expiration: 86400000 # token 有效期(毫秒) expiration: 86400000 # token 有效期(毫秒)
renewal-threshold: 7200000 # 剩余有效期低于此值时滑动续期(毫秒) renewal-threshold: 7200000 # 剩余有效期低于此值时滑动续期(毫秒)

View File

@ -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();
}
}

View File

@ -141,6 +141,19 @@ class SsoStateServiceTest {
assertThat(token.split("\\.")).hasSize(3); 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 @Test
@DisplayName("verifyBindToken succeeds on first consume, inserts jti into sso_state") @DisplayName("verifyBindToken succeeds on first consume, inserts jti into sso_state")
void verifyBindTokenSucceedsOnFirstConsume() { void verifyBindTokenSucceedsOnFirstConsume() {

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "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" "mateclaw.webchat.orphan-grace-sec=-1"
}) })
class WebChatApprovalInteractionTest { 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 String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1"
private static final long CHANNEL_ID = 9_147_310L; private static final long CHANNEL_ID = 9_147_310L;
private static final long AGENT_ID = 9_147_3101L; private static final long AGENT_ID = 9_147_3101L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatArchivePinTest { 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 String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_147_501L; private static final long CHANNEL_ID = 9_147_501L;
private static final long AGENT_ID = 9_147_5011L; private static final long AGENT_ID = 9_147_5011L;

View File

@ -66,12 +66,12 @@ import static org.mockito.ArgumentMatchers.isNull;
@TestPropertySource(properties = { @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.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", "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" "mateclaw.feature-flag.refresh-ms=999999"
}) })
class WebChatAttachmentE2ETest { 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 String API_KEY = "testkey1atte2e01"; // key8 = "testkey1"
private static final long CHANNEL_ID = 9_148_101L; private static final long CHANNEL_ID = 9_148_101L;
private static final long AGENT_ID = 9_148_1011L; private static final long AGENT_ID = 9_148_1011L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatAuditTrailTest { 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 String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_147_701L; private static final long CHANNEL_ID = 9_147_701L;
private static final long AGENT_ID = 9_147_7011L; private static final long AGENT_ID = 9_147_7011L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatCreateSessionTest { 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 String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1"
private static final long CHANNEL_ID = 9_147_101L; private static final long CHANNEL_ID = 9_147_101L;
private static final long AGENT_ID = 9_147_1011L; private static final long AGENT_ID = 9_147_1011L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatRegenerateTest { 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 String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_147_601L; private static final long CHANNEL_ID = 9_147_601L;
private static final long AGENT_ID = 9_147_6011L; private static final long AGENT_ID = 9_147_6011L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatSchemaFieldsTest { 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 String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_147_301L; private static final long CHANNEL_ID = 9_147_301L;
private static final long AGENT_ID = 9_147_3011L; private static final long AGENT_ID = 9_147_3011L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatSessionManagementTest { 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 API_KEY = "testkey1abcdefgh"; // key8 = "testkey1"
private static final String VISITOR = "visitorAAAA"; private static final String VISITOR = "visitorAAAA";
private static final long CHANNEL_ID = 9_147_001L; private static final long CHANNEL_ID = 9_147_001L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatSkillListTest { 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 String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_300_001L; private static final long CHANNEL_ID = 9_300_001L;
private static final long AGENT_ID = 9_300_011L; private static final long AGENT_ID = 9_300_011L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatStopStreamTest { 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 String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1"
private static final long CHANNEL_ID = 9_147_201L; private static final long CHANNEL_ID = 9_147_201L;
private static final long AGENT_ID = 9_147_2011L; private static final long AGENT_ID = 9_147_2011L;

View File

@ -79,14 +79,14 @@ import static org.mockito.ArgumentMatchers.isNull;
@TestPropertySource(properties = { @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.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", "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.webchat.orphan-grace-sec=-1",
"mateclaw.feature-flag.refresh-ms=999999" "mateclaw.feature-flag.refresh-ms=999999"
}) })
class WebChatStreamE2ETest { class WebChatStreamE2ETest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 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 String API_KEY = "testkey1e2etest01"; // key8 = "testkey1"
private static final long CHANNEL_ID = 9_148_001L; private static final long CHANNEL_ID = 9_148_001L;
private static final long AGENT_ID = 9_148_0011L; private static final long AGENT_ID = 9_148_0011L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatTokenRevocationTest { 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 String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_147_401L; private static final long CHANNEL_ID = 9_147_401L;
private static final long AGENT_ID = 9_147_4011L; private static final long AGENT_ID = 9_147_4011L;

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789" "mateclaw.jwt.secret=webchat-it-secret-0123456789abcdef"
}) })
class WebChatWikiPageListTest { 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 String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_400_001L; private static final long CHANNEL_ID = 9_400_001L;
private static final long AGENT_ID = 9_400_011L; private static final long AGENT_ID = 9_400_011L;

View File

@ -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<Arguments> 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;
}
}

View File

@ -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.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.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none", "spring.main.web-application-type=none",
"mateclaw.jwt.secret=session-search-iso-secret-0123456789" "mateclaw.jwt.secret=session-search-iso-secret-0123456789abcdef"
}) })
class SessionSearchIsolationTest { class SessionSearchIsolationTest {

View File

@ -0,0 +1 @@
mateclaw.jwt.secret=mateclaw-test-only-jwt-secret-32-bytes