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>
This commit is contained in:
naiba
2026-05-30 15:56:44 +00:00
co-authored by cloudcode
parent 58c1ea7b0b
commit ab25662ddd
153 changed files with 16974 additions and 244 deletions
+373
View File
@@ -0,0 +1,373 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"slices"
"strings"
"time"
"gorm.io/gorm"
)
// Scope 命名规范(唯一一套):nezha:{resource}:{verb}
//
// - resource: server / service / alertrule / cron / ddns / nat /
// notification / notification-group / transfer / admin
// - verb: read / write / delete / exec
//
// `*` 通配在 resource 或 verb 位均可:
// - nezha:server:* 给定资源的所有动作
// - nezha:* admin-only 全权
//
// 同一 scope 同时管 MCP tool 和 REST endpoint:例如 nezha:server:read 既允许
// `server.list` MCP tool,也允许 `GET /api/v1/server`。
//
// 历史上还有 mcp:* 一套,会被 HasScope 通过别名映射到 nezha:server:* 子集。
// 由于 HasScope 同时服务 MCP tool 调度与 REST scope middleware,旧 mcp:fs:write
// 等会静默扩到所有 nezha:server:write REST 路由——这是命名分裂带来的提权漏洞。
// 现在 mcp:* 不再在运行时被识别;createAPIToken 入口对老调用方做一次性归一化:
// 只读/exec 类(mcp:fs:read、mcp:server:read、mcp:server:exec)映射到对应
// nezha:* read/exec scopemcp:fs:write、mcp:fs:delete、mcp:* 一律拒签。
// 数据库已有的危险旧 scope 由 MigrateLegacyMCPScopes 在启动迁移阶段清理。
const (
ScopeNezhaAll = "nezha:*"
ScopeServerRead = "nezha:server:read"
ScopeServerWrite = "nezha:server:write"
ScopeServerDelete = "nezha:server:delete"
ScopeServerExec = "nezha:server:exec"
ScopeServiceRead = "nezha:service:read"
ScopeServiceWrite = "nezha:service:write"
ScopeServiceDelete = "nezha:service:delete"
ScopeAlertRuleRead = "nezha:alertrule:read"
ScopeAlertRuleWrite = "nezha:alertrule:write"
ScopeAlertRuleDelete = "nezha:alertrule:delete"
ScopeCronRead = "nezha:cron:read"
ScopeCronWrite = "nezha:cron:write"
ScopeCronDelete = "nezha:cron:delete"
ScopeCronExec = "nezha:cron:exec"
ScopeDDNSRead = "nezha:ddns:read"
ScopeDDNSWrite = "nezha:ddns:write"
ScopeDDNSDelete = "nezha:ddns:delete"
ScopeNATRead = "nezha:nat:read"
ScopeNATWrite = "nezha:nat:write"
ScopeNATDelete = "nezha:nat:delete"
ScopeNotificationRead = "nezha:notification:read"
ScopeNotificationWrite = "nezha:notification:write"
ScopeNotificationDelete = "nezha:notification:delete"
ScopeNotificationGroupRead = "nezha:notification-group:read"
ScopeNotificationGroupWrite = "nezha:notification-group:write"
ScopeNotificationGroupDelete = "nezha:notification-group:delete"
ScopeTransferRead = "nezha:transfer:read"
ScopeTransferWrite = "nezha:transfer:write"
ScopeTransferDelete = "nezha:transfer:delete"
ScopeAdminAll = "nezha:admin:*"
)
var AllScopes = []string{
ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec,
ScopeServiceRead, ScopeServiceWrite, ScopeServiceDelete,
ScopeAlertRuleRead, ScopeAlertRuleWrite, ScopeAlertRuleDelete,
ScopeCronRead, ScopeCronWrite, ScopeCronDelete, ScopeCronExec,
ScopeDDNSRead, ScopeDDNSWrite, ScopeDDNSDelete,
ScopeNATRead, ScopeNATWrite, ScopeNATDelete,
ScopeNotificationRead, ScopeNotificationWrite, ScopeNotificationDelete,
ScopeNotificationGroupRead, ScopeNotificationGroupWrite, ScopeNotificationGroupDelete,
ScopeTransferRead, ScopeTransferWrite, ScopeTransferDelete,
"nezha:server:*",
"nezha:service:*",
"nezha:alertrule:*",
"nezha:cron:*",
"nezha:ddns:*",
"nezha:nat:*",
"nezha:notification:*",
"nezha:notification-group:*",
"nezha:transfer:*",
}
var AdminOnlyScopes = []string{ScopeNezhaAll, ScopeAdminAll}
// legacyMCPReadOnlyRewrite 列出仍允许 createAPIToken 入口重写为 nezha:* 的旧 scope。
// 只有只读/exec 类被接受;write/delete/wildcard 一律拒签——保留映射等于扩权。
var legacyMCPReadOnlyRewrite = map[string]string{
"mcp:server:read": ScopeServerRead,
"mcp:server:exec": ScopeServerExec,
"mcp:fs:read": ScopeServerRead,
}
// NormalizeIncomingScope 把入参里的旧 mcp:* scope 重写到 nezha:* 命名。
// 第二个返回值表示该 scope 是否被允许(false = 危险旧 scope,调用方应拒签)。
func NormalizeIncomingScope(s string) (string, bool) {
if mapped, ok := legacyMCPReadOnlyRewrite[s]; ok {
return mapped, true
}
if strings.HasPrefix(s, "mcp:") {
return s, false
}
return s, true
}
// APITokenPrefix 是明文 token 的人类可识别前缀。`nzp_` = nezha personal access token。
const APITokenPrefix = "nzp_"
// APIToken 是用户用于程序化访问的长期凭证。MCP 接入点 /mcp 用它做鉴权。
// 双层鉴权:闸 1 用 UserID 复用 Server.HasPermission;闸 2 用 Scopes / ServerIDs。
type APIToken struct {
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
UserID uint64 `gorm:"index" json:"user_id,omitempty"`
Name string `gorm:"type:varchar(128)" json:"name,omitempty"`
TokenHash string `gorm:"uniqueIndex;type:char(64)" json:"-"`
ScopesCSV string `gorm:"type:text" json:"-"`
ServersCSV string `gorm:"type:text" json:"-"`
ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `gorm:"type:varchar(64)" json:"last_used_ip,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
}
func (APIToken) TableName() string {
return "api_tokens"
}
// HashAPIToken 计算明文 token 的存储哈希。
func HashAPIToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
// Scopes 解码逗号分隔的 scope 列表。
func (t *APIToken) Scopes() []string {
if t.ScopesCSV == "" {
return nil
}
parts := strings.Split(t.ScopesCSV, ",")
out := parts[:0]
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// SetScopes 编码 scope 列表为 CSV。
func (t *APIToken) SetScopes(scopes []string) {
t.ScopesCSV = strings.Join(scopes, ",")
}
// ServerIDs 解码服务器 ID 白名单。空切片 = 不限制(继承用户原有权限)。
func (t *APIToken) ServerIDs() []uint64 {
if t.ServersCSV == "" {
return nil
}
parts := strings.Split(t.ServersCSV, ",")
out := make([]uint64, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
var id uint64
for _, c := range p {
if c < '0' || c > '9' {
id = 0
break
}
id = id*10 + uint64(c-'0')
}
if id != 0 {
out = append(out, id)
}
}
return out
}
// SetServerIDs 编码服务器 ID 白名单。
func (t *APIToken) SetServerIDs(ids []uint64) {
parts := make([]string, 0, len(ids))
for _, id := range ids {
parts = append(parts, formatUint(id))
}
t.ServersCSV = strings.Join(parts, ",")
}
// HasScope 判定 token 是否携带某个 scope。
//
// 匹配规则:
// - nezha:* 覆盖整个 nezha 命名空间
// - 资源级通配:nezha:server:* 匹配所有 nezha:server:read/write/delete/exec
// - 精确匹配
//
// 不再做 mcp:* 别名展开;任何遗留的 mcp:* scope 都视为无效(已被
// MigrateLegacyMCPScopes 在启动迁移阶段清掉;运行时再遇到当作无权处理)。
func (t *APIToken) HasScope(scope string) bool {
for _, s := range t.Scopes() {
if scopeMatches(s, scope) {
return true
}
}
return false
}
// scopeMatches 判定 owned scope 是否覆盖 wanted scope。
func scopeMatches(owned, wanted string) bool {
if owned == wanted {
return true
}
if owned == ScopeNezhaAll {
return strings.HasPrefix(wanted, "nezha:")
}
if strings.HasSuffix(owned, ":*") {
prefix := strings.TrimSuffix(owned, ":*")
return strings.HasPrefix(wanted, prefix+":") || wanted == prefix
}
return false
}
// CanAccessServer 判定 token 是否被允许操作某 server(白名单层;
// 仍需上层调用 Server.HasPermission 做用户级权限校验)。
func (t *APIToken) CanAccessServer(serverID uint64) bool {
ids := t.ServerIDs()
if len(ids) == 0 {
return true
}
return slices.Contains(ids, serverID)
}
// IsExpired 判定 token 是否已过期。ExpiresAt 为 nil 表示永不过期。
func (t *APIToken) IsExpired(now time.Time) bool {
return t.ExpiresAt != nil && now.After(*t.ExpiresAt)
}
// BeforeCreate 在写入前强校验 TokenHash 必填,避免空哈希撞键。
func (t *APIToken) BeforeCreate(tx *gorm.DB) error {
if t.TokenHash == "" {
return gorm.ErrInvalidData
}
return nil
}
// MigrateLegacyMCPScopes 把数据库里残留的 mcp:* scope 一次性归一化:
// - 只读/exec 类映射到对应 nezha:* read/exec scope
// - mcp:fs:write / mcp:fs:delete / mcp:* 会被剥掉(不再赋予 REST write/delete),
// 若 token 因此 scope 列表清空则整体删除——避免出现一张 0 scope 但仍能命中
// auth middleware 的 PAT。
//
// 返回 (rewrittenTokens, deletedTokens, err)。生产路径在启动时调用一次;
// 测试也会用它构造 fixture。
func MigrateLegacyMCPScopes(db *gorm.DB) (int, int, error) {
if db == nil {
return 0, 0, nil
}
var rows []APIToken
if err := db.Where("scopes_csv LIKE ?", "%mcp:%").Find(&rows).Error; err != nil {
return 0, 0, err
}
rewritten, deleted := 0, 0
for i := range rows {
tok := &rows[i]
old := tok.Scopes()
next := make([]string, 0, len(old))
seen := make(map[string]struct{}, len(old))
for _, s := range old {
mapped, ok := NormalizeIncomingScope(s)
if !ok {
continue
}
if _, dup := seen[mapped]; dup {
continue
}
seen[mapped] = struct{}{}
next = append(next, mapped)
}
if len(next) == 0 {
if err := db.Delete(&APIToken{}, tok.ID).Error; err != nil {
return rewritten, deleted, err
}
deleted++
continue
}
joined := strings.Join(next, ",")
if joined == tok.ScopesCSV {
continue
}
if err := db.Model(&APIToken{}).Where("id = ?", tok.ID).
Update("scopes_csv", joined).Error; err != nil {
return rewritten, deleted, err
}
rewritten++
}
return rewritten, deleted, nil
}
// formatUint —— 小工具,避免引入 strconv。
func formatUint(v uint64) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}
// APITokenCreateRequest 是创建 PAT 接口的入参。
type APITokenCreateRequest struct {
Name string `json:"name" binding:"required,max=128"`
Scopes []string `json:"scopes" binding:"required,min=1,dive,max=64"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
ExpiresInDays int `json:"expires_in_days,omitempty"` // 0 = 永不过期
}
// APITokenCreateResponse 创建 PAT 接口的出参;明文 token 仅在此刻返回一次。
type APITokenCreateResponse struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Token string `json:"token"`
Scopes []string `json:"scopes"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// APITokenView 是 PAT 列表展示用的脱敏视图。
type APITokenView struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ToView 把数据库实体转为列表脱敏视图。
func (t *APIToken) ToView() APITokenView {
return APITokenView{
ID: t.ID,
Name: t.Name,
Scopes: t.Scopes(),
ServerIDs: t.ServerIDs(),
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
LastUsedIP: t.LastUsedIP,
CreatedAt: t.CreatedAt,
}
}