mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 05:11:34 +00:00
This commit addresses all the high-priority tasks outlined in the TASKS.md file, significantly improving the application's observability, completing key features, and refactoring critical parts of the codebase. ### Observability - **Centralized Logging:** Implemented a new structured, context-aware logging system using `zerolog`. A new logging middleware injects request-specific information (request ID, user ID, trace ID) into the logger, and all application logging has been refactored to use this new system. - **Prometheus Metrics:** Added Prometheus metrics for database query performance by creating a GORM plugin that automatically records query latency and totals. - **OpenTelemetry Tracing:** Fully instrumented all application services in `internal/app` and data repositories in `internal/data/sql` with OpenTelemetry tracing, providing deep visibility into application performance. ### Features - **Analytics:** Implemented like, comment, and bookmark counting. The respective command handlers now call the analytics service to increment counters when these actions are performed. - **Enrichment Tool:** Built a new, extensible `enrich` command-line tool to fetch data from external sources. The initial implementation enriches author data using the Open Library API. ### Refactoring & Fixes - **Decoupled Testing:** Refactored the testing utilities in `internal/testutil` to be database-agnostic, promoting the use of mock-based unit tests and improving test speed and reliability. - **Build Fixes:** Resolved numerous build errors, including a critical import cycle between the logging, observability, and authentication packages. - **Search Service:** Fixed the search service integration by implementing the `GetWorkContent` method in the localization service, allowing the search indexer to correctly fetch and index work content.
197 lines
5.5 KiB
Go
197 lines
5.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"tercul/internal/observability"
|
|
"tercul/internal/platform/log"
|
|
)
|
|
|
|
// ContextKey is a type for context keys
|
|
type ContextKey string
|
|
|
|
const (
|
|
// UserContextKey is the key for user in context
|
|
UserContextKey ContextKey = "user"
|
|
// ClaimsContextKey is the key for claims in context
|
|
ClaimsContextKey ContextKey = "claims"
|
|
)
|
|
|
|
// AuthMiddleware creates middleware for JWT authentication
|
|
func AuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
logger := log.FromContext(r.Context())
|
|
// Skip authentication for certain paths
|
|
if shouldSkipAuth(r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Extract token from Authorization header
|
|
authHeader := r.Header.Get("Authorization")
|
|
tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader)
|
|
if err != nil {
|
|
logger.Warn("Authentication failed - missing or invalid token")
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Validate token
|
|
claims, err := jwtManager.ValidateToken(tokenString)
|
|
if err != nil {
|
|
logger.Warn("Authentication failed - invalid token")
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Add claims to context
|
|
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// RoleMiddleware creates middleware for role-based authorization
|
|
func RoleMiddleware(requiredRole string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
logger := log.FromContext(r.Context())
|
|
claims, ok := r.Context().Value(ClaimsContextKey).(*Claims)
|
|
if !ok {
|
|
logger.Warn("Authorization failed - no claims in context")
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
jwtManager := NewJWTManager()
|
|
if err := jwtManager.RequireRole(claims.Role, requiredRole); err != nil {
|
|
logger.With("user_role", claims.Role).With("required_role", requiredRole).Warn("Authorization failed - insufficient role")
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// GraphQLAuthMiddleware creates middleware specifically for GraphQL requests
|
|
func GraphQLAuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
logger := log.FromContext(r.Context())
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader)
|
|
if err != nil {
|
|
logger.Error(err, "GraphQL authentication failed - could not extract token")
|
|
next.ServeHTTP(w, r) // Proceed without auth
|
|
return
|
|
}
|
|
|
|
claims, err := jwtManager.ValidateToken(tokenString)
|
|
if err != nil {
|
|
logger.Error(err, "GraphQL authentication failed - invalid token")
|
|
next.ServeHTTP(w, r) // Proceed without auth
|
|
return
|
|
}
|
|
|
|
// Add claims and enriched logger to context for authenticated requests
|
|
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
|
|
enrichedLogger := logger.With("user_id", claims.UserID)
|
|
ctx = context.WithValue(ctx, observability.LoggerContextKey, enrichedLogger)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// GetClaimsFromContext extracts claims from context
|
|
func GetClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
|
claims, ok := ctx.Value(ClaimsContextKey).(*Claims)
|
|
return claims, ok
|
|
}
|
|
|
|
// GetUserIDFromContext extracts user ID from context
|
|
func GetUserIDFromContext(ctx context.Context) (uint, bool) {
|
|
claims, ok := GetClaimsFromContext(ctx)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return claims.UserID, true
|
|
}
|
|
|
|
// IsAuthenticated checks if the request is authenticated
|
|
func IsAuthenticated(ctx context.Context) bool {
|
|
_, ok := GetClaimsFromContext(ctx)
|
|
return ok
|
|
}
|
|
|
|
// RequireAuth ensures the request is authenticated
|
|
func RequireAuth(ctx context.Context) (*Claims, error) {
|
|
claims, ok := GetClaimsFromContext(ctx)
|
|
if !ok {
|
|
return nil, ErrMissingToken
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// RequireRole ensures the user has the required role
|
|
func RequireRole(ctx context.Context, requiredRole string) (*Claims, error) {
|
|
claims, err := RequireAuth(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
jwtManager := NewJWTManager()
|
|
if err := jwtManager.RequireRole(claims.Role, requiredRole); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// shouldSkipAuth determines if authentication should be skipped for a path
|
|
func shouldSkipAuth(path string) bool {
|
|
skipPaths := []string{
|
|
"/",
|
|
"/query",
|
|
"/health",
|
|
"/metrics",
|
|
"/favicon.ico",
|
|
}
|
|
|
|
for _, skipPath := range skipPaths {
|
|
if path == skipPath {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Skip static files
|
|
if strings.HasPrefix(path, "/static/") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ContextWithUserID adds a user ID to the context for testing purposes.
|
|
func ContextWithUserID(ctx context.Context, userID uint) context.Context {
|
|
claims := &Claims{UserID: userID}
|
|
return context.WithValue(ctx, ClaimsContextKey, claims)
|
|
}
|
|
|
|
// ContextWithAdminUser adds an admin user to the context for testing purposes.
|
|
func ContextWithAdminUser(ctx context.Context, userID uint) context.Context {
|
|
claims := &Claims{
|
|
UserID: userID,
|
|
Role: "admin",
|
|
}
|
|
return context.WithValue(ctx, ClaimsContextKey, claims)
|
|
}
|