feat(http/mw): bearer token auth middleware with optional mode

This commit is contained in:
2026-04-28 15:34:38 +08:00
parent f88c352e55
commit 782247a239
3 changed files with 189 additions and 1 deletions
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require ( require (
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/golang-migrate/migrate/v4 v4.19.1 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/jackc/pgx/v5 v5.9.2
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
@@ -34,7 +35,6 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
+109
View File
@@ -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 <tok>"
// 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):])
}
@@ -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)
}