Add tenant-aware HTTP middleware and router
internal/httpapi/ provides:
- Tenant{UserID, Email} carried on context.Context, with
WithTenant / TenantFrom helpers and ErrNoTenant for the
programming-error case (route reached without middleware).
- AuthMiddleware verifies an Authorization: Bearer <jwt> on every
request via auth.Signer.Verify (which already enforces HS256
and rejects alg=none). On failure: 401, with the underlying
reason logged server-side but not exposed to the client.
- RequireTenantOwnership(ownerID) compares the request's tenant
against the resource owner; returns 403 on mismatch. Handlers
that touch tenant-owned resources call this guard.
- Server.Routes() mounts /auth/* unauthenticated and wraps
/api/* with the middleware. /api/whoami is included as the
canonical example of a tenant-scoped endpoint.
Tests cover: valid JWT pass-through, missing/empty Authorization,
wrong scheme, malformed JWT, tampered signature, JWT signed with
a different secret (cross-tenant key confusion), and the 200/403
matrix for RequireTenantOwnership.
Closes #11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.librete.ch/public/librenotes/internal/auth"
|
||||
)
|
||||
|
||||
// AuthMiddleware validates the Authorization: Bearer <jwt> header on
|
||||
// every request. On success the verified Tenant is attached to the
|
||||
// request context so downstream handlers can scope their work. On any
|
||||
// failure (missing header, wrong scheme, invalid/expired/forged JWT)
|
||||
// the request is rejected with 401 — the failure reason is logged
|
||||
// server-side but not surfaced to the client to avoid hinting at
|
||||
// validation internals.
|
||||
func AuthMiddleware(signer *auth.Signer, logger *log.Logger) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = log.Default()
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tok, err := bearerToken(r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
logger.Printf("auth: %v from %s", err, r.RemoteAddr)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
claims, err := signer.Verify(tok)
|
||||
if err != nil {
|
||||
logger.Printf("auth: jwt verify failed for %s: %v", r.RemoteAddr, err)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx := WithTenant(r.Context(), Tenant{
|
||||
UserID: claims.UserID,
|
||||
Email: claims.Email,
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(header string) (string, error) {
|
||||
const prefix = "Bearer "
|
||||
if header == "" {
|
||||
return "", errMissingHeader
|
||||
}
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return "", errBadScheme
|
||||
}
|
||||
tok := strings.TrimSpace(header[len(prefix):])
|
||||
if tok == "" {
|
||||
return "", errEmptyToken
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// Sentinel errors for log diagnostics. Not exported; clients always
|
||||
// see "unauthorized".
|
||||
var (
|
||||
errMissingHeader = strErr("missing Authorization header")
|
||||
errBadScheme = strErr("expected Bearer scheme")
|
||||
errEmptyToken = strErr("empty bearer token")
|
||||
)
|
||||
|
||||
type strErr string
|
||||
|
||||
func (e strErr) Error() string { return string(e) }
|
||||
|
||||
// RequireTenantOwnership compares the tenant on the request with the
|
||||
// owner of the resource. Returns true if access is allowed; otherwise
|
||||
// writes 403 to w and returns false.
|
||||
//
|
||||
// Handlers that mutate or read tenant-owned resources should call this
|
||||
// before serving the response. The middleware ensures a Tenant is on
|
||||
// the context; the handler's job is to ensure the *resource* belongs
|
||||
// to that tenant.
|
||||
func RequireTenantOwnership(w http.ResponseWriter, r *http.Request, ownerID string) bool {
|
||||
t, err := TenantFrom(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
if t.UserID != ownerID {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user