- migration 0002: users.verified boolean, default false
- verified rides in the JWT (role + verified claims)
- POST /users/{id}/verify + /unverify, admin-only (403 otherwise)
- Issue now takes the whole user so trust claims travel in the token
- unit + integration + admin-flow tests
150 lines
4.2 KiB
Go
150 lines
4.2 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/emil28092005/SciMesh/users/internal/auth"
|
|
"github.com/emil28092005/SciMesh/users/internal/domain"
|
|
)
|
|
|
|
type ctxKey string
|
|
|
|
const (
|
|
requestIDKey ctxKey = "request_id"
|
|
userIDKey ctxKey = "user_id"
|
|
roleKey ctxKey = "role"
|
|
)
|
|
|
|
// withRequestID stamps every request with an ID for correlated logs and error
|
|
// bodies. It wraps the auth middleware rather than the other way round, so even
|
|
// a rejected request carries an ID the caller can quote in a bug report.
|
|
func withRequestID(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := newRequestID()
|
|
w.Header().Set("X-Request-ID", id)
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id)))
|
|
})
|
|
}
|
|
|
|
func requestIDFrom(ctx context.Context) string {
|
|
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func newRequestID() string {
|
|
var b [8]byte
|
|
_, _ = rand.Read(b[:])
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// tokenVerifier is the slice of auth.Issuer the JWT middleware needs. Taking an
|
|
// interface keeps the middleware testable with a stub verifier.
|
|
type tokenVerifier interface {
|
|
Verify(token string) (*auth.Claims, error)
|
|
}
|
|
|
|
// withJWT verifies the Bearer token and stashes the caller's id and role in the
|
|
// request context. It rejects any request without a valid, unexpired HS256
|
|
// token — this is what protects endpoints that act on a specific user.
|
|
func withJWT(v tokenVerifier) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if raw == "" {
|
|
unauthorized(w, r)
|
|
return
|
|
}
|
|
claims, err := v.Verify(raw)
|
|
if err != nil {
|
|
unauthorized(w, r)
|
|
return
|
|
}
|
|
id, err := uuid.Parse(claims.Subject)
|
|
if err != nil {
|
|
unauthorized(w, r)
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), userIDKey, id)
|
|
ctx = context.WithValue(ctx, roleKey, claims.Role)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
func unauthorized(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "unauthorized",
|
|
RequestID: requestIDFrom(r.Context()),
|
|
})
|
|
}
|
|
|
|
// userIDFrom returns the authenticated caller's id, set by withJWT.
|
|
func userIDFrom(ctx context.Context) (uuid.UUID, bool) {
|
|
id, ok := ctx.Value(userIDKey).(uuid.UUID)
|
|
return id, ok
|
|
}
|
|
|
|
// withAdmin rejects any caller whose token role is not admin. It must sit inside
|
|
// withJWT, which stamps the role after verifying the token.
|
|
func withAdmin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if role, ok := r.Context().Value(roleKey).(domain.Role); !ok || role != domain.RoleAdmin {
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
Error: "admin role required",
|
|
RequestID: requestIDFrom(r.Context()),
|
|
})
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// statusRecorder captures the status code for the access log.
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (s *statusRecorder) WriteHeader(code int) {
|
|
s.status = code
|
|
s.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// withAccessLog records one structured line per request — the minimum needed to
|
|
// debug a distributed system after the fact.
|
|
func withAccessLog(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) {
|
|
start := time.Now()
|
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(rec, r)
|
|
log.Info("request",
|
|
"request_id", requestIDFrom(r.Context()),
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", rec.status,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// chain applies middleware so that the first argument is the outermost layer.
|
|
func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
|
|
for i := len(mw) - 1; i >= 0; i-- {
|
|
h = mw[i](h)
|
|
}
|
|
return h
|
|
}
|