mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
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:
@@ -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 scope;mcp: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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user