mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
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>
136 lines
4.6 KiB
Go
136 lines
4.6 KiB
Go
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 → 跳过 JWT,restScopeMiddleware 会按 scope 收口。
|
||
// 3. 未带 PAT → 走 fallbackJwtMw,存在 JWT 则挂 user,没有就匿名继续。
|
||
//
|
||
// 这是修复 ForceAuth=false 时 optional 路由完全不解析 PAT 的关键:
|
||
// 之前直接用 fallbackAuthMw 会让 PAT 请求被当作 guest,scope 形同虚设。
|
||
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()
|
||
}
|
||
}
|