217 lines
6.8 KiB
Go
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")
|
|
}
|