From 782247a239bd172ec11f0757c90a2fbec5139b2a Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 28 Apr 2026 15:34:38 +0800 Subject: [PATCH] feat(http/mw): bearer token auth middleware with optional mode --- go.mod | 2 +- internal/transport/http/middleware/auth.go | 109 ++++++++++++++++++ .../transport/http/middleware/auth_test.go | 79 +++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 internal/transport/http/middleware/auth.go create mode 100644 internal/transport/http/middleware/auth_test.go diff --git a/go.mod b/go.mod index c2089fc..e5f2ace 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.2.5 github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 github.com/oklog/ulid/v2 v2.1.1 github.com/spf13/viper v1.21.0 @@ -34,7 +35,6 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/internal/transport/http/middleware/auth.go b/internal/transport/http/middleware/auth.go new file mode 100644 index 0000000..99c4d18 --- /dev/null +++ b/internal/transport/http/middleware/auth.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/google/uuid" +) + +// SessionResolver is the contract Auth depends on to translate a bearer +// token into an authenticated user ID. Defining the dependency as an +// interface here (rather than importing user.Service directly) keeps the +// transport layer free of any concrete user-package import, which is +// essential because the user module is implemented in a later phase and +// this middleware must be writeable and testable without it. +// +// Implementations must: +// - return (uid, true, nil) on a valid session, +// - return (uuid.Nil, false, nil) for a syntactically valid but unknown +// or expired token (the middleware treats this the same as an error +// for the client: 401), +// - return a non-nil error for unexpected failures (e.g. database +// outage); the middleware also returns 401 to avoid leaking which +// branch failed. +type SessionResolver interface { + ResolveSession(ctx context.Context, token string) (userID uuid.UUID, ok bool, err error) +} + +// AuthOptions configures the Auth middleware. A zero value means +// "authentication required". Optional=true is intended for endpoints +// where an anonymous caller is allowed (e.g. a public landing page that +// personalises content when a user happens to be signed in). +// +// The struct exists rather than a bare bool so future extensions +// (RBAC scope checks, multi-realm support, etc.) can be added without +// breaking call sites. +type AuthOptions struct { + // Optional, when true, lets requests without an Authorization header + // pass through with no user_id in the context. Requests that DO carry + // a token are still validated; an invalid token is rejected even when + // Optional is set, so a forged token cannot bypass enforcement. + Optional bool +} + +// userIDKey is the context key used to attach the authenticated user ID +// to the request context. It deliberately reuses the ctxKey type defined +// in request_id.go (value 1) but with a distinct integer value (2) so +// keys cannot collide while still keeping the type unexported to the +// middleware package. +const userIDKey ctxKey = 2 + +// Auth returns HTTP middleware that enforces bearer token authentication +// using the supplied SessionResolver. On success the resolved user ID is +// stored in the request context and downstream handlers can retrieve it +// via UserIDFromContext. +// +// Failure modes all collapse to a 401 response with a small JSON body so +// the client cannot distinguish between "missing token", "malformed +// header", "unknown token" and "resolver failure" — this is intentional +// to limit the information available to attackers probing for valid +// tokens. +func Auth(resolver SessionResolver, opt AuthOptions) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := extractBearer(r) + if token == "" { + if opt.Optional { + next.ServeHTTP(w, r) + return + } + http.Error(w, `{"code":"unauthorized","message":"missing token"}`, http.StatusUnauthorized) + return + } + uid, ok, err := resolver.ResolveSession(r.Context(), token) + if err != nil || !ok { + http.Error(w, `{"code":"unauthorized","message":"invalid token"}`, http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), userIDKey, uid) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// UserIDFromContext returns the authenticated user ID stored in ctx by +// the Auth middleware. The boolean is false when no Auth middleware ran +// upstream or the request was admitted via AuthOptions.Optional with no +// token, so callers can distinguish "anonymous" from "logged-in user +// whose ID happens to be the zero UUID". +func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) { + v, ok := ctx.Value(userIDKey).(uuid.UUID) + return v, ok +} + +// extractBearer parses an Authorization header of the form "Bearer " +// and returns the trimmed token. It returns an empty string when the +// header is missing or does not start with the canonical "Bearer " +// prefix; case-insensitive matching is intentionally NOT performed +// because RFC 6750 specifies the literal "Bearer" scheme name and being +// strict here keeps the parsing surface small. +func extractBearer(r *http.Request) string { + h := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(h, prefix) { + return "" + } + return strings.TrimSpace(h[len(prefix):]) +} diff --git a/internal/transport/http/middleware/auth_test.go b/internal/transport/http/middleware/auth_test.go new file mode 100644 index 0000000..59f5e6d --- /dev/null +++ b/internal/transport/http/middleware/auth_test.go @@ -0,0 +1,79 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// fakeAuth is a stub SessionResolver used by the auth middleware tests. +// It returns either the configured user (ok=true) or the configured error, +// so each test can target a specific branch in Auth without spinning up +// the real user.Service (which lives in a later phase). +type fakeAuth struct { + user uuid.UUID + err error +} + +func (f fakeAuth) ResolveSession(_ context.Context, _ string) (uuid.UUID, bool, error) { + if f.err != nil { + return uuid.Nil, false, f.err + } + return f.user, true, nil +} + +func TestAuth_NoTokenAllowedWhenOptional(t *testing.T) { + called := false + mw := Auth(fakeAuth{}, AuthOptions{Optional: true}) + h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + called = true + _, ok := UserIDFromContext(r.Context()) + require.False(t, ok) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + require.True(t, called) + require.Equal(t, http.StatusOK, rec.Code) +} + +func TestAuth_NoTokenRejectedWhenRequired(t *testing.T) { + mw := Auth(fakeAuth{}, AuthOptions{}) + h := mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler should not be called") + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestAuth_ValidTokenPopulatesContext(t *testing.T) { + uid := uuid.New() + mw := Auth(fakeAuth{user: uid}, AuthOptions{}) + h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got, ok := UserIDFromContext(r.Context()) + require.True(t, ok) + require.Equal(t, uid, got) + })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} + +func TestAuth_BadTokenReturns401(t *testing.T) { + mw := Auth(fakeAuth{err: errors.New("invalid")}, AuthOptions{}) + h := mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler should not be called") + })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer x") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusUnauthorized, rec.Code) +}