Files
nezha_domains/cmd/dashboard/controller/api_token_scope.go
T
naibaandcloudcode ab25662ddd feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.

Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.

Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.

Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-30 15:56:44 +00:00

136 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
)
// jwtOrPATAuthMiddleware 把 PAT 与 JWT 两条鉴权链组合到 /api/v1/* 入口。
//
// 处理顺序:
// 1. apiTokenAuthMiddleware:识别 `Authorization: Bearer nzp_*`。命中(合法 PAT
// 把 user 挂到 ctx;非法 PAT 直接 abort 401。
// 2. 如果 PAT 已挂 user → 跳过 JWT。
// 3. 否则 → JWT 中间件接管,按现有 cookie / Bearer / query token 逻辑鉴权。
//
// 存量 JWT 客户端零感知;新 PAT 客户端可直接调 REST,但每个端点的 scope
// 仍由 restScopeMiddleware 控制。
func jwtOrPATAuthMiddleware(patMw, jwtMw gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
patMw(c)
if c.IsAborted() {
return
}
if APITokenFromContext(c) != nil {
return
}
jwtMw(c)
if c.IsAborted() {
return
}
}
}
// patOrFallbackAuthMiddleware 是 optional 路由(ForceAuth=false 时也能匿名访问)
// 的鉴权链:
// 1. apiTokenAuthMiddleware:识别 PAT,命中后挂 user;非法 PAT 401 abort。
// 2. 已挂 PAT → 跳过 JWTrestScopeMiddleware 会按 scope 收口。
// 3. 未带 PAT → 走 fallbackJwtMw,存在 JWT 则挂 user,没有就匿名继续。
//
// 这是修复 ForceAuth=false 时 optional 路由完全不解析 PAT 的关键:
// 之前直接用 fallbackAuthMw 会让 PAT 请求被当作 guestscope 形同虚设。
func patOrFallbackAuthMiddleware(patMw, fallbackJwtMw gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
patMw(c)
if c.IsAborted() {
return
}
if APITokenFromContext(c) != nil {
return
}
fallbackJwtMw(c)
}
}
// restScopeMiddleware 在 /api/v1/* 路由上 enforce PAT scope。
//
// 行为:
// - JWT 持有者(任何来源:cookie / Authorization Bearer 非 nzp_)→ 直接放行,
// 沿用 JWT 模型的完整权限。
// - PAT 持有者 → 必须命中给定 scope。命中后下游 handler 仍受 user 级权限
// 检查(adminHandler / Server.HasPermission),scope 只能收窄不能放大。
// - PAT 持有者遇到 scope=="" → 直接 403。空字符串作为 fail-closed 默认值,
// 防止接入新路由时忘填 scope 把 PAT 静默放行。
//
// 因此"自我管理"端点(/profile、/api-tokens、/refresh-token 等)必须显式挂
// restPATForbiddenMiddleware 来拒绝 PAT,而不是依赖空 scope 兜底。
func restScopeMiddleware(scope string) gin.HandlerFunc {
return func(c *gin.Context) {
tok := APITokenFromContext(c)
if tok == nil {
c.Next()
return
}
if scope == "" || !tok.HasScope(scope) {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: api token lacks scope " + scope,
})
return
}
c.Next()
}
}
// restScopeAllOf is the multi-scope variant of restScopeMiddleware. It
// gates on EVERY listed scope, used by routes whose semantics span more
// than one capability — file-manager sessions read, write AND delete
// files, so a PAT that only carries nezha:server:write must NOT be allowed
// to open one. JWT callers pass through unchanged.
func restScopeAllOf(scopes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
tok := APITokenFromContext(c)
if tok == nil {
c.Next()
return
}
for _, scope := range scopes {
if scope == "" || !tok.HasScope(scope) {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: api token lacks scope " + scope,
})
return
}
}
c.Next()
}
}
// serverConfigSensitiveScope 收紧 GET /server/config/:id 的 PAT scope 到
// ScopeServerWrite:返回体里包含 client_secret 等下发到 agent 的凭据,单纯
// nezha:server:read 不应足以读取。命名刻意带 Sensitive 而不是 Read,避免下
// 个维护者把它当成普通 read scope 还原成 ScopeServerRead 重新打开提权链。
func serverConfigSensitiveScope() string { return model.ScopeServerWrite }
// restPATForbiddenMiddleware 在「自我管理」类端点上显式拒绝 PAT。
//
// 这些端点(profile / api-tokens / oauth2 绑定 / refresh-token)一旦允许 PAT
// 自调,就可形成提权链(PAT → 创建更高权限 PAT → ...)。
// 显式 403 比静默放行更安全。
func restPATForbiddenMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if APITokenFromContext(c) != nil {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: this endpoint is not accessible by api token",
})
return
}
c.Next()
}
}