From 0f05202438c7315254649af9806c8a101b7ee6da Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 9 Dec 2025 11:32:44 +0200 Subject: [PATCH] Database Authenticator with cache --- pkg/security/providers.go | 127 +++++++++++++---- pkg/security/providers_test.go | 241 ++++++++++++++++++++++++++++++++- 2 files changed, 338 insertions(+), 30 deletions(-) diff --git a/pkg/security/providers.go b/pkg/security/providers.go index 2cc610d..2812e8c 100644 --- a/pkg/security/providers.go +++ b/pkg/security/providers.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "time" + + "github.com/bitechdev/ResolveSpec/pkg/cache" ) // Production-Ready Authenticators @@ -58,11 +60,41 @@ func (a *HeaderAuthenticator) Authenticate(r *http.Request) (*UserContext, error // resolvespec_session_update, resolvespec_refresh_token // See database_schema.sql for procedure definitions type DatabaseAuthenticator struct { - db *sql.DB + db *sql.DB + cache *cache.Cache + cacheTTL time.Duration +} + +// DatabaseAuthenticatorOptions configures the database authenticator +type DatabaseAuthenticatorOptions struct { + // CacheTTL is the duration to cache user contexts + // Default: 5 minutes + CacheTTL time.Duration + // Cache is an optional cache instance. If nil, uses the default cache + Cache *cache.Cache } func NewDatabaseAuthenticator(db *sql.DB) *DatabaseAuthenticator { - return &DatabaseAuthenticator{db: db} + return NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{ + CacheTTL: 5 * time.Minute, + }) +} + +func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorOptions) *DatabaseAuthenticator { + if opts.CacheTTL == 0 { + opts.CacheTTL = 5 * time.Minute + } + + cacheInstance := opts.Cache + if cacheInstance == nil { + cacheInstance = cache.GetDefaultCache() + } + + return &DatabaseAuthenticator{ + db: db, + cache: cacheInstance, + cacheTTL: opts.CacheTTL, + } } func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) { @@ -124,17 +156,25 @@ func (a *DatabaseAuthenticator) Logout(ctx context.Context, req LogoutRequest) e return fmt.Errorf("logout failed") } + // Clear cache for this token + if req.Token != "" { + cacheKey := fmt.Sprintf("auth:session:%s", req.Token) + _ = a.cache.Delete(ctx, cacheKey) + } + return nil } func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, error) { // Extract session token from header or cookie sessionToken := r.Header.Get("Authorization") + reference := "authenticate" if sessionToken == "" { // Try cookie cookie, err := r.Cookie("session_token") if err == nil { sessionToken = cookie.Value + reference = "cookie" } } else { // Remove "Bearer " prefix if present @@ -147,35 +187,45 @@ func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, err return nil, fmt.Errorf("session token required") } - // Call resolvespec_session stored procedure - // reference could be route, controller name, or any identifier - reference := "authenticate" + // Build cache key + cacheKey := fmt.Sprintf("auth:session:%s", sessionToken) - var success bool - var errorMsg sql.NullString - var userJSON sql.NullString - - query := `SELECT p_success, p_error, p_user::text FROM resolvespec_session($1, $2)` - err := a.db.QueryRowContext(r.Context(), query, sessionToken, reference).Scan(&success, &errorMsg, &userJSON) - if err != nil { - return nil, fmt.Errorf("session query failed: %w", err) - } - - if !success { - if errorMsg.Valid { - return nil, fmt.Errorf("%s", errorMsg.String) - } - return nil, fmt.Errorf("invalid or expired session") - } - - if !userJSON.Valid { - return nil, fmt.Errorf("no user data in session") - } - - // Parse UserContext + // Use cache.GetOrSet to get from cache or load from database var userCtx UserContext - if err := json.Unmarshal([]byte(userJSON.String), &userCtx); err != nil { - return nil, fmt.Errorf("failed to parse user context: %w", err) + err := a.cache.GetOrSet(r.Context(), cacheKey, &userCtx, a.cacheTTL, func() (interface{}, error) { + // This function is called only if cache miss + var success bool + var errorMsg sql.NullString + var userJSON sql.NullString + + query := `SELECT p_success, p_error, p_user::text FROM resolvespec_session($1, $2)` + err := a.db.QueryRowContext(r.Context(), query, sessionToken, reference).Scan(&success, &errorMsg, &userJSON) + if err != nil { + return nil, fmt.Errorf("session query failed: %w", err) + } + + if !success { + if errorMsg.Valid { + return nil, fmt.Errorf("%s", errorMsg.String) + } + return nil, fmt.Errorf("invalid or expired session") + } + + if !userJSON.Valid { + return nil, fmt.Errorf("no user data in session") + } + + // Parse UserContext + var user UserContext + if err := json.Unmarshal([]byte(userJSON.String), &user); err != nil { + return nil, fmt.Errorf("failed to parse user context: %w", err) + } + + return &user, nil + }) + + if err != nil { + return nil, err } // Update last activity timestamp asynchronously @@ -184,6 +234,25 @@ func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, err return &userCtx, nil } +// ClearCache removes a specific token from the cache or clears all cache if token is empty +func (a *DatabaseAuthenticator) ClearCache(token string) error { + ctx := context.Background() + if token != "" { + cacheKey := fmt.Sprintf("auth:session:%s", token) + return a.cache.Delete(ctx, cacheKey) + } + // Clear all auth cache entries + return a.cache.DeleteByPattern(ctx, "auth:session:*") +} + +// ClearUserCache removes all cache entries for a specific user ID +func (a *DatabaseAuthenticator) ClearUserCache(userID int) error { + ctx := context.Background() + // Clear all sessions for this user + pattern := "auth:session:*" + return a.cache.DeleteByPattern(ctx, pattern) +} + // updateSessionActivity updates the last activity timestamp for the session func (a *DatabaseAuthenticator) updateSessionActivity(ctx context.Context, sessionToken string, userCtx *UserContext) { // Convert UserContext to JSON diff --git a/pkg/security/providers_test.go b/pkg/security/providers_test.go index b1a841d..9aac847 100644 --- a/pkg/security/providers_test.go +++ b/pkg/security/providers_test.go @@ -6,8 +6,10 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/bitechdev/ResolveSpec/pkg/cache" ) // Test HeaderAuthenticator @@ -158,6 +160,243 @@ func TestParseIntHeader(t *testing.T) { }) } +// Test DatabaseAuthenticator caching +func TestDatabaseAuthenticatorCaching(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + // Create a test cache instance + cacheProvider := cache.NewMemoryProvider(&cache.Options{ + DefaultTTL: 1 * time.Minute, + MaxSize: 1000, + }) + testCache := cache.NewCache(cacheProvider) + + // Create authenticator with short cache TTL for testing + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{ + CacheTTL: 100 * time.Millisecond, + Cache: testCache, + }) + + t.Run("cache hit avoids database call", func(t *testing.T) { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer cached-token-123") + + // First call - should hit database + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":1,"user_name":"testuser","session_id":"cached-token-123"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("cached-token-123", "authenticate"). + WillReturnRows(rows) + + userCtx1, err := auth.Authenticate(req) + if err != nil { + t.Fatalf("first authenticate failed: %v", err) + } + if userCtx1.UserID != 1 { + t.Errorf("expected UserID 1, got %d", userCtx1.UserID) + } + + // Second call - should use cache, no database call expected + userCtx2, err := auth.Authenticate(req) + if err != nil { + t.Fatalf("second authenticate failed: %v", err) + } + if userCtx2.UserID != 1 { + t.Errorf("expected UserID 1, got %d", userCtx2.UserID) + } + + // Verify no unexpected database calls + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } + }) + + t.Run("cache expiration triggers database call", func(t *testing.T) { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer expire-token-456") + + // First call - populate cache + rows1 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":2,"user_name":"expireuser","session_id":"expire-token-456"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("expire-token-456", "authenticate"). + WillReturnRows(rows1) + + _, err := auth.Authenticate(req) + if err != nil { + t.Fatalf("first authenticate failed: %v", err) + } + + // Wait for cache to expire + time.Sleep(150 * time.Millisecond) + + // Second call - cache expired, should hit database again + rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":2,"user_name":"expireuser","session_id":"expire-token-456"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("expire-token-456", "authenticate"). + WillReturnRows(rows2) + + _, err = auth.Authenticate(req) + if err != nil { + t.Fatalf("second authenticate after expiration failed: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } + }) + + t.Run("logout clears cache", func(t *testing.T) { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer logout-token-789") + + // First call - populate cache + rows1 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":3,"user_name":"logoutuser","session_id":"logout-token-789"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("logout-token-789", "authenticate"). + WillReturnRows(rows1) + + _, err := auth.Authenticate(req) + if err != nil { + t.Fatalf("authenticate failed: %v", err) + } + + // Logout - should clear cache + logoutRows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}). + AddRow(true, nil, nil) + + mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_logout`). + WithArgs(sqlmock.AnyArg()). + WillReturnRows(logoutRows) + + err = auth.Logout(context.Background(), LogoutRequest{ + Token: "logout-token-789", + UserID: 3, + }) + if err != nil { + t.Fatalf("logout failed: %v", err) + } + + // Next authenticate should hit database again since cache was cleared + rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":3,"user_name":"logoutuser","session_id":"logout-token-789"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("logout-token-789", "authenticate"). + WillReturnRows(rows2) + + _, err = auth.Authenticate(req) + if err != nil { + t.Fatalf("authenticate after logout failed: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } + }) + + t.Run("manual cache clear", func(t *testing.T) { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer manual-clear-token") + + // Populate cache + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":4,"user_name":"clearuser","session_id":"manual-clear-token"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("manual-clear-token", "authenticate"). + WillReturnRows(rows) + + _, err := auth.Authenticate(req) + if err != nil { + t.Fatalf("authenticate failed: %v", err) + } + + // Manually clear cache + auth.ClearCache("manual-clear-token") + + // Next call should hit database + rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":4,"user_name":"clearuser","session_id":"manual-clear-token"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("manual-clear-token", "authenticate"). + WillReturnRows(rows2) + + _, err = auth.Authenticate(req) + if err != nil { + t.Fatalf("authenticate after cache clear failed: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } + }) + + t.Run("clear user cache", func(t *testing.T) { + // Populate cache with multiple tokens for the same user + req1 := httptest.NewRequest("GET", "/test", nil) + req1.Header.Set("Authorization", "Bearer user-token-1") + + rows1 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":5,"user_name":"multiuser","session_id":"user-token-1"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("user-token-1", "authenticate"). + WillReturnRows(rows1) + + _, err := auth.Authenticate(req1) + if err != nil { + t.Fatalf("first authenticate failed: %v", err) + } + + req2 := httptest.NewRequest("GET", "/test", nil) + req2.Header.Set("Authorization", "Bearer user-token-2") + + rows2 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":5,"user_name":"multiuser","session_id":"user-token-2"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("user-token-2", "authenticate"). + WillReturnRows(rows2) + + _, err = auth.Authenticate(req2) + if err != nil { + t.Fatalf("second authenticate failed: %v", err) + } + + // Clear all cache entries for user 5 + auth.ClearUserCache(5) + + // Both tokens should now require database calls + rows3 := sqlmock.NewRows([]string{"p_success", "p_error", "p_user"}). + AddRow(true, nil, `{"user_id":5,"user_name":"multiuser","session_id":"user-token-1"}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). + WithArgs("user-token-1", "authenticate"). + WillReturnRows(rows3) + + _, err = auth.Authenticate(req1) + if err != nil { + t.Fatalf("authenticate after user cache clear failed: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } + }) +} + // Test DatabaseAuthenticator func TestDatabaseAuthenticator(t *testing.T) { db, mock, err := sqlmock.New() @@ -281,7 +520,7 @@ func TestDatabaseAuthenticator(t *testing.T) { AddRow(true, nil, `{"user_id":2,"user_name":"cookieuser","session_id":"cookie-token-456"}`) mock.ExpectQuery(`SELECT p_success, p_error, p_user::text FROM resolvespec_session`). - WithArgs("cookie-token-456", "authenticate"). + WithArgs("cookie-token-456", "cookie"). WillReturnRows(rows) userCtx, err := auth.Authenticate(req)