first commit
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
// Package middleware contains chi-compatible HTTP middleware: functions
|
||||
// matching the shape func(http.Handler) http.Handler, each wrapping the
|
||||
// next handler in the chain to add some cross-cutting behavior (logging,
|
||||
// authentication, ...) before and/or after the real handler runs.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
// Aliased to chimw so it doesn't collide with this package's own name
|
||||
// ("middleware") when referenced from other files/packages.
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// RequestLogger is a middleware FACTORY: a function that takes the
|
||||
// dependencies it needs (here, just a logger) and returns the actual
|
||||
// middleware function chi expects. This extra layer exists because chi's
|
||||
// r.Use() only accepts func(http.Handler) http.Handler - there's no room
|
||||
// to pass in a logger directly, so we wrap it in an outer function that
|
||||
// captures the logger in a closure instead.
|
||||
//
|
||||
// There are three layers of function here, each running at a different
|
||||
// time:
|
||||
//
|
||||
// RequestLogger(logger) -> runs ONCE, when building the router
|
||||
// func(next http.Handler) ... -> runs ONCE, when chi wires up the chain
|
||||
// func(w, r) { ... } -> runs on EVERY request
|
||||
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Capture the start time BEFORE the request is handled, so we
|
||||
// can measure total duration afterward.
|
||||
start := time.Now()
|
||||
|
||||
// A plain http.ResponseWriter only lets you WRITE a status
|
||||
// code/body - it doesn't let you read back what was written
|
||||
// afterward. WrapResponseWriter adds that: once the handler
|
||||
// below has run, ww.Status() and ww.BytesWritten() become
|
||||
// available. We must pass ww (not w) to next.ServeHTTP so the
|
||||
// wrapping actually captures what gets written downstream.
|
||||
ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
// Run the rest of the middleware chain / the final handler.
|
||||
// Everything above this line happens BEFORE the request is
|
||||
// handled; everything below happens AFTER the response has
|
||||
// been written.
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
// Emit one structured JSON log line per request, with typed
|
||||
// fields (slog.String, slog.Int, slog.Duration) so each one
|
||||
// becomes an independently queryable key once these logs land
|
||||
// in Loki via Grafana Alloy.
|
||||
logger.Info("http_request",
|
||||
// GetReqID reads back the request ID that chi's own
|
||||
// RequestID middleware (registered earlier in the chain,
|
||||
// see router.go) attached to the request's context - this
|
||||
// lets you correlate every log line belonging to one
|
||||
// specific request.
|
||||
slog.String("request_id", chimw.GetReqID(r.Context())),
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
slog.Int("bytes", ww.BytesWritten()),
|
||||
slog.Duration("duration_ms", time.Since(start)),
|
||||
slog.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
|
||||
)
|
||||
|
||||
// contextKey is a private, unexported type used as the type of our context
|
||||
// key below. This is a well-known Go idiom to avoid key collisions: since
|
||||
// context.WithValue keys are compared by BOTH type and value, using our
|
||||
// own named type (instead of a plain string) guarantees userContextKey can
|
||||
// never accidentally collide with a key defined by another package, even
|
||||
// if the underlying text happened to be identical.
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "current_user"
|
||||
|
||||
// RequireAuth is a middleware factory (same three-layer shape as
|
||||
// RequestLogger) that protects a route: it checks the caller's session for
|
||||
// a logged-in user ID, loads the full user from the database, and - only
|
||||
// if that all succeeds - stores the user in the request's context and lets
|
||||
// the request continue. If anything fails, it responds 401 immediately and
|
||||
// the wrapped handler never runs at all.
|
||||
func RequireAuth(sessions *scs.SessionManager, userRepo *models.UserRepository, logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// GetInt returns the zero value (0) if the key was never set
|
||||
// in the session - which is exactly what happens for a
|
||||
// visitor who never logged in.
|
||||
userID := sessions.GetInt(r.Context(), session.UserIDKey)
|
||||
if userID == 0 {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userRepo.FindByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
// Covers both "no such user" (e.g. the account was
|
||||
// deleted after this session was created) and genuine
|
||||
// database errors - either way, this request cannot
|
||||
// proceed as authenticated.
|
||||
logger.Error("require auth: find user failed", "error", err, "user_id", userID)
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
// context.WithValue returns a NEW context wrapping the old
|
||||
// one plus our key/value pair - contexts are immutable, you
|
||||
// can't add to an existing one in place. Similarly,
|
||||
// r.WithContext returns a NEW *http.Request carrying that
|
||||
// context; we pass that new request onward so downstream
|
||||
// handlers can read the user back out.
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentUser lets handlers retrieve the authenticated user that
|
||||
// RequireAuth already loaded and stashed in the request's context.
|
||||
// Handlers never need to know about userContextKey directly (it's
|
||||
// unexported - only this file can create or read that specific key) - they
|
||||
// just call this function.
|
||||
func CurrentUser(r *http.Request) *models.User {
|
||||
// Value() returns `any`, so we need a type assertion to get back a
|
||||
// concrete *models.User. The two-value form (`user, ok := ...`) is the
|
||||
// SAFE version: ok is false if the assertion fails (wrong type, or the
|
||||
// key simply isn't present) instead of panicking - always prefer this
|
||||
// form when the value's presence isn't 100% guaranteed.
|
||||
user, ok := r.Context().Value(userContextKey).(*models.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// writeUnauthorized writes a plain {"error":"unauthorized"} 401 response.
|
||||
// Written by hand (instead of reusing handlers.writeError) because
|
||||
// internal/middleware and internal/handlers are separate packages, and
|
||||
// writeError is unexported in the handlers package - a deliberate package
|
||||
// boundary, not an oversight.
|
||||
func writeUnauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
}
|
||||
Reference in New Issue
Block a user