Files
nezha_domains/cmd/dashboard/controller/api_token.go
T
naibaandcloudcode e8dabf5bc6 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

287 lines
8.5 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 (
"errors"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/service/singleton"
)
const (
apiTokenSecretLength = 32 // 明文 token 随机部分长度(hex 编码前)
apiTokenCtxKey = "nz_api_token" // gin context 里存 *model.APIToken 的 key
apiTokenLastUsedCtxKey = "nz_api_token_used_marker" // 标记是否需要异步更新 last_used
apiTokenAuthSchemePrefix = "Bearer "
)
// listAPITokens 列出当前用户的所有 PAT(脱敏,不含 token 明文)。
// @Summary List API tokens
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.APITokenView]
// @Router /api-tokens [get]
func listAPITokens(c *gin.Context) ([]model.APITokenView, error) {
uid := getUid(c)
var rows []model.APIToken
if err := singleton.DB.Where("user_id = ?", uid).Order("id DESC").Find(&rows).Error; err != nil {
return nil, newGormError("%v", err)
}
out := make([]model.APITokenView, 0, len(rows))
for i := range rows {
out = append(out, rows[i].ToView())
}
return out, nil
}
// createAPIToken 创建一个 PAT。明文 token 仅在响应中返回一次。
// @Summary Create API token
// @Tags auth required
// @Accept json
// @Param body body model.APITokenCreateRequest true "request"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.APITokenCreateResponse]
// @Router /api-tokens [post]
func createAPIToken(c *gin.Context) (*model.APITokenCreateResponse, error) {
var req model.APITokenCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
return nil, err
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return nil, errors.New("name required")
}
if len(req.Name) > 128 {
return nil, errors.New("name too long (max 128 chars)")
}
if req.ExpiresInDays < 0 {
return nil, errors.New("expires_in_days must be >= 0")
}
if req.ExpiresInDays > 3650 {
return nil, errors.New("expires_in_days too large (max 3650, i.e. 10 years)")
}
if len(req.Scopes) > 32 {
return nil, errors.New("too many scopes (max 32)")
}
if len(req.ServerIDs) > 1000 {
return nil, errors.New("too many server_ids (max 1000)")
}
allowed := append(append([]string{}, model.AllScopes...), model.AdminOnlyScopes...)
seen := make(map[string]struct{}, len(req.Scopes))
cleaned := make([]string, 0, len(req.Scopes))
for _, s := range req.Scopes {
s = strings.TrimSpace(s)
if s == "" {
continue
}
normalized, ok := model.NormalizeIncomingScope(s)
if !ok {
return nil, errors.New("unknown scope: " + s)
}
if !slices.Contains(allowed, normalized) {
return nil, errors.New("unknown scope: " + s)
}
if _, dup := seen[normalized]; dup {
continue
}
seen[normalized] = struct{}{}
cleaned = append(cleaned, normalized)
}
if len(cleaned) == 0 {
return nil, errors.New("at least one scope required")
}
if !callerIsAdmin(c) {
for _, s := range cleaned {
if slices.Contains(model.AdminOnlyScopes, s) {
return nil, errors.New("only admin can issue scope: " + s)
}
}
}
if len(req.ServerIDs) > 0 {
seenSrv := make(map[uint64]struct{}, len(req.ServerIDs))
deduped := make([]uint64, 0, len(req.ServerIDs))
for _, sid := range req.ServerIDs {
if sid == 0 {
return nil, errors.New("server_id 0 is invalid")
}
if _, dup := seenSrv[sid]; dup {
continue
}
seenSrv[sid] = struct{}{}
deduped = append(deduped, sid)
if !callerIsAdmin(c) {
server, _ := singleton.ServerShared.Get(sid)
if server == nil {
return nil, errors.New("server not found")
}
if !server.HasPermission(c) {
return nil, errors.New("permission denied on server")
}
}
}
req.ServerIDs = deduped
}
secret, err := utils.GenerateRandomString(apiTokenSecretLength)
if err != nil {
return nil, err
}
plaintext := model.APITokenPrefix + secret
tok := model.APIToken{
UserID: getUid(c),
Name: req.Name,
TokenHash: model.HashAPIToken(plaintext),
}
tok.SetScopes(cleaned)
if len(req.ServerIDs) > 0 {
tok.SetServerIDs(req.ServerIDs)
}
if req.ExpiresInDays > 0 {
exp := time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour)
tok.ExpiresAt = &exp
}
if err := singleton.DB.Create(&tok).Error; err != nil {
return nil, newGormError("%v", err)
}
return &model.APITokenCreateResponse{
ID: tok.ID,
Name: tok.Name,
Token: plaintext,
Scopes: tok.Scopes(),
ServerIDs: tok.ServerIDs(),
ExpiresAt: tok.ExpiresAt,
}, nil
}
// deleteAPIToken 吊销一个 PAT。
// @Summary Revoke API token
// @Tags auth required
// @Param id path uint true "token id"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /api-tokens/{id} [delete]
func deleteAPIToken(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
q := singleton.DB.Where("id = ?", id)
if !callerIsAdmin(c) {
q = q.Where("user_id = ?", getUid(c))
}
res := q.Delete(&model.APIToken{})
if res.Error != nil {
return nil, newGormError("%v", res.Error)
}
if res.RowsAffected == 0 {
return nil, errors.New("not found")
}
// Fan out the revocation to any active long-lived connection that
// carries this PAT — ws/server, ws/transfer, terminal, FM. Without
// this hook a deleted PAT keeps streaming until the underlying
// connection naturally drops.
patConnectionRegistryShared.revokeToken(id)
return nil, nil
}
// apiTokenAuthMiddleware 解析 `Authorization: Bearer nzp_xxx`
// 命中后把 *model.User 挂到 ctx 上,使下游一切 Server.HasPermission/getUid 复用 JWT 路径。
//
// 不命中(无 Authorization 头或前缀不是 nzp_):放行下一个中间件(例如 JWT)。
// 命中但 token 无效:直接 401 并 abort,不再走到 JWT。
func apiTokenAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
raw := strings.TrimSpace(c.GetHeader("Authorization"))
if raw == "" {
return
}
if !strings.HasPrefix(raw, apiTokenAuthSchemePrefix) {
return
}
plaintext := strings.TrimSpace(strings.TrimPrefix(raw, apiTokenAuthSchemePrefix))
if !strings.HasPrefix(plaintext, model.APITokenPrefix) {
// 既然有 Bearer 但不是 PAT 前缀,交给后续 JWT 中间件处理
return
}
realIP := c.GetString(model.CtxKeyRealIPStr)
var tok model.APIToken
err := singleton.DB.Where("token_hash = ?", model.HashAPIToken(plaintext)).First(&tok).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken)
abortAPITokenUnauthorized(c, "invalid api token")
return
}
abortAPITokenUnauthorized(c, "api token lookup failed")
return
}
now := time.Now()
if tok.IsExpired(now) {
model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken)
abortAPITokenUnauthorized(c, "api token expired")
return
}
var user model.User
if err := singleton.DB.First(&user, tok.UserID).Error; err != nil {
model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken)
abortAPITokenUnauthorized(c, "owner of api token not found")
return
}
model.UnblockIP(singleton.DB, realIP, model.BlockIDToken)
c.Set(model.CtxKeyAuthorizedUser, &user)
c.Set(apiTokenCtxKey, &tok)
c.Set(model.CtxKeyAPIToken, &tok)
// last_used 同步更新:开销极低(一行 UPDATE),异步路径在
// 多连接 sqlite 测试场景下会和测试 teardown 形成竞态,并把
// `last_used_*` 写丢到不可见的 :memory: 实例。生产路径上等价。
if v, ok := c.Get(apiTokenLastUsedCtxKey); !ok || v != true {
c.Set(apiTokenLastUsedCtxKey, true)
_ = singleton.DB.Model(&model.APIToken{}).
Where("id = ?", tok.ID).
Updates(map[string]any{
"last_used_at": now,
"last_used_ip": c.GetString(model.CtxKeyRealIPStr),
}).Error
}
}
}
func abortAPITokenUnauthorized(c *gin.Context, reason string) {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorUnauthorized: " + reason,
})
}
// APITokenFromContext 取当前请求关联的 PAT,未命中返回 nil。
// MCP tool 中间件用它做 scope 校验(闸 2)。
func APITokenFromContext(c *gin.Context) *model.APIToken {
v, ok := c.Get(apiTokenCtxKey)
if !ok {
return nil
}
t, _ := v.(*model.APIToken)
return t
}