mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
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:
parent
614740459a
commit
576eb8055a
@ -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)。
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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<void> {
|
||||
// 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`,
|
||||
}
|
||||
|
||||
1
mateclaw-desktop/electron/main/jwtSecret.d.mts
Normal file
1
mateclaw-desktop/electron/main/jwtSecret.d.mts
Normal file
@ -0,0 +1 @@
|
||||
export function getLocalJwtSecret(userDataPath: string, explicitSecret?: string): string
|
||||
35
mateclaw-desktop/electron/main/jwtSecret.mjs
Normal file
35
mateclaw-desktop/electron/main/jwtSecret.mjs
Normal 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)
|
||||
}
|
||||
}
|
||||
43
mateclaw-desktop/scripts/__tests__/jwt-secret.test.mjs
Normal file
43
mateclaw-desktop/scripts/__tests__/jwt-secret.test.mjs
Normal 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)
|
||||
})
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
# 全局开关(默认关闭,不影响现有部署)
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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 # 剩余有效期低于此值时滑动续期(毫秒)
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -0,0 +1 @@
|
||||
mateclaw.jwt.secret=mateclaw-test-only-jwt-secret-32-bytes
|
||||
Loading…
Reference in New Issue
Block a user