diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..0423bd4 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,106 @@ +// Package api contains HTTP handlers, middleware, and request/response DTOs. +package api + +import ( + "crypto/rand" + "crypto/subtle" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/shcizo/package-updater/internal/logging" +) + +// Auth returns middleware that requires a matching bearer token. +// Compares with constant-time to defeat timing attacks. +func Auth(token string) func(http.Handler) http.Handler { + tokenBytes := []byte(token) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(h, prefix) { + writeAuthError(w) + return + } + provided := []byte(strings.TrimPrefix(h, prefix)) + if len(provided) == 0 || + subtle.ConstantTimeCompare(provided, tokenBytes) != 1 { + writeAuthError(w) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func writeAuthError(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) +} + +// newUUID generates a random UUID v4 string using crypto/rand. +func newUUID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant bits + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// RequestID middleware ensures every request has an X-Request-ID +// header (generated if absent) and stores it in the request context. +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-ID") + if id == "" { + id = newUUID() + } + w.Header().Set("X-Request-ID", id) + ctx := logging.WithRequestID(r.Context(), id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequestLogger emits a structured access-log line per request. +func RequestLogger(base *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + logger := logging.FromContext(r.Context(), base) + logger.Info("http_request", + "method", r.Method, + "path", r.URL.Path, + "status", sw.status, + "duration_ms", time.Since(start).Milliseconds(), + "client_ip", clientIP(r), + ) + }) + } +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (s *statusWriter) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if comma := strings.Index(xff, ","); comma >= 0 { + return strings.TrimSpace(xff[:comma]) + } + return xff + } + return r.RemoteAddr +} diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go new file mode 100644 index 0000000..d042ba3 --- /dev/null +++ b/internal/api/middleware_test.go @@ -0,0 +1,92 @@ +package api_test + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/shcizo/package-updater/internal/api" + "github.com/shcizo/package-updater/internal/logging" + "github.com/stretchr/testify/require" +) + +func newTestLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(io.Discard, nil)) +} + +func TestAuth_AllowsMatchingToken(t *testing.T) { + called := false + h := api.Auth("secret")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodPost, "/update", nil) + req.Header.Set("Authorization", "Bearer secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.True(t, called) + require.Equal(t, http.StatusOK, w.Code) +} + +func TestAuth_Rejects(t *testing.T) { + cases := []struct{ name, header string }{ + {"missing", ""}, + {"wrong scheme", "Token secret"}, + {"wrong value", "Bearer nope"}, + {"empty bearer", "Bearer "}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := api.Auth("secret")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("handler must not be called") + })) + req := httptest.NewRequest(http.MethodPost, "/update", nil) + if c.header != "" { + req.Header.Set("Authorization", c.header) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusUnauthorized, w.Code) + }) + } +} + +func TestRequestID_GeneratesIfMissing(t *testing.T) { + var seenID string + h := api.RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seenID = logging.RequestIDFrom(r.Context()) + })) + req := httptest.NewRequest(http.MethodPost, "/update", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.NotEmpty(t, seenID) + require.Equal(t, seenID, w.Header().Get("X-Request-ID")) +} + +func TestRequestID_UsesIncoming(t *testing.T) { + var seenID string + h := api.RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seenID = logging.RequestIDFrom(r.Context()) + })) + req := httptest.NewRequest(http.MethodPost, "/update", nil) + req.Header.Set("X-Request-ID", "given-id") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, "given-id", seenID) +} + +func TestRequestLogger_LogsAndDelegates(t *testing.T) { + logger := newTestLogger() + called := false + h := api.RequestLogger(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + req := httptest.NewRequest(http.MethodPost, "/update", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.True(t, called) + require.Equal(t, http.StatusTeapot, w.Code) +}