You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

196 lines
7.7 KiB

package mcp
import (
"context"
"fmt"
"strings"
"github.com/golang-jwt/jwt/v5"
)
/*
MCP 的会话身份。
## 这块此前是空的
`authFromRequest` 只把 Authorization 头塞进 context,**全仓没有一处读回来,
也没有任何校验**;三个用户工具(get_user_tasks / allhelp_task / cancel_user_task)
的 uid 都是 `mcp.WithString("uid", Required())` —— 由大模型填进来的普通参数。
而 MCP 是**独立进程、独立端口(7300)**,不经过 gateway 那套 parseToken +
isInWhiteList(那只管 /api/* 和 /web/*),又必须对百炼公网可达。
也就是说这不是「内网接口没做鉴权」,是**公网接口完全没有鉴权**:
知道 uid 就能读改任意人的数据。
记忆中心的条目装的是花销金额、灵感原文、每日行程,比「提醒任务」敏感得多,
所以在挂 memory 工具之前先把这层补上。
## 口径
- 有 Authorization 且能解出 uid → 用**会话 uid**,忽略参数里的 uid;
- 参数里的 uid 与会话 uid 不一致 → 直接拒(不是静默改用会话 uid:
调用方显然误以为自己能指定用户,让它失败比让它以为成功了更安全);
- 解不出会话 uid → 拒。**不降级回退到参数 uid** —— 那等于这层没做。
⚠️ TokenKey 必须与 gateway 的 `GATEWAY_TOKEN_KEY` 一致,否则所有 token 都验不过。
两边都从同一个 .env 变量注入。
*/
// authKey is a custom context key for storing the auth token.
type authKey struct{}
// withAuthKey adds an auth key to the context.
func withAuthKey(ctx context.Context, auth string) context.Context {
return context.WithValue(ctx, authKey{}, auth)
}
// tokenFromContext 取出请求带来的 Authorization 原始串
func tokenFromContext(ctx context.Context) string {
v, _ := ctx.Value(authKey{}).(string)
return v
}
// parseUID 从 JWT 里解出 uid。与 gateway/core.go 的 parseToken 同一套口径
// (jwt.RegisteredClaims,uid 放在 ID 字段)。
func parseUID(tokenString string, secretKey []byte) (string, error) {
tokenString = strings.TrimSpace(tokenString)
if tokenString == "" {
return "", fmt.Errorf("缺少 Authorization")
}
// 兼容 "Bearer xxx" 写法:gateway 那边收到的就是裸 token,
// 但 MCP 客户端(百炼)常按标准加 Bearer 前缀。
if len(tokenString) > 7 && strings.EqualFold(tokenString[:7], "bearer ") {
tokenString = strings.TrimSpace(tokenString[7:])
}
parsed, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{},
func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return secretKey, nil
})
if err != nil {
return "", err
}
claims, ok := parsed.Claims.(*jwt.RegisteredClaims)
if !ok || !parsed.Valid {
return "", fmt.Errorf("invalid token")
}
if claims.ID == "" {
return "", fmt.Errorf("token 里没有 uid")
}
return claims.ID, nil
}
// mcpTokenAudience 与 user 模块签发时写入的用途标记一致。
// 必须校验:将来若有别处也用 McpTokenKey 签东西,不校验就会被一并接受。
const mcpTokenAudience = "mcp"
// ResolveUID 决定这次工具调用该用哪个 uid。
//
// argUID 是模型传上来的参数,只用于「与会话 uid 比对」,绝不作为回退值。
func (this *Mcp) ResolveUID(ctx context.Context, argUID string) (string, error) {
return this.ResolveUIDWithToken(ctx, argUID, "")
}
// ResolveUIDWithToken 在 [ResolveUID] 的基础上多认一条来源:**参数里的签名令牌**。
//
// ## 为什么需要参数这条路
//
// 走 Authorization 头是首选,但 EMAI 这条链路上根本送不到:客户端连百炼只带
// 百炼的 API Key,百炼调 MCP 的 Authorization 头又是控制台里配死的、所有用户
// 共用一个值。唯一能按用户变的通道是 Start 指令的 `biz_params.user_defined_params`,
// 它会作为**工具参数**交到这里。
//
// ⚠️ 「参数里的令牌」和「参数里的 uid」是两回事,别混为一谈:
// 裸 uid 谁都能填,所以永远不采信;令牌带签名,伪造不了,安全性与走 header 等价。
//
// ⚠️ 令牌用的是独立的 McpTokenKey,不是 gateway 的 TokenKey——它经过百炼
// (第三方)之手,万一泄露也只能用于 MCP 这几个只读工具。
//
// ⚠️ `auth_token` **刻意不声明进工具的参数 schema**:声明了大模型就会试着自己
// 填一个值,而它根本无从知道真值,只会编一个出来——既污染参数又让日志里全是
// 「令牌校验失败」。它由百炼从 user_defined_params 注入到调用参数里,
// 不需要模型参与,所以这里只管读、不管声明。
func (this *Mcp) ResolveUIDWithToken(ctx context.Context, argUID, argToken string) (string, error) {
uid, err := this.resolveUIDFromSources(ctx, argToken)
if err != nil {
return "", err
}
argUID = strings.TrimSpace(argUID)
if argUID != "" && argUID != uid {
return "", fmt.Errorf("uid 与当前会话不符,已拒绝")
}
return uid, nil
}
// resolveUIDFromSources 依次尝试:请求头的登录 JWT → 参数里的 MCP 令牌。
// 两条都拿不出身份就拒,**绝不回退到参数 uid**——那等于这层没做。
func (this *Mcp) resolveUIDFromSources(ctx context.Context, argToken string) (string, error) {
var headerErr error
if key := this.options.TokenKey; key != "" {
if raw := strings.TrimSpace(tokenFromContext(ctx)); raw != "" {
uid, err := parseUID(raw, []byte(key))
if err == nil {
return uid, nil
}
headerErr = err
}
}
if argToken = strings.TrimSpace(argToken); argToken != "" {
key := this.options.McpTokenKey
if key == "" {
return "", fmt.Errorf("服务端未配置 McpTokenKey,无法校验令牌")
}
uid, err := parseUIDWithAudience(argToken, []byte(key), mcpTokenAudience)
if err != nil {
return "", fmt.Errorf("令牌校验失败: %v", err)
}
return uid, nil
}
if headerErr != nil {
return "", fmt.Errorf("身份校验失败: %v", headerErr)
}
if this.options.TokenKey == "" && this.options.McpTokenKey == "" {
// 两个密钥都没配说明部署时漏了注入。这时**拒绝所有用户类工具**
// 而不是放行:配置缺失导致鉴权静默失效是最糟的一种失败方式。
return "", fmt.Errorf("服务端未配置 TokenKey/McpTokenKey,用户数据类工具不可用")
}
return "", fmt.Errorf("身份校验失败: 未带 Authorization,也未带 MCP 令牌")
}
// parseUIDWithAudience 解 MCP 令牌:验签之外还要校验用途(aud)。
// jwt/v5 默认**不校验** aud,必须显式声明 WithAudience。
func parseUIDWithAudience(tokenString string, secretKey []byte, audience string) (string, error) {
tokenString = strings.TrimSpace(tokenString)
if tokenString == "" {
return "", fmt.Errorf("令牌为空")
}
if len(tokenString) > 7 && strings.EqualFold(tokenString[:7], "bearer ") {
tokenString = strings.TrimSpace(tokenString[7:])
}
parsed, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{},
func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return secretKey, nil
},
jwt.WithAudience(audience),
// 过期是这套令牌的核心保障,必须要求 exp 存在——没有 exp 的令牌等同长期有效
jwt.WithExpirationRequired(),
)
if err != nil {
return "", err
}
claims, ok := parsed.Claims.(*jwt.RegisteredClaims)
if !ok || !parsed.Valid {
return "", fmt.Errorf("invalid token")
}
if claims.ID == "" {
return "", fmt.Errorf("token 里没有 uid")
}
return claims.ID, nil
}