feat: initial commit — backend API + student cabinet frontend
- Go backend: auth (JWT), points earn/spend, QR token generation, partners, admin grant/stats endpoints with chi router - Next.js 14 frontend: login, student dashboard, transaction history, QR display, partners list - PostgreSQL migrations (4 tables), Redis cache, Docker Compose - CORS middleware, role-based route protection, Zustand auth store Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// Package middleware provides HTTP middleware: JWT verification, role guard, request logging.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// contextKey is an unexported type for context keys in this package,
|
||||
// preventing collisions with keys set by other packages.
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
userIDKey contextKey = "user_id"
|
||||
userRoleKey contextKey = "user_role"
|
||||
jtiKey contextKey = "jti"
|
||||
)
|
||||
|
||||
// tokenClaims mirrors the JWT payload fields the middleware needs to inspect.
|
||||
// Defined locally so the middleware does not import the auth package.
|
||||
type tokenClaims struct {
|
||||
jwt.RegisteredClaims // provides Subject (user_id), JWTID (jti), ExpiresAt
|
||||
Role string `json:"role"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// UserIDFromContext retrieves the authenticated user's ID stored by Auth middleware.
|
||||
func UserIDFromContext(ctx context.Context) string {
|
||||
v, _ := ctx.Value(userIDKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// UserRoleFromContext retrieves the authenticated user's role stored by Auth middleware.
|
||||
func UserRoleFromContext(ctx context.Context) string {
|
||||
v, _ := ctx.Value(userRoleKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// JTIFromContext retrieves the JWT ID (jti) stored by Auth middleware.
|
||||
// Useful for token revocation checks in downstream handlers.
|
||||
func JTIFromContext(ctx context.Context) string {
|
||||
v, _ := ctx.Value(jtiKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// Auth returns middleware that validates the Bearer JWT in the Authorization header.
|
||||
// On success it injects user_id, role, and jti into the request context.
|
||||
// Rejects tokens that are expired, have a bad signature, or are not of type "access"
|
||||
// (prevents refresh tokens from being used on protected endpoints).
|
||||
func Auth(secret string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr, err := bearerToken(r)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"missing or invalid Authorization header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims := &tokenClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("middleware.Auth: unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Explicitly block refresh tokens from reaching protected endpoints.
|
||||
if claims.Type != "access" {
|
||||
http.Error(w, `{"error":"access token required"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userIDKey, claims.Subject)
|
||||
ctx = context.WithValue(ctx, userRoleKey, claims.Role)
|
||||
ctx = context.WithValue(ctx, jtiKey, claims.ID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken extracts the token string from the Authorization: Bearer <token> header.
|
||||
func bearerToken(r *http.Request) (string, error) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, "Bearer ") {
|
||||
return "", fmt.Errorf("middleware.bearerToken: missing Bearer prefix")
|
||||
}
|
||||
return strings.TrimPrefix(h, "Bearer "), nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// CORS adds permissive CORS headers for local development.
|
||||
// Handles the browser preflight OPTIONS request so chi doesn't return 405.
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Logger is structured request-logging middleware using log/slog.
|
||||
// It records method, path, status code, and response duration for every request.
|
||||
func Logger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(wrapped, r)
|
||||
slog.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", wrapped.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture the status code.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// SpendRateLimit returns middleware that limits requests to maxPerMinute per
|
||||
// authenticated partner (identified by user_id in context). It must be placed
|
||||
// after Auth + RequireRole("partner") so that UserIDFromContext is populated.
|
||||
//
|
||||
// Implementation: Redis INCR + EXPIRE sliding-window counter.
|
||||
// Key: rate_limit:spend:<partner_id> — expires after 1 minute.
|
||||
// On Redis failure the middleware fails open (lets the request through) so that
|
||||
// a Redis outage does not take down point transactions.
|
||||
func SpendRateLimit(rdb *redis.Client, maxPerMinute int) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
partnerID := UserIDFromContext(r.Context())
|
||||
key := "rate_limit:spend:" + partnerID
|
||||
|
||||
// Use a short-lived context for Redis so a slow Redis doesn't stall the request.
|
||||
rCtx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
count, err := rdb.Incr(rCtx, key).Result()
|
||||
if err != nil {
|
||||
// Fail open: Redis unavailable should not block transactions.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Set the expiry only on the first increment so the window resets each minute.
|
||||
if count == 1 {
|
||||
rdb.Expire(rCtx, key, time.Minute) //nolint:errcheck
|
||||
}
|
||||
if count > int64(maxPerMinute) {
|
||||
http.Error(w, `{"error":"rate limit exceeded, max 10 requests per minute"}`, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// RequireRole returns middleware that allows only requests whose authenticated user
|
||||
// holds one of the permitted roles. Must be chained after Auth middleware.
|
||||
func RequireRole(allowed ...string) func(http.Handler) http.Handler {
|
||||
set := make(map[string]struct{}, len(allowed))
|
||||
for _, r := range allowed {
|
||||
set[r] = struct{}{}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := set[UserRoleFromContext(r.Context())]; !ok {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user