# 鉴权与加密图（Authentication）

## 1. JWT Token

- vjmap 后端用 **标准 HS256 JWT** 做鉴权
- Claims 里包含 `ID` / `Username` / `NickName` / `AuthorityId` / `BufferTime` / `nbf` / `exp` / `iss=vjmap`
- 过期：`exp = now + expires_time`（config.json `expires_time`，默认 **3153600000 秒 ≈ 100 年**）。`BufferTime`（config `buffer_time`，默认 86400）只是写进 claims，鉴权中间件**从不检查**——服务端**没有自动刷新/换新 token 的机制**，过期就得换新 token
- 生产环境拿 token 的方式：通过业务登录接口；开发环境可直接从 vjmap 后台复制（本地服务在 `<server>/data/auth/token/*.token`）

### 1.1 Token 放哪里

两种位置，**优先级从高到低**（只有两级回退，没有 form）：

1. HTTP Header：`token: eyJ...`
2. Query：`?token=eyJ...`

REST 接口**不认** `Authorization: Bearer ...`——只有 MCP 路由在自己的中间件链里把 Bearer 转成 `token` 头（见 §7）。

SDK 实际上**两种都带**：URL 构造方法（`map_url`/`cmd_url`/`service_url` 等）把 `token` 拼进 query（便于 curl 排障），`_request` 同时自动附带 `token` header——反代剥 query 也能工作，无需任何开关。

### 1.2 Token 的 5 分钟内测

```bash
TOKEN="eyJhbG..."
curl -s "http://127.0.0.1:27660/version?token=$TOKEN"
curl -s "http://127.0.0.1:27660/api/v1/map/cmd/runstatus/_/v1?token=$TOKEN&detail=true"
```

### 1.3 常见错误

网关鉴权失败统一 `FAIL=7` + **HTTP 403**（`{code:8}` + HTTP 500 为服务端错误），`msg` 区分原因：

| `msg` | 原因 |
|------|------|
| `Token is invalid` | **没带 token**（header 与 query 都为空），`data:{reload:true}` |
| `Couldn't handle this token:` | 签名不匹配（换了 signing_key / 被篡改）等其它校验失败 |
| `That's not even a token` | 不是 JWT 格式 |
| `Token expired` | 已过期 |
| `Token not active yet` | `nbf` 未到 |
| `权限不足` | token 合法，但角色被 casbin 策略拒绝（见 §1.4），`data:{}` |

`HTTP 200` 但业务字段异常 → token OK，但参数错误。

> ⚠️ 注记：系统根路径 `/heart`、`/version` 是**公共端点不过 JWT 鉴权**——坏 token 请求它们也返回 200。验证鉴权行为要用 `/api/v1/...` 受保护端点（如 `/api/v1/map/cmd/runstatus/_/v1`）：坏 token → `403 + {code:7}`，无 token → `403`；合法 token 放 query 单通道即可通过。

### 1.4 角色权限（casbin 默认策略）

角色取 claims 的 `AuthorityId`（内置用户 root1/admin1/guest1 → root/admin/guest）。策略文件 `data/auth/policy.conf` 首次启动生成，按请求 URI 做 `regexMatch`（子串正则）：

| 角色 | 默认拒绝 |
|------|---------|
| root | 无（全放行） |
| admin | `/api/v1/service/workspace*`（工作区 create/modify/delete/status，`getWorkspace` 不受影响）、`/api/v1/map/cmd/slicelayer/*`、对 `sys_*` 图的 `updatemap` / `renamemap` / `deletemap` / `deletestyle`、`/*RegCode`、`/client_*` |
| guest | 整个 `/api/v1/*`，以及 `/*RegCode`、`/client_*` |

被拒返回 HTTP 403 `{code:7, msg:"权限不足"}`。

## 2. 加密图（secretKey / accessKey）

### 2.1 设密：只在 v1 首次 openmap 时

- 上传接口 `/map/uploads` 只取 multipart 里的文件，**不读任何表单字段**——上传时传 `secretKey` 无效
- 唯一入口：该 mapid **首次** `openmap` 且版本为 `v1`（mapinfos ini 尚不存在）时，请求参数里的 `secretKey` 若**长度 ≥ 10** → 服务端**原样**存为该图 secretKey（不做 md5），同时生成随机 `salt` 与本图 accessKey = `NormalizeMd5(salt + secretKey, 18, "ak")`（18 位、`ak` 开头）。不足 10 位**静默不加密**
- 之后改密 / 取消密码 / 取 accessKey 走 `cmd/permission`（§2.5）
- 明文密码建议先转成 16 位 key 再提交——`computeSecretKey(pwd)` / `compute_secret_key(pwd)`（连续 4 次 md5 取前 16，与官方 JS SDK `pwdToSecretKey` 一致；空串 → 空串；18 位 `ak` 开头原样返回）。服务端不做这一步，官方前端弹框输入密码后也是先 `pwdToSecretKey` 再发

### 2.2 权限判定

| 请求里出现 | 匹配 | 得到权限 |
|-----------|------|---------|
| `accessKey` 列表含 config `map_super_key` | 实例所有图 | admin |
| `secretKey` 列表含该图 secretKey | 该图 | admin |
| `accessKey` 列表含该图 accessKey | 该图 | **仅浏览** |

- 未设密的图对任何请求都是 admin
- 删除、重命名、`cmd/permission` 等需要图级 admin，只拿 accessKey 不够

### 2.3 传递方式

- Header：`secretKey: k1_;_k2`、`accessKey: ak1_;_ak2`（多个用 `_;_` 连接，后端按它切分）；也可放 body/query 的同名驼峰键（后端无 `secret_key` 消费点）
- ⚠️ **header 会覆盖 body/query 里的同名键**。首次 openmap 设密时若 header 里带的是别的 key 串，存进去的就是整串 header 值
- SDK 缓存：`_secretKeys` / `_secret_keys` 按形态拆头——18 位 `ak` 开头 → `accessKey` 头，其余 → `secretKey` 头；`_accessKeys` / `_access_keys` 里的值始终走 `accessKey` 头（super key 放这里）。`openMap` / `open_map` 显式传了 `secretKey` / `accessKey` 时，本次请求对应的头只带该值

```python
svc._secret_keys = ["<16chars>", "ak................"]   # 前者 → secretKey 头，后者 → accessKey 头
svc._access_keys = ["<map_super_key>"]                    # 始终 → accessKey 头
```

```javascript
svc._secretKeys = ['16chars...', 'ak................'];
svc._accessKeys = ['<map_super_key>'];
```

### 2.4 加密图典型流程

```python
key = compute_secret_key("my-password")     # 16 位；服务端原样存/比对，<10 位不加密
up = svc.upload_map("./finance.dwg")
svc.open_map(mapid="finance_map", version="v1",
             fileid=up["fileid"], secretKey=key)   # v1 首次打开即设密
svc._secret_keys.append(key)                 # 之后的请求自动带 secretKey 头
meta = svc.metadata("finance_map", "v1")
```

### 2.5 改密 / 取 accessKey：`cmd/permission/{mapid}/_`

body `{opName, key, newKey?}`。前提：本次请求已对该图有 admin 权限（带对 secretKey，或把 super key 作 accessKey），否则回 `{status:false, error:true, reason:"no admin permisson, please use password or superKey"}`；且 `key` 必须等于该图 secretKey 或 super key，否则 `reason:"old password is not right"`。

| `opName` | 作用 |
|---------|------|
| `GetAccessKey` | 返回 `{status:true, accessKey}` |
| `ResetAccessKey` | 重新生成 salt 与 accessKey 并返回（旧 accessKey 失效） |
| `ModifyPassword` | `newKey` **原样**存为新 secretKey 并重生 accessKey（须 ≥ 10 位，否则 `new password is not valid`）；`newKey` 为空 → 取消密码保护；原先无密码的图则是新设密 |

SDK 未封装，用 `_request` 裸调：

```python
r = svc._request("POST", svc.cmd_url("permission", "finance_map", "_"),
                 json_body={"opName": "GetAccessKey", "key": key})
```

### 2.6 加密图的错误形态

- `openmap` 缺 key / key 错：**HTTP 200** + `{status:403, isPasswordError, error}`（`isPasswordError` = 请求里带了 secretKey 或 accessKey）——SDK **不抛异常**，须自查 `res.status === 403`
- `cmd/...` 路由：**HTTP 403** + `{error:"no permission to access this map, request must include secretKey or accessKey"}` → SDK 抛 `VjmapError(status=403)`。`thumbnail` / `wms` 以及 mapid 为 `_` / `_null` 的 cmd 不做此校验

## 3. Super Key（运维视角）

`.env` 里的 `VJMAP_MAP_SUPER_KEY`（测试/示例约定，对应服务端 `config.json` 的 `map_super_key`）是一把**超级密钥**：

- **只在请求的 `accessKey` 列表里匹配**——当 `secretKey` / password 提交无效。SDK 放 `svc._accessKeys` / `svc._access_keys`
- 对实例所有加密图都是 admin 权限
- `cmd/permission` 的 `key` 可以填 super key，但前提是请求已带 super key 作 accessKey 拿到了 admin 权限
- 二次开发一般不需要它；**只有**测试框架需要跑加密场景时才用

## 4. CSRF / 同源

- 后端不强制 CSRF token；依赖 JWT 签名
- 浏览器侧调用请注意 CORS：Go 网关与图形服务都默认 `Access-Control-Allow-Origin: *`；如果在受限部署里碰到 CORS，需要运维调整这两处中间件/构建配置

## 5. 速率限制

- **默认无全局速率限制**
- 单个 cmd 的并发由 `runstatus.maxParallelProcessGeomNum`（几何渲染）控制，典型值 2-4
- 超并发请求会被排队，不会报错但会"看起来卡住"

## 6. Token 重置路由（运维，不是刷新）

没有"过期前换新 token"的接口。config `system.update_token_route`（长度 ≥ 2 才注册）会挂一个 `GET /<该值>`（同样过 JWT + casbin）：

- 用 query `signing_key` / `expires_time` / `buffer_time` 改写 config 中的 JWT 配置——**`signing_key` 不传时回落为内置默认 key**
- 随后为内置用户重签全部 token（写 `data/auth/token/*.token`）
- signing_key 一变（包括不传而当前配置是自定义 key 的情况），**所有已发出的旧 token 立即失效**

这是运维重置端点，二次开发**不要调用**。

## 7. MCP 路由

- 默认关闭：config `mcp.enable=true` 才注册；路径 `mcp.route`（缺省 `/mcp`），streamable HTTP
- 注册在全局 JWT 之前，自带"Bearer 适配 → JWT → casbin"链：token 可放 `token` 头、`Authorization: Bearer <jwt>`（仅在没有 `token` 头时转成 `token` 头）或 `?token=`；`mcp.disable_auth=true` 可关闭鉴权（仅限可信内网）
- URL 参数：`?tools=分类或工具名`（白名单）、`?exclude=`（黑名单）、`?workspace=名称`（本连接默认工作区）
- `GET {route}/tools`：列出当前暴露的工具（`name/category/write/description`）与 `enableWrite`（写类工具受 `mcp.enable_write` 控制）
