diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 6514660..a12c522 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -23,6 +23,15 @@ func Init(dev bool) { } +func UpdateLoggerPath(path string, dev bool) { + defaultConfig := zap.NewProductionConfig() + if dev { + defaultConfig = zap.NewDevelopmentConfig() + } + defaultConfig.OutputPaths = []string{path} + UpdateLogger(&defaultConfig) +} + func UpdateLogger(config *zap.Config) { defaultConfig := zap.NewProductionConfig() defaultConfig.OutputPaths = []string{"resolvespec.log"} diff --git a/pkg/security/QUICK_REFERENCE.md b/pkg/security/QUICK_REFERENCE.md index b1c6d4f..9eb1979 100644 --- a/pkg/security/QUICK_REFERENCE.md +++ b/pkg/security/QUICK_REFERENCE.md @@ -91,7 +91,8 @@ security.UserContext{ RemoteID: "remote_xyz", // Remote system ID Roles: []string{"admin"}, // User roles Email: "john@example.com", // User email - Claims: map[string]any{}, // Additional metadata + Claims: map[string]any{}, // Additional authentication claims + Meta: map[string]any{}, // Additional metadata (JSON-serializable) } ``` @@ -621,6 +622,67 @@ func main() { --- +## Authentication Modes + +```go +// Required authentication (default) +// Authentication must succeed or returns 401 +router.Use(security.NewAuthMiddleware(securityList)) + +// Skip authentication for specific routes +// Always sets guest user context +func PublicRoute(w http.ResponseWriter, r *http.Request) { + ctx := security.SkipAuth(r.Context()) + r = r.WithContext(ctx) + // Guest context will be set +} + +// Optional authentication for specific routes +// Tries to authenticate, falls back to guest if it fails +func HomeRoute(w http.ResponseWriter, r *http.Request) { + ctx := security.OptionalAuth(r.Context()) + r = r.WithContext(ctx) + + userCtx, _ := security.GetUserContext(r.Context()) + if userCtx.UserID == 0 { + // Guest user + } else { + // Authenticated user + } +} +``` + +**Comparison:** +- **Required**: Auth must succeed or return 401 (default) +- **SkipAuth**: Never tries to authenticate, always guest +- **OptionalAuth**: Tries to authenticate, guest on failure + +--- + +## Standalone Handlers + +```go +// NewAuthHandler - Required authentication (returns 401 on failure) +authHandler := security.NewAuthHandler(securityList, myHandler) +http.Handle("/api/protected", authHandler) + +// NewOptionalAuthHandler - Optional authentication (guest on failure) +optionalHandler := security.NewOptionalAuthHandler(securityList, myHandler) +http.Handle("/home", optionalHandler) + +// Example handler +func myHandler(w http.ResponseWriter, r *http.Request) { + userCtx, _ := security.GetUserContext(r.Context()) + if userCtx.UserID == 0 { + // Guest user + } else { + // Authenticated user + } +} +``` + +--- + ## Context Helpers ```go @@ -635,6 +697,7 @@ sessionID, ok := security.GetSessionID(ctx) remoteID, ok := security.GetRemoteID(ctx) roles, ok := security.GetUserRoles(ctx) email, ok := security.GetUserEmail(ctx) +meta, ok := security.GetUserMeta(ctx) ``` --- diff --git a/pkg/security/README.md b/pkg/security/README.md index 054a878..8ad2e12 100644 --- a/pkg/security/README.md +++ b/pkg/security/README.md @@ -118,14 +118,15 @@ Enhanced user context with complete user information: ```go type UserContext struct { - UserID int // User's unique ID - UserName string // Username - UserLevel int // User privilege level - SessionID string // Current session ID - RemoteID string // Remote system ID - Roles []string // User roles - Email string // User email - Claims map[string]any // Additional metadata + UserID int // User's unique ID + UserName string // Username + UserLevel int // User privilege level + SessionID string // Current session ID + RemoteID string // Remote system ID + Roles []string // User roles + Email string // User email + Claims map[string]any // Additional authentication claims + Meta map[string]any // Additional metadata (can hold any JSON-serializable values) } ``` @@ -629,6 +630,142 @@ func (p *MyProvider) GetRowSecurity(ctx context.Context, userID int, schema, tab } ``` +## Middleware and Handler API + +### NewAuthMiddleware +Standard middleware that authenticates all requests: + +```go +router.Use(security.NewAuthMiddleware(securityList)) +``` + +Routes can skip authentication using the `SkipAuth` helper: + +```go +func PublicHandler(w http.ResponseWriter, r *http.Request) { + ctx := security.SkipAuth(r.Context()) + // This route will bypass authentication + // A guest user context will be set instead +} + +router.Handle("/public", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := security.SkipAuth(r.Context()) + PublicHandler(w, r.WithContext(ctx)) +})) +``` + +When authentication is skipped, a guest user context is automatically set: +- UserID: 0 +- UserName: "guest" +- Roles: ["guest"] +- RemoteID: Request's remote address + +Routes can use optional authentication with the `OptionalAuth` helper: + +```go +func OptionalAuthHandler(w http.ResponseWriter, r *http.Request) { + ctx := security.OptionalAuth(r.Context()) + r = r.WithContext(ctx) + + // This route will try to authenticate + // If authentication succeeds, authenticated user context is set + // If authentication fails, guest user context is set instead + + userCtx, _ := security.GetUserContext(r.Context()) + if userCtx.UserID == 0 { + // Guest user + fmt.Fprintf(w, "Welcome, guest!") + } else { + // Authenticated user + fmt.Fprintf(w, "Welcome back, %s!", userCtx.UserName) + } +} + +router.Handle("/home", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := security.OptionalAuth(r.Context()) + OptionalAuthHandler(w, r.WithContext(ctx)) +})) +``` + +**Authentication Modes Summary:** +- **Required (default)**: Authentication must succeed or returns 401 +- **SkipAuth**: Bypasses authentication entirely, always sets guest context +- **OptionalAuth**: Tries authentication, falls back to guest context if it fails + +### NewAuthHandler + +Standalone authentication handler (without middleware wrapping): + +```go +// Use when you need authentication logic without middleware +authHandler := security.NewAuthHandler(securityList, myHandler) +http.Handle("/api/protected", authHandler) +``` + +### NewOptionalAuthHandler + +Standalone optional authentication handler that tries to authenticate but falls back to guest: + +```go +// Use for routes that should work for both authenticated and guest users +optionalHandler := security.NewOptionalAuthHandler(securityList, myHandler) +http.Handle("/home", optionalHandler) + +// Example handler that checks user context +func myHandler(w http.ResponseWriter, r *http.Request) { + userCtx, _ := security.GetUserContext(r.Context()) + if userCtx.UserID == 0 { + fmt.Fprintf(w, "Welcome, guest!") + } else { + fmt.Fprintf(w, "Welcome back, %s!", userCtx.UserName) + } +} +``` + +### Helper Functions + +Extract user information from context: + +```go +// Get full user context +userCtx, ok := security.GetUserContext(ctx) + +// Get specific fields +userID, ok := security.GetUserID(ctx) +userName, ok := security.GetUserName(ctx) +userLevel, ok := security.GetUserLevel(ctx) +sessionID, ok := security.GetSessionID(ctx) +remoteID, ok := security.GetRemoteID(ctx) +roles, ok := security.GetUserRoles(ctx) +email, ok := security.GetUserEmail(ctx) +meta, ok := security.GetUserMeta(ctx) +``` + +### Metadata Support + +The `Meta` field in `UserContext` can hold any JSON-serializable values: + +```go +// Set metadata during login +loginReq := security.LoginRequest{ + Username: "user@example.com", + Password: "password", + Meta: map[string]any{ + "department": "engineering", + "location": "US", + "preferences": map[string]any{ + "theme": "dark", + }, + }, +} + +// Access metadata in handlers +meta, ok := security.GetUserMeta(ctx) +if ok { + department := meta["department"].(string) +} +``` + ## License Part of the ResolveSpec project. diff --git a/pkg/security/examples.go b/pkg/security/examples.go index c1557b4..24e4485 100644 --- a/pkg/security/examples.go +++ b/pkg/security/examples.go @@ -54,6 +54,8 @@ func (a *HeaderAuthenticatorExample) Authenticate(r *http.Request) (*UserContext RemoteID: r.Header.Get("X-Remote-ID"), Email: r.Header.Get("X-User-Email"), Roles: parseRoles(r.Header.Get("X-User-Roles")), + Claims: make(map[string]any), + Meta: make(map[string]any), }, nil } @@ -125,6 +127,8 @@ func (a *JWTAuthenticatorExample) Login(ctx context.Context, req LoginRequest) ( Email: user.Email, UserLevel: user.UserLevel, Roles: parseRoles(user.Roles), + Claims: req.Claims, + Meta: req.Meta, }, ExpiresIn: int64(24 * time.Hour.Seconds()), }, nil @@ -242,6 +246,9 @@ func (a *DatabaseAuthenticatorExample) Login(ctx context.Context, req LoginReque Email: user.Email, UserLevel: user.UserLevel, Roles: parseRoles(user.Roles), + SessionID: sessionToken, + Claims: req.Claims, + Meta: req.Meta, }, ExpiresIn: int64(24 * time.Hour.Seconds()), }, nil @@ -320,6 +327,8 @@ func (a *DatabaseAuthenticatorExample) Authenticate(r *http.Request) (*UserConte UserLevel: session.UserLevel, SessionID: sessionToken, Roles: parseRoles(session.Roles), + Claims: make(map[string]any), + Meta: make(map[string]any), }, nil } @@ -370,9 +379,12 @@ func (a *DatabaseAuthenticatorExample) RefreshToken(ctx context.Context, refresh return &LoginResponse{ Token: newSessionToken, User: &UserContext{ - UserID: session.UserID, - UserName: session.Username, - Email: session.Email, + UserID: session.UserID, + UserName: session.Username, + Email: session.Email, + SessionID: newSessionToken, + Claims: make(map[string]any), + Meta: make(map[string]any), }, ExpiresIn: int64(24 * time.Hour.Seconds()), }, nil diff --git a/pkg/security/interfaces.go b/pkg/security/interfaces.go index 675a8fc..d68fbc9 100644 --- a/pkg/security/interfaces.go +++ b/pkg/security/interfaces.go @@ -7,35 +7,37 @@ import ( // UserContext holds authenticated user information type UserContext struct { - UserID int - UserName string - UserLevel int - SessionID string - RemoteID string - Roles []string - Email string - Claims map[string]any + UserID int `json:"user_id"` + UserName string `json:"user_name"` + UserLevel int `json:"user_level"` + SessionID string `json:"session_id"` + RemoteID string `json:"remote_id"` + Roles []string `json:"roles"` + Email string `json:"email"` + Claims map[string]any `json:"claims"` + Meta map[string]any `json:"meta"` // Additional metadata that can hold any JSON-serializable values } // LoginRequest contains credentials for login type LoginRequest struct { - Username string - Password string - Claims map[string]any // Additional login data + Username string `json:"username"` + Password string `json:"password"` + Claims map[string]any `json:"claims"` // Additional login data + Meta map[string]any `json:"meta"` // Additional metadata to be set on user context } // LoginResponse contains the result of a login attempt type LoginResponse struct { - Token string - RefreshToken string - User *UserContext - ExpiresIn int64 // Token expiration in seconds + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + User *UserContext `json:"user"` + ExpiresIn int64 `json:"expires_in"` // Token expiration in seconds } // LogoutRequest contains information for logout type LogoutRequest struct { - Token string - UserID int + Token string `json:"token"` + UserID int `json:"user_id"` } // Authenticator handles user authentication operations diff --git a/pkg/security/middleware.go b/pkg/security/middleware.go index 3d5aaf9..2f73683 100644 --- a/pkg/security/middleware.go +++ b/pkg/security/middleware.go @@ -10,21 +10,145 @@ type contextKey string const ( // Context keys for user information - UserIDKey contextKey = "user_id" - UserNameKey contextKey = "user_name" - UserLevelKey contextKey = "user_level" - SessionIDKey contextKey = "session_id" - RemoteIDKey contextKey = "remote_id" - UserRolesKey contextKey = "user_roles" - UserEmailKey contextKey = "user_email" - UserContextKey contextKey = "user_context" + UserIDKey contextKey = "user_id" + UserNameKey contextKey = "user_name" + UserLevelKey contextKey = "user_level" + SessionIDKey contextKey = "session_id" + RemoteIDKey contextKey = "remote_id" + UserRolesKey contextKey = "user_roles" + UserEmailKey contextKey = "user_email" + UserContextKey contextKey = "user_context" + UserMetaKey contextKey = "user_meta" + SkipAuthKey contextKey = "skip_auth" + OptionalAuthKey contextKey = "optional_auth" ) +// SkipAuth returns a context with skip auth flag set to true +// Use this to mark routes that should bypass authentication middleware +func SkipAuth(ctx context.Context) context.Context { + return context.WithValue(ctx, SkipAuthKey, true) +} + +// OptionalAuth returns a context with optional auth flag set to true +// Use this to mark routes that should try to authenticate, but fall back to guest if authentication fails +func OptionalAuth(ctx context.Context) context.Context { + return context.WithValue(ctx, OptionalAuthKey, true) +} + +// createGuestContext creates a guest user context for unauthenticated requests +func createGuestContext(r *http.Request) *UserContext { + return &UserContext{ + UserID: 0, + UserName: "guest", + UserLevel: 0, + SessionID: "", + RemoteID: r.RemoteAddr, + Roles: []string{"guest"}, + Email: "", + Claims: map[string]any{}, + Meta: map[string]any{}, + } +} + +// setUserContext adds a user context to the request context +func setUserContext(r *http.Request, userCtx *UserContext) *http.Request { + ctx := r.Context() + ctx = context.WithValue(ctx, UserContextKey, userCtx) + ctx = context.WithValue(ctx, UserIDKey, userCtx.UserID) + ctx = context.WithValue(ctx, UserNameKey, userCtx.UserName) + ctx = context.WithValue(ctx, UserLevelKey, userCtx.UserLevel) + ctx = context.WithValue(ctx, SessionIDKey, userCtx.SessionID) + ctx = context.WithValue(ctx, RemoteIDKey, userCtx.RemoteID) + ctx = context.WithValue(ctx, UserRolesKey, userCtx.Roles) + + if userCtx.Email != "" { + ctx = context.WithValue(ctx, UserEmailKey, userCtx.Email) + } + if len(userCtx.Meta) > 0 { + ctx = context.WithValue(ctx, UserMetaKey, userCtx.Meta) + } + + return r.WithContext(ctx) +} + +// authenticateRequest performs authentication and adds user context to the request +// This is the shared authentication logic used by both handler and middleware +func authenticateRequest(w http.ResponseWriter, r *http.Request, provider SecurityProvider) (*http.Request, bool) { + // Call the provider's Authenticate method + userCtx, err := provider.Authenticate(r) + if err != nil { + http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized) + return nil, false + } + + return setUserContext(r, userCtx), true +} + +// NewAuthHandler creates an authentication handler that can be used standalone +// This handler performs authentication and returns 401 if authentication fails +// Use this when you need authentication logic without middleware wrapping +func NewAuthHandler(securityList *SecurityList, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get the security provider + provider := securityList.Provider() + if provider == nil { + http.Error(w, "Security provider not configured", http.StatusInternalServerError) + return + } + + // Authenticate the request + authenticatedReq, ok := authenticateRequest(w, r, provider) + if !ok { + return // authenticateRequest already wrote the error response + } + + // Continue with authenticated context + next.ServeHTTP(w, authenticatedReq) + }) +} + +// NewOptionalAuthHandler creates an optional authentication handler that can be used standalone +// This handler tries to authenticate but falls back to guest context if authentication fails +// Use this for routes that should show personalized content for authenticated users but still work for guests +func NewOptionalAuthHandler(securityList *SecurityList, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get the security provider + provider := securityList.Provider() + if provider == nil { + http.Error(w, "Security provider not configured", http.StatusInternalServerError) + return + } + + // Try to authenticate + userCtx, err := provider.Authenticate(r) + if err != nil { + // Authentication failed - set guest context and continue + guestCtx := createGuestContext(r) + next.ServeHTTP(w, setUserContext(r, guestCtx)) + return + } + + // Authentication succeeded - set user context + next.ServeHTTP(w, setUserContext(r, userCtx)) + }) +} + // NewAuthMiddleware creates an authentication middleware with the given security list // This middleware extracts user authentication from the request and adds it to context +// Routes can skip authentication by setting SkipAuthKey context value (use SkipAuth helper) +// Routes can use optional authentication by setting OptionalAuthKey context value (use OptionalAuth helper) +// When authentication is skipped or fails with optional auth, a guest user context is set instead func NewAuthMiddleware(securityList *SecurityList) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if this route should skip authentication + if skip, ok := r.Context().Value(SkipAuthKey).(bool); ok && skip { + // Set guest user context for skipped routes + guestCtx := createGuestContext(r) + next.ServeHTTP(w, setUserContext(r, guestCtx)) + return + } + // Get the security provider provider := securityList.Provider() if provider == nil { @@ -32,31 +156,25 @@ func NewAuthMiddleware(securityList *SecurityList) func(http.Handler) http.Handl return } - // Call the provider's Authenticate method + // Check if this route has optional authentication + optional, _ := r.Context().Value(OptionalAuthKey).(bool) + + // Try to authenticate userCtx, err := provider.Authenticate(r) if err != nil { + if optional { + // Optional auth failed - set guest context and continue + guestCtx := createGuestContext(r) + next.ServeHTTP(w, setUserContext(r, guestCtx)) + return + } + // Required auth failed - return error http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized) return } - // Add user information to context - ctx := r.Context() - ctx = context.WithValue(ctx, UserContextKey, userCtx) - ctx = context.WithValue(ctx, UserIDKey, userCtx.UserID) - ctx = context.WithValue(ctx, UserNameKey, userCtx.UserName) - ctx = context.WithValue(ctx, UserLevelKey, userCtx.UserLevel) - ctx = context.WithValue(ctx, SessionIDKey, userCtx.SessionID) - ctx = context.WithValue(ctx, RemoteIDKey, userCtx.RemoteID) - - if len(userCtx.Roles) > 0 { - ctx = context.WithValue(ctx, UserRolesKey, userCtx.Roles) - } - if userCtx.Email != "" { - ctx = context.WithValue(ctx, UserEmailKey, userCtx.Email) - } - - // Continue with authenticated context - next.ServeHTTP(w, r.WithContext(ctx)) + // Authentication succeeded - set user context + next.ServeHTTP(w, setUserContext(r, userCtx)) }) } } @@ -119,3 +237,9 @@ func GetUserEmail(ctx context.Context) (string, bool) { email, ok := ctx.Value(UserEmailKey).(string) return email, ok } + +// GetUserMeta extracts user metadata from context +func GetUserMeta(ctx context.Context) (map[string]any, bool) { + meta, ok := ctx.Value(UserMetaKey).(map[string]any) + return meta, ok +} diff --git a/pkg/security/provider.go b/pkg/security/provider.go index 788e603..ce8828c 100644 --- a/pkg/security/provider.go +++ b/pkg/security/provider.go @@ -15,26 +15,26 @@ import ( ) type ColumnSecurity struct { - Schema string - Tablename string - Path []string - ExtraFilters map[string]string - UserID int - Accesstype string `json:"accesstype"` - MaskStart int - MaskEnd int - MaskInvert bool - MaskChar string - Control string `json:"control"` - ID int `json:"id"` + Schema string `json:"schema"` + Tablename string `json:"tablename"` + Path []string `json:"path"` + ExtraFilters map[string]string `json:"extra_filters"` + UserID int `json:"user_id"` + Accesstype string `json:"accesstype"` + MaskStart int `json:"mask_start"` + MaskEnd int `json:"mask_end"` + MaskInvert bool `json:"mask_invert"` + MaskChar string `json:"mask_char"` + Control string `json:"control"` + ID int `json:"id"` } type RowSecurity struct { - Schema string - Tablename string - Template string - HasBlock bool - UserID int + Schema string `json:"schema"` + Tablename string `json:"tablename"` + Template string `json:"template"` + HasBlock bool `json:"has_block"` + UserID int `json:"user_id"` } func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string {