Files
pages/internal/auth/middleware.go
T
2026-08-15 07:13:00 +00:00

217 lines
6.8 KiB
Go

package auth
import (
"context"
"errors"
"log/slog"
"net/http"
"net/netip"
"strconv"
"strings"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/store"
)
type ctxKey int
const identityKey ctxKey = iota
// IdentityFrom returns the identity established by Authenticate.
//
// A handler mounted behind Authenticate can treat a false result as a
// programming error: the middleware answers 401 itself and never calls through
// without an identity.
func IdentityFrom(ctx context.Context) (*Identity, bool) {
id, ok := ctx.Value(identityKey).(*Identity)
return id, ok
}
// ContextWithIdentity is used by tests and by handlers that authenticate out of
// band; ordinary request handling gets its identity from Authenticate.
func ContextWithIdentity(ctx context.Context, id *Identity) context.Context {
return context.WithValue(ctx, identityKey, id)
}
// errUnauthorized is the single response every authentication failure produces.
// Distinguishing "no such key" from "wrong secret" from "revoked" would tell an
// unauthenticated caller which key ids are real.
func errUnauthorized() *api.Error {
return api.Errorf(api.CodeUnauthorized, "missing or invalid API token")
}
// Middleware carries the collaborators the auth handlers need.
type Middleware struct {
V *Verifier
Limiter *Limiter
Trusted []netip.Prefix
Log *slog.Logger
}
// Authenticate requires a valid bearer token and puts the identity in the
// request context.
//
// The token is read only from the Authorization header, never from a query
// parameter: query strings land in proxy access logs, browser history and
// Referer headers, and a credential that ends up there is a credential leaked.
func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
m.reject(w, r, errors.New("auth: no bearer token"))
return
}
client := m.clientKey(r)
if !m.Limiter.Allow(client) {
if d := m.Limiter.RetryAfter(client); d > 0 {
w.Header().Set("Retry-After", strconv.Itoa(int(d.Seconds())))
}
httpx.WriteError(w, r, m.Log, api.Errorf(api.CodeRateLimited,
"too many failed authentication attempts; slow down"))
return
}
id, err := m.V.Verify(r.Context(), token)
if err != nil {
m.Limiter.Fail(client)
m.reject(w, r, err)
return
}
// The key id is the public half of the token and is safe to log; the
// secret never leaves this function.
httpx.LogAttr(r.Context(), "key_id", id.KeyID)
httpx.LogAttr(r.Context(), "scope", string(id.Scope))
next.ServeHTTP(w, r.WithContext(ContextWithIdentity(r.Context(), id)))
})
}
// reject logs why authentication failed and tells the client only that it did.
func (m *Middleware) reject(w http.ResponseWriter, r *http.Request, cause error) {
if m.Log != nil {
// cause is one of this package's sentinels or a store error. None of
// them embed the token, which is what makes it safe to log at all.
m.Log.Debug("authentication failed",
"reason", cause,
"req_id", httpx.RequestIDFrom(r.Context()),
"path", r.URL.Path)
}
w.Header().Set("WWW-Authenticate", `Bearer realm="pages"`)
httpx.WriteError(w, r, m.Log, errUnauthorized())
}
// clientKey identifies the caller for rate limiting.
func (m *Middleware) clientKey(r *http.Request) string {
if addr, ok := httpx.ClientIP(r, m.Trusted); ok {
return addr.String()
}
return r.RemoteAddr
}
// bearerToken extracts the credential from the Authorization header. The scheme
// comparison is case-insensitive per RFC 7235; the token itself is not touched.
func bearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
if h == "" {
return "", false
}
scheme, token, ok := strings.Cut(h, " ")
if !ok || !strings.EqualFold(scheme, "Bearer") {
return "", false
}
token = strings.TrimSpace(token)
if token == "" {
return "", false
}
return token, true
}
// RequireAdmin rejects identities that are not admin-scoped.
func RequireAdmin(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := IdentityFrom(r.Context())
if !ok {
httpx.WriteError(w, r, log, errUnauthorized())
return
}
if !id.IsAdmin() {
httpx.WriteError(w, r, log, api.Errorf(api.CodeForbidden,
"this operation requires an admin key"))
return
}
next.ServeHTTP(w, r)
})
}
}
// ProjectResolver maps a project name from the URL to its row id.
//
// It is an interface rather than a concrete type so this package does not
// depend on the site registry, which does not exist until the serving layer is
// wired up, and so tests can supply a two-line fake.
type ProjectResolver interface {
ResolveProject(ctx context.Context, name string) (int64, error)
}
// ResolverFunc adapts a function to ProjectResolver.
type ResolverFunc func(ctx context.Context, name string) (int64, error)
func (f ResolverFunc) ResolveProject(ctx context.Context, name string) (int64, error) {
return f(ctx, name)
}
// RequireProject allows admins through and otherwise requires the caller's key
// to belong to the project named by the {pathValue} URL wildcard.
//
// The comparison is on resolved row ids, never on the name string. Comparing
// names would make the trust boundary depend on every handler normalising the
// same way, and would break the moment two names can resolve to one project.
func RequireProject(pathValue string, r ProjectResolver, log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
id, ok := IdentityFrom(req.Context())
if !ok {
httpx.WriteError(w, req, log, errUnauthorized())
return
}
name := req.PathValue(pathValue)
if name == "" {
httpx.WriteError(w, req, log, api.Errorf(api.CodeBadRequest, "missing project name"))
return
}
projectID, err := r.ResolveProject(req.Context(), name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
// A project-scoped key must not be able to probe which
// project names exist, so an unknown name looks the same as
// someone else's project.
if !id.IsAdmin() {
httpx.WriteError(w, req, log, forbiddenProject())
return
}
httpx.WriteError(w, req, log,
api.Errorf(api.CodeNotFound, "no such project: %s", name))
return
}
httpx.WriteError(w, req, log, err)
return
}
if !id.Owns(projectID) {
httpx.WriteError(w, req, log, forbiddenProject())
return
}
httpx.LogAttr(req.Context(), "project", name)
next.ServeHTTP(w, req)
})
}
}
func forbiddenProject() *api.Error {
return api.Errorf(api.CodeForbidden, "this key does not have access to that project")
}