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,148 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHashAPIToken_DeterministicAndAvalanche(t *testing.T) {
|
||||
a := HashAPIToken("nzp_alpha")
|
||||
b := HashAPIToken("nzp_alpha")
|
||||
if a != b {
|
||||
t.Fatalf("hash must be deterministic for identical inputs")
|
||||
}
|
||||
c := HashAPIToken("nzp_alphb")
|
||||
if a == c {
|
||||
t.Fatalf("single-byte change in input must change hash")
|
||||
}
|
||||
if len(a) != 64 {
|
||||
t.Fatalf("hash must be 64 hex chars (sha256), got %d", len(a))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_HasScope_AllAndExact(t *testing.T) {
|
||||
tok := &APIToken{}
|
||||
tok.SetScopes([]string{ScopeServerRead, ScopeServerExec})
|
||||
if !tok.HasScope(ScopeServerRead) {
|
||||
t.Fatalf("explicit scope must pass")
|
||||
}
|
||||
if !tok.HasScope(ScopeServerExec) {
|
||||
t.Fatalf("explicit scope must pass")
|
||||
}
|
||||
if tok.HasScope(ScopeServerWrite) {
|
||||
t.Fatalf("missing scope must fail")
|
||||
}
|
||||
|
||||
tok.SetScopes([]string{ScopeNezhaAll})
|
||||
for _, s := range []string{ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec} {
|
||||
if !tok.HasScope(s) {
|
||||
t.Fatalf("nezha:* must cover %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_HasScope_TrimsWhitespace(t *testing.T) {
|
||||
tok := &APIToken{ScopesCSV: " nezha:server:read , nezha:server:exec "}
|
||||
if !tok.HasScope(ScopeServerRead) {
|
||||
t.Fatalf("scope with surrounding whitespace must be normalized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_CanAccessServer_EmptyMeansAll(t *testing.T) {
|
||||
tok := &APIToken{}
|
||||
if !tok.CanAccessServer(1) {
|
||||
t.Fatalf("empty server list must allow any server")
|
||||
}
|
||||
if !tok.CanAccessServer(99999) {
|
||||
t.Fatalf("empty server list must allow any server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_CanAccessServer_Whitelist(t *testing.T) {
|
||||
tok := &APIToken{}
|
||||
tok.SetServerIDs([]uint64{2, 5, 7})
|
||||
if !tok.CanAccessServer(5) {
|
||||
t.Fatalf("listed server must be allowed")
|
||||
}
|
||||
if tok.CanAccessServer(6) {
|
||||
t.Fatalf("unlisted server must be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_SetServerIDs_RoundTrip(t *testing.T) {
|
||||
tok := &APIToken{}
|
||||
tok.SetServerIDs([]uint64{10, 11, 12})
|
||||
got := tok.ServerIDs()
|
||||
if len(got) != 3 || got[0] != 10 || got[2] != 12 {
|
||||
t.Fatalf("round-trip failed: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_ServerIDs_SkipsGarbage(t *testing.T) {
|
||||
tok := &APIToken{ServersCSV: "1,2,abc,3"}
|
||||
got := tok.ServerIDs()
|
||||
if len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 3 {
|
||||
t.Fatalf("garbage entries must be skipped; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_IsExpired(t *testing.T) {
|
||||
tok := &APIToken{}
|
||||
if tok.IsExpired(time.Now()) {
|
||||
t.Fatalf("nil expiry must mean never expired")
|
||||
}
|
||||
past := time.Now().Add(-time.Hour)
|
||||
tok.ExpiresAt = &past
|
||||
if !tok.IsExpired(time.Now()) {
|
||||
t.Fatalf("past expiry must mark expired")
|
||||
}
|
||||
future := time.Now().Add(time.Hour)
|
||||
tok.ExpiresAt = &future
|
||||
if tok.IsExpired(time.Now()) {
|
||||
t.Fatalf("future expiry must not mark expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_HashAPIToken_NoSecretLeak(t *testing.T) {
|
||||
plaintext := "nzp_supersecret"
|
||||
hash := HashAPIToken(plaintext)
|
||||
if strings.Contains(hash, plaintext) {
|
||||
t.Fatalf("hash must not contain plaintext")
|
||||
}
|
||||
if strings.Contains(hash, "super") {
|
||||
t.Fatalf("hash must not contain secret substring")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_BeforeCreate_RejectsEmptyHash(t *testing.T) {
|
||||
tok := &APIToken{Name: "x"}
|
||||
err := tok.BeforeCreate(nil)
|
||||
if err == nil {
|
||||
t.Fatalf("BeforeCreate must reject empty TokenHash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_BeforeCreate_AcceptsNonEmptyHash(t *testing.T) {
|
||||
tok := &APIToken{Name: "x", TokenHash: HashAPIToken("nzp_xyz")}
|
||||
if err := tok.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate must accept non-empty hash, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIToken_ToView_OmitsTokenHash(t *testing.T) {
|
||||
tok := &APIToken{
|
||||
ID: 1,
|
||||
UserID: 2,
|
||||
Name: "x",
|
||||
TokenHash: "DEADBEEF",
|
||||
}
|
||||
tok.SetScopes([]string{ScopeServerRead})
|
||||
v := tok.ToView()
|
||||
if v.ID != 1 || v.Name != "x" {
|
||||
t.Fatalf("view missing core fields")
|
||||
}
|
||||
if len(v.Scopes) != 1 || v.Scopes[0] != ScopeServerRead {
|
||||
t.Fatalf("view missing scopes")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user