feat(api): auth, request_id, and request-logging middleware
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user