mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 11:39:57 +00:00
The HTTP auth middleware runs syncUserJWTGroups in the request context. The dashboard SPA routinely aborts in-flight requests on re-render or navigation, which cancels the request context mid-write and rolls back the group-sync DB transaction. The error is logged but swallowed, so the synced groups silently never persist (users.auto_groups stays empty) while the failing log line repeats on every request. Detach the sync from the request's cancellation with context.WithoutCancel so the write can commit regardless of the client connection; the store already bounds the transaction with its own timeout. Add a regression test asserting the sync receives a non-cancelled context even when the originating request is cancelled.
791 lines
25 KiB
Go
791 lines
25 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
"github.com/netbirdio/netbird/management/server/auth"
|
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
|
"github.com/netbirdio/netbird/management/server/http/middleware/bypass"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/management/server/util"
|
|
nbauth "github.com/netbirdio/netbird/shared/auth"
|
|
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
|
)
|
|
|
|
const (
|
|
audience = "audience"
|
|
userIDClaim = "userIDClaim"
|
|
accountID = "accountID"
|
|
domain = "domain"
|
|
domainCategory = "domainCategory"
|
|
userID = "userID"
|
|
tokenID = "tokenID"
|
|
tokenID2 = "tokenID2"
|
|
PAT = "nbp_PAT"
|
|
PAT2 = "nbp_PAT2"
|
|
JWT = "JWT"
|
|
wrongToken = "wrongToken"
|
|
)
|
|
|
|
var testAccount = &types.Account{
|
|
Id: accountID,
|
|
Domain: domain,
|
|
Users: map[string]*types.User{
|
|
userID: {
|
|
Id: userID,
|
|
AccountID: accountID,
|
|
PATs: map[string]*types.PersonalAccessToken{
|
|
tokenID: {
|
|
ID: tokenID,
|
|
Name: "My first token",
|
|
HashedToken: "someHash",
|
|
ExpirationDate: util.ToPtr(time.Now().UTC().AddDate(0, 0, 7)),
|
|
CreatedBy: userID,
|
|
CreatedAt: time.Now().UTC(),
|
|
LastUsed: util.ToPtr(time.Now().UTC()),
|
|
},
|
|
tokenID2: {
|
|
ID: tokenID2,
|
|
Name: "My second token",
|
|
HashedToken: "someHash2",
|
|
ExpirationDate: util.ToPtr(time.Now().UTC().AddDate(0, 0, 7)),
|
|
CreatedBy: userID,
|
|
CreatedAt: time.Now().UTC(),
|
|
LastUsed: util.ToPtr(time.Now().UTC()),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
func mockGetAccountInfoFromPAT(_ context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error) {
|
|
if token == PAT {
|
|
return testAccount.Users[userID], testAccount.Users[userID].PATs[tokenID], testAccount.Domain, testAccount.DomainCategory, nil
|
|
}
|
|
if token == PAT2 {
|
|
return testAccount.Users[userID], testAccount.Users[userID].PATs[tokenID2], testAccount.Domain, testAccount.DomainCategory, nil
|
|
}
|
|
return nil, nil, "", "", fmt.Errorf("PAT invalid")
|
|
}
|
|
|
|
func mockValidateAndParseToken(_ context.Context, token string) (nbauth.UserAuth, *jwt.Token, error) {
|
|
if token == JWT {
|
|
return nbauth.UserAuth{
|
|
UserId: userID,
|
|
AccountId: accountID,
|
|
Domain: testAccount.Domain,
|
|
DomainCategory: testAccount.DomainCategory,
|
|
},
|
|
&jwt.Token{
|
|
Claims: jwt.MapClaims{
|
|
userIDClaim: userID,
|
|
audience + nbjwt.AccountIDSuffix: accountID,
|
|
},
|
|
Valid: true,
|
|
}, nil
|
|
}
|
|
return nbauth.UserAuth{}, nil, fmt.Errorf("JWT invalid")
|
|
}
|
|
|
|
func mockMarkPATUsed(_ context.Context, token string) error {
|
|
if token == tokenID || token == tokenID2 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("Should never get reached")
|
|
}
|
|
|
|
func mockEnsureUserAccessByJWTGroups(_ context.Context, userAuth nbauth.UserAuth, token *jwt.Token) (nbauth.UserAuth, error) {
|
|
if userAuth.IsChild || userAuth.IsPAT {
|
|
return userAuth, nil
|
|
}
|
|
|
|
if testAccount.Id != userAuth.AccountId {
|
|
return userAuth, fmt.Errorf("account with id %s does not exist", userAuth.AccountId)
|
|
}
|
|
|
|
if _, ok := testAccount.Users[userAuth.UserId]; !ok {
|
|
return userAuth, fmt.Errorf("user with id %s does not exist", userAuth.UserId)
|
|
}
|
|
|
|
return userAuth, nil
|
|
}
|
|
|
|
func TestAuthMiddleware_Handler(t *testing.T) {
|
|
tt := []struct {
|
|
name string
|
|
path string
|
|
authHeader string
|
|
expectedStatusCode int
|
|
shouldBypassAuth bool
|
|
}{
|
|
{
|
|
name: "Valid PAT Token",
|
|
path: "/test",
|
|
authHeader: "Token " + PAT,
|
|
expectedStatusCode: 200,
|
|
},
|
|
{
|
|
name: "Invalid PAT Token",
|
|
path: "/test",
|
|
authHeader: "Token " + wrongToken,
|
|
expectedStatusCode: 401,
|
|
},
|
|
{
|
|
name: "Fallback to PAT Token",
|
|
path: "/test",
|
|
authHeader: "Bearer " + PAT,
|
|
expectedStatusCode: 200,
|
|
},
|
|
{
|
|
name: "Valid JWT Token",
|
|
path: "/test",
|
|
authHeader: "Bearer " + JWT,
|
|
expectedStatusCode: 200,
|
|
},
|
|
{
|
|
name: "Invalid JWT Token",
|
|
path: "/test",
|
|
authHeader: "Bearer " + wrongToken,
|
|
expectedStatusCode: 401,
|
|
},
|
|
{
|
|
name: "Basic Auth",
|
|
path: "/test",
|
|
authHeader: "Basic " + PAT,
|
|
expectedStatusCode: 401,
|
|
},
|
|
{
|
|
name: "Webhook Path Bypass",
|
|
path: "/webhook",
|
|
authHeader: "",
|
|
expectedStatusCode: 200,
|
|
shouldBypassAuth: true,
|
|
},
|
|
{
|
|
name: "Webhook Path Bypass with Subpath",
|
|
path: "/webhook/test",
|
|
authHeader: "",
|
|
expectedStatusCode: 200,
|
|
shouldBypassAuth: true,
|
|
},
|
|
{
|
|
name: "Different Webhook Path",
|
|
path: "/webhooktest",
|
|
authHeader: "",
|
|
expectedStatusCode: 401,
|
|
shouldBypassAuth: false,
|
|
},
|
|
}
|
|
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
})
|
|
|
|
mockAuth := &auth.MockManager{
|
|
ValidateAndParseTokenFunc: mockValidateAndParseToken,
|
|
EnsureUserAccessByJWTGroupsFunc: mockEnsureUserAccessByJWTGroups,
|
|
MarkPATUsedFunc: mockMarkPATUsed,
|
|
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
|
}
|
|
|
|
disabledLimiter := NewAPIRateLimiter(nil)
|
|
disabledLimiter.SetEnabled(false)
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
disabledLimiter,
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
handlerToTest := authMiddleware.Handler(nextHandler)
|
|
|
|
for _, tc := range tt {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.shouldBypassAuth {
|
|
err := bypass.AddBypassPath(tc.path)
|
|
if err != nil {
|
|
t.Fatalf("failed to add bypass path: %v", err)
|
|
}
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "http://testing"+tc.path, nil)
|
|
req.Header.Set("Authorization", tc.authHeader)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handlerToTest.ServeHTTP(rec, req)
|
|
|
|
result := rec.Result()
|
|
defer result.Body.Close()
|
|
|
|
if result.StatusCode != tc.expectedStatusCode {
|
|
t.Errorf("expected status code %d, got %d", tc.expectedStatusCode, result.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation ensures the
|
|
// JWT group sync write is not bound to the request context. The dashboard SPA
|
|
// routinely aborts in-flight requests on re-render/navigation; if the sync ran in
|
|
// the request context, the cancellation would roll back the DB transaction and the
|
|
// synced groups would silently never persist. The sync must receive a context that
|
|
// is not cancelled even when the originating request is.
|
|
func TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation(t *testing.T) {
|
|
var (
|
|
syncCalled bool
|
|
syncCtxErr error
|
|
)
|
|
|
|
mockAuth := &auth.MockManager{
|
|
ValidateAndParseTokenFunc: mockValidateAndParseToken,
|
|
EnsureUserAccessByJWTGroupsFunc: mockEnsureUserAccessByJWTGroups,
|
|
MarkPATUsedFunc: mockMarkPATUsed,
|
|
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
|
}
|
|
|
|
disabledLimiter := NewAPIRateLimiter(nil)
|
|
disabledLimiter.SetEnabled(false)
|
|
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
syncCalled = true
|
|
syncCtxErr = ctx.Err()
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
disabledLimiter,
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
handlerToTest := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
|
|
// Simulate the dashboard aborting the request: it arrives already cancelled.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
req := httptest.NewRequest("GET", "http://testing/test", nil).WithContext(ctx)
|
|
req.Header.Set("Authorization", "Bearer "+JWT)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handlerToTest.ServeHTTP(rec, req)
|
|
|
|
if !syncCalled {
|
|
t.Fatal("syncUserJWTGroups was not called")
|
|
}
|
|
if syncCtxErr != nil {
|
|
t.Fatalf("syncUserJWTGroups received a cancelled context (%v); the group-sync write must be detached from request cancellation", syncCtxErr)
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
|
mockAuth := &auth.MockManager{
|
|
ValidateAndParseTokenFunc: mockValidateAndParseToken,
|
|
EnsureUserAccessByJWTGroupsFunc: mockEnsureUserAccessByJWTGroups,
|
|
MarkPATUsedFunc: mockMarkPATUsed,
|
|
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
|
}
|
|
|
|
t.Run("PAT Token Rate Limiting - Burst Works", func(t *testing.T) {
|
|
// Configure rate limiter: 10 requests per minute with burst of 5
|
|
rateLimitConfig := &RateLimiterConfig{
|
|
RequestsPerMinute: 10,
|
|
Burst: 5,
|
|
CleanupInterval: 5 * time.Minute,
|
|
LimiterTTL: 10 * time.Minute,
|
|
}
|
|
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
NewAPIRateLimiter(rateLimitConfig),
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// Make burst requests - all should succeed
|
|
successCount := 0
|
|
for i := 0; i < 5; i++ {
|
|
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusOK {
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, 5, successCount, "All burst requests should succeed")
|
|
|
|
// The 6th request should fail (exceeded burst)
|
|
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Request beyond burst should be rate limited")
|
|
})
|
|
|
|
t.Run("PAT Token Rate Limiting - Rate Limit Enforced", func(t *testing.T) {
|
|
// Configure very low rate limit: 1 request per minute
|
|
rateLimitConfig := &RateLimiterConfig{
|
|
RequestsPerMinute: 1,
|
|
Burst: 1,
|
|
CleanupInterval: 5 * time.Minute,
|
|
LimiterTTL: 10 * time.Minute,
|
|
}
|
|
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
NewAPIRateLimiter(rateLimitConfig),
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// First request should succeed
|
|
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code, "First request should succeed")
|
|
|
|
// Second request should fail (rate limited)
|
|
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request should be rate limited")
|
|
})
|
|
|
|
t.Run("Bearer Token Not Rate Limited", func(t *testing.T) {
|
|
// Configure strict rate limit
|
|
rateLimitConfig := &RateLimiterConfig{
|
|
RequestsPerMinute: 1,
|
|
Burst: 1,
|
|
CleanupInterval: 5 * time.Minute,
|
|
LimiterTTL: 10 * time.Minute,
|
|
}
|
|
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
NewAPIRateLimiter(rateLimitConfig),
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// Make multiple requests with Bearer token - all should succeed
|
|
successCount := 0
|
|
for i := 0; i < 10; i++ {
|
|
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Bearer "+JWT)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusOK {
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, 10, successCount, "All Bearer token requests should succeed (not rate limited)")
|
|
})
|
|
|
|
t.Run("PAT Token Rate Limiting Per Token", func(t *testing.T) {
|
|
// Configure rate limiter
|
|
rateLimitConfig := &RateLimiterConfig{
|
|
RequestsPerMinute: 1,
|
|
Burst: 1,
|
|
CleanupInterval: 5 * time.Minute,
|
|
LimiterTTL: 10 * time.Minute,
|
|
}
|
|
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
NewAPIRateLimiter(rateLimitConfig),
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// Use first PAT token
|
|
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code, "First request with PAT should succeed")
|
|
|
|
// Second request with same token should fail
|
|
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request with same PAT should be rate limited")
|
|
|
|
// Use second PAT token - should succeed because it has independent rate limit
|
|
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT2)
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code, "First request with PAT2 should succeed (independent rate limit)")
|
|
|
|
// Second request with PAT2 should also be rate limited
|
|
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT2)
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request with PAT2 should be rate limited")
|
|
|
|
// JWT should still work (not rate limited)
|
|
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Bearer "+JWT)
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code, "JWT request should succeed (not rate limited)")
|
|
})
|
|
|
|
t.Run("Rate Limiter Cleanup", func(t *testing.T) {
|
|
// Configure rate limiter with short cleanup interval and TTL for testing
|
|
rateLimitConfig := &RateLimiterConfig{
|
|
RequestsPerMinute: 60,
|
|
Burst: 1,
|
|
CleanupInterval: 100 * time.Millisecond,
|
|
LimiterTTL: 200 * time.Millisecond,
|
|
}
|
|
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
NewAPIRateLimiter(rateLimitConfig),
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// First request - should succeed
|
|
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code, "First request should succeed")
|
|
|
|
// Second request immediately - should fail (burst exhausted)
|
|
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request should be rate limited")
|
|
|
|
// Wait for limiter to be cleaned up (TTL + cleanup interval + buffer)
|
|
time.Sleep(400 * time.Millisecond)
|
|
|
|
// After cleanup, the limiter should be removed and recreated with full burst capacity
|
|
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Request after cleanup should succeed (new limiter with full burst)")
|
|
|
|
// Verify it's a fresh limiter by checking burst is reset
|
|
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request after cleanup should be rate limited again")
|
|
})
|
|
|
|
t.Run("Terraform User Agent Not Rate Limited", func(t *testing.T) {
|
|
rateLimitConfig := &RateLimiterConfig{
|
|
RequestsPerMinute: 1,
|
|
Burst: 1,
|
|
CleanupInterval: 5 * time.Minute,
|
|
LimiterTTL: 10 * time.Minute,
|
|
}
|
|
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
NewAPIRateLimiter(rateLimitConfig),
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// Test various Terraform user agent formats
|
|
terraformUserAgents := []string{
|
|
"Terraform/1.5.0",
|
|
"terraform/1.0.0",
|
|
"Terraform-Provider/2.0.0",
|
|
"Mozilla/5.0 (compatible; Terraform/1.3.0)",
|
|
}
|
|
|
|
for _, userAgent := range terraformUserAgents {
|
|
t.Run("UserAgent: "+userAgent, func(t *testing.T) {
|
|
successCount := 0
|
|
for i := 0; i < 10; i++ {
|
|
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
req.Header.Set("User-Agent", userAgent)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code == http.StatusOK {
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, 10, successCount, "All Terraform user agent requests should succeed (not rate limited)")
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Non-Terraform User Agent With PAT Is Rate Limited", func(t *testing.T) {
|
|
rateLimitConfig := &RateLimiterConfig{
|
|
RequestsPerMinute: 1,
|
|
Burst: 1,
|
|
CleanupInterval: 5 * time.Minute,
|
|
LimiterTTL: 10 * time.Minute,
|
|
}
|
|
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
NewAPIRateLimiter(rateLimitConfig),
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
req.Header.Set("User-Agent", "curl/7.68.0")
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code, "First request should succeed")
|
|
|
|
req = httptest.NewRequest("GET", "http://testing/test", nil)
|
|
req.Header.Set("Authorization", "Token "+PAT)
|
|
req.Header.Set("User-Agent", "curl/7.68.0")
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request should be rate limited")
|
|
})
|
|
}
|
|
|
|
func TestAuthMiddleware_Handler_Child(t *testing.T) {
|
|
tt := []struct {
|
|
name string
|
|
path string
|
|
authHeader string
|
|
expectedUserAuth *nbauth.UserAuth // nil expects 401 response status
|
|
}{
|
|
{
|
|
name: "Valid PAT Token",
|
|
path: "/test",
|
|
authHeader: "Token " + PAT,
|
|
expectedUserAuth: &nbauth.UserAuth{
|
|
AccountId: accountID,
|
|
UserId: userID,
|
|
Domain: testAccount.Domain,
|
|
DomainCategory: testAccount.DomainCategory,
|
|
IsPAT: true,
|
|
},
|
|
},
|
|
{
|
|
name: "PAT Token with account param ignored in public version",
|
|
path: "/test?account=xyz",
|
|
authHeader: "Token " + PAT,
|
|
expectedUserAuth: &nbauth.UserAuth{
|
|
AccountId: accountID,
|
|
UserId: userID,
|
|
Domain: testAccount.Domain,
|
|
DomainCategory: testAccount.DomainCategory,
|
|
IsPAT: true,
|
|
},
|
|
},
|
|
{
|
|
name: "Valid JWT Token",
|
|
path: "/test",
|
|
authHeader: "Bearer " + JWT,
|
|
expectedUserAuth: &nbauth.UserAuth{
|
|
AccountId: accountID,
|
|
UserId: userID,
|
|
Domain: testAccount.Domain,
|
|
DomainCategory: testAccount.DomainCategory,
|
|
},
|
|
},
|
|
|
|
{
|
|
name: "JWT Token with account param ignored in public version",
|
|
path: "/test?account=xyz",
|
|
authHeader: "Bearer " + JWT,
|
|
expectedUserAuth: &nbauth.UserAuth{
|
|
AccountId: accountID,
|
|
UserId: userID,
|
|
Domain: testAccount.Domain,
|
|
DomainCategory: testAccount.DomainCategory,
|
|
},
|
|
},
|
|
}
|
|
|
|
mockAuth := &auth.MockManager{
|
|
ValidateAndParseTokenFunc: mockValidateAndParseToken,
|
|
EnsureUserAccessByJWTGroupsFunc: mockEnsureUserAccessByJWTGroups,
|
|
MarkPATUsedFunc: mockMarkPATUsed,
|
|
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
|
}
|
|
|
|
disabledLimiter := NewAPIRateLimiter(nil)
|
|
disabledLimiter.SetEnabled(false)
|
|
authMiddleware := NewAuthMiddleware(
|
|
mockAuth,
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
|
return userAuth.AccountId, userAuth.UserId, nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) error {
|
|
return nil
|
|
},
|
|
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
|
return &types.User{}, nil
|
|
},
|
|
disabledLimiter,
|
|
nil,
|
|
func(_ context.Context, _, _, _ string) bool { return false },
|
|
)
|
|
|
|
for _, tc := range tt {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
handlerToTest := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
userAuth, err := nbcontext.GetUserAuthFromRequest(r)
|
|
if tc.expectedUserAuth != nil {
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, *tc.expectedUserAuth, userAuth)
|
|
} else {
|
|
assert.Error(t, err)
|
|
assert.Empty(t, userAuth)
|
|
}
|
|
}))
|
|
|
|
req := httptest.NewRequest("GET", "http://testing"+tc.path, nil)
|
|
req.Header.Set("Authorization", tc.authHeader)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handlerToTest.ServeHTTP(rec, req)
|
|
|
|
result := rec.Result()
|
|
defer result.Body.Close()
|
|
|
|
if tc.expectedUserAuth != nil {
|
|
assert.Equal(t, 200, result.StatusCode)
|
|
} else {
|
|
assert.Equal(t, 401, result.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|