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, } }