mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-13 17:10:36 +00:00
Database Authenticator with cache
This commit is contained in:
parent
b2115038f2
commit
0f05202438
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user