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>
92 lines
2.7 KiB
Go
92 lines
2.7 KiB
Go
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
|
|
}
|