This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
// Package config loads application configuration from environment variables.
package config
import (
"fmt"
"os"
"strconv"
"time"
"github.com/joho/godotenv"
)
type Config struct {
Database DatabaseConfig
Redis RedisConfig
JWT JWTConfig
Server ServerConfig
Seed SeedConfig
Media MediaConfig
}
type DatabaseConfig struct {
URL string
}
type RedisConfig struct {
URL string
}
type JWTConfig struct {
Secret string
AccessTTL time.Duration
RefreshTTL time.Duration
}
type ServerConfig struct {
Port string
GinMode string
CORSOrigin string
CookieSecure bool
}
type SeedConfig struct {
AdminEmail string
AdminPassword string
}
// MediaConfig configures the pluggable media storage backend. Driver
// selects "local" (files on disk, served via /uploads) or "s3" (any
// S3-compatible object store). Only the fields relevant to the selected
// driver need to be set.
type MediaConfig struct {
Driver string
MaxUploadMB int64
LocalDir string
LocalBaseURL string
S3Bucket string
S3Region string
S3Endpoint string
S3AccessKeyID string
S3SecretKey string
S3UsePathStyle bool
S3PublicBaseURL string
}
func Load() (*Config, error) {
// The .env file lives at the repo root (next to docker-compose.yml),
// but `go run` is commonly invoked from inside backend/. Try both so
// either working directory picks it up; missing files are ignored.
_ = godotenv.Load(".env")
_ = godotenv.Load("../.env")
accessMinutes, err := strconv.Atoi(getEnv("ACCESS_TOKEN_TTL_MINUTES", "15"))
if err != nil {
return nil, fmt.Errorf("invalid ACCESS_TOKEN_TTL_MINUTES: %w", err)
}
refreshHours, err := strconv.Atoi(getEnv("REFRESH_TOKEN_TTL_HOURS", "168"))
if err != nil {
return nil, fmt.Errorf("invalid REFRESH_TOKEN_TTL_HOURS: %w", err)
}
secret := os.Getenv("JWT_SECRET")
if secret == "" || secret == "change-me-to-a-long-random-secret" {
return nil, fmt.Errorf("JWT_SECRET must be set to a strong, unique value")
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL must be set")
}
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
return nil, fmt.Errorf("REDIS_URL must be set")
}
ginMode := getEnv("GIN_MODE", "debug")
cookieSecure, err := strconv.ParseBool(getEnv("COOKIE_SECURE", strconv.FormatBool(ginMode != "debug")))
if err != nil {
return nil, fmt.Errorf("invalid COOKIE_SECURE: %w", err)
}
mediaDriver := getEnv("MEDIA_STORAGE_DRIVER", "local")
if mediaDriver != "local" && mediaDriver != "s3" {
return nil, fmt.Errorf("invalid MEDIA_STORAGE_DRIVER %q: must be \"local\" or \"s3\"", mediaDriver)
}
maxUploadMB, err := strconv.ParseInt(getEnv("MEDIA_MAX_UPLOAD_MB", "10"), 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid MEDIA_MAX_UPLOAD_MB: %w", err)
}
s3UsePathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "false"))
if err != nil {
return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err)
}
if mediaDriver == "s3" {
for _, key := range []string{"S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"} {
if os.Getenv(key) == "" {
return nil, fmt.Errorf("%s must be set when MEDIA_STORAGE_DRIVER=s3", key)
}
}
}
return &Config{
Database: DatabaseConfig{
URL: dbURL,
},
Redis: RedisConfig{
URL: redisURL,
},
JWT: JWTConfig{
Secret: secret,
AccessTTL: time.Duration(accessMinutes) * time.Minute,
RefreshTTL: time.Duration(refreshHours) * time.Hour,
},
Server: ServerConfig{
Port: getEnv("PORT", "8080"),
GinMode: ginMode,
CORSOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
CookieSecure: cookieSecure,
},
Seed: SeedConfig{
AdminEmail: os.Getenv("SEED_ADMIN_EMAIL"),
AdminPassword: os.Getenv("SEED_ADMIN_PASSWORD"),
},
Media: MediaConfig{
Driver: mediaDriver,
MaxUploadMB: maxUploadMB,
LocalDir: getEnv("MEDIA_LOCAL_DIR", "./uploads"),
LocalBaseURL: getEnv("MEDIA_LOCAL_BASE_URL", "http://localhost:8080/uploads"),
S3Bucket: os.Getenv("S3_BUCKET"),
S3Region: os.Getenv("S3_REGION"),
S3Endpoint: os.Getenv("S3_ENDPOINT"),
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
S3SecretKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
S3UsePathStyle: s3UsePathStyle,
S3PublicBaseURL: os.Getenv("S3_PUBLIC_BASE_URL"),
},
}, nil
}
func getEnv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
+34
View File
@@ -0,0 +1,34 @@
// Package db provides the PostgreSQL connection via GORM.
package db
import (
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func Connect(databaseURL string, debug bool) (*gorm.DB, error) {
logLevel := gormlogger.Silent
if debug {
logLevel = gormlogger.Warn
}
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
Logger: gormlogger.Default.LogMode(logLevel),
})
if err != nil {
return nil, fmt.Errorf("connect postgres: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("get sql.DB: %w", err)
}
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("ping postgres: %w", err)
}
return db, nil
}
@@ -0,0 +1,16 @@
// Package logger provides a minimal structured logger shared across modules.
package logger
import (
"log/slog"
"os"
)
func New(debug bool) *slog.Logger {
level := slog.LevelInfo
if debug {
level = slog.LevelDebug
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
return slog.New(handler)
}
@@ -0,0 +1,100 @@
// Package middleware provides HTTP middleware shared across modules,
// notably the admin/customer auth guards, CORS and rate limiting.
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"backend/internal/platform/security"
)
const (
ctxUserIDKey = "auth_user_id"
ctxRoleKey = "auth_role"
)
func extractBearerToken(c *gin.Context) (string, bool) {
header := c.GetHeader("Authorization")
if header == "" {
return "", false
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" {
return "", false
}
return parts[1], true
}
// requireAudience builds a guard that only accepts access tokens issued for
// the given audience (admin or customer space) and role. Because the JWT
// audience and the expected role are both checked, an admin-space token can
// never authenticate a customer-only route, and vice versa.
func requireAudience(secret string, aud security.Audience, role string) gin.HandlerFunc {
return func(c *gin.Context) {
tokenString, ok := extractBearerToken(c)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
claims, err := security.ParseAccessToken(secret, tokenString, aud)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
if claims.Role != role {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"})
return
}
userID, err := uuid.Parse(claims.Subject)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid subject claim"})
return
}
c.Set(ctxUserIDKey, userID)
c.Set(ctxRoleKey, claims.Role)
c.Next()
}
}
// RequireAdmin protects admin-panel routes. Only tokens issued in the admin
// audience with role "admin" pass.
func RequireAdmin(secret string) gin.HandlerFunc {
return requireAudience(secret, security.AudienceAdmin, "admin")
}
// RequireCustomer protects storefront account routes. Only tokens issued in
// the customer audience with role "customer" pass. Not mounted yet in this
// phase (no customer-facing routes), but kept fully separate from
// RequireAdmin so activating the customer module later never risks sharing
// a session/cookie space with the admin panel.
func RequireCustomer(secret string) gin.HandlerFunc {
return requireAudience(secret, security.AudienceCustomer, "customer")
}
// GetUserID returns the authenticated user's ID set by RequireAdmin/RequireCustomer.
func GetUserID(c *gin.Context) (uuid.UUID, bool) {
v, exists := c.Get(ctxUserIDKey)
if !exists {
return uuid.UUID{}, false
}
id, ok := v.(uuid.UUID)
return id, ok
}
// GetRole returns the authenticated user's role set by RequireAdmin/RequireCustomer.
func GetRole(c *gin.Context) (string, bool) {
v, exists := c.Get(ctxRoleKey)
if !exists {
return "", false
}
role, ok := v.(string)
return role, ok
}
@@ -0,0 +1,30 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CORS allows only the configured frontend origin, with credentials enabled
// (required for the httpOnly refresh-token cookie to be sent cross-origin
// between the Vite dev server and the API in development).
func CORS(allowedOrigin string) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" && origin == allowedOrigin {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
c.Header("Vary", "Origin")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
@@ -0,0 +1,66 @@
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
// PerIPRateLimiter limits requests per client IP, intended for sensitive
// low-frequency endpoints like login (defense against credential stuffing
// / brute force).
type PerIPRateLimiter struct {
mu sync.Mutex
limiters map[string]*rate.Limiter
r rate.Limit
burst int
}
func NewPerIPRateLimiter(requestsPerMinute int, burst int) *PerIPRateLimiter {
l := &PerIPRateLimiter{
limiters: make(map[string]*rate.Limiter),
r: rate.Every(time.Minute / time.Duration(requestsPerMinute)),
burst: burst,
}
go l.cleanupLoop()
return l
}
func (l *PerIPRateLimiter) getLimiter(ip string) *rate.Limiter {
l.mu.Lock()
defer l.mu.Unlock()
limiter, exists := l.limiters[ip]
if !exists {
limiter = rate.NewLimiter(l.r, l.burst)
l.limiters[ip] = limiter
}
return limiter
}
func (l *PerIPRateLimiter) cleanupLoop() {
for {
time.Sleep(10 * time.Minute)
l.mu.Lock()
for ip, limiter := range l.limiters {
if limiter.Tokens() >= float64(l.burst) {
delete(l.limiters, ip)
}
}
l.mu.Unlock()
}
}
func (l *PerIPRateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !l.getLimiter(ip).Allow() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many requests, try again later"})
return
}
c.Next()
}
}
+28
View File
@@ -0,0 +1,28 @@
// Package redis provides the Redis client used for refresh-token/session state.
package redis
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
func Connect(redisURL string) (*redis.Client, error) {
opt, err := redis.ParseURL(redisURL)
if err != nil {
return nil, fmt.Errorf("parse redis url: %w", err)
}
client := redis.NewClient(opt)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("ping redis: %w", err)
}
return client, nil
}
+104
View File
@@ -0,0 +1,104 @@
package security
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// Audience separates admin-space tokens from customer-space tokens so a
// token issued for one space can never be accepted by the other, even
// though both are signed with the same server secret.
type Audience string
const (
AudienceAdmin Audience = "admin"
AudienceCustomer Audience = "customer"
)
// Claims are the JWT claims carried by access tokens issued by this platform.
type Claims struct {
Role string `json:"role"`
jwt.RegisteredClaims
}
// IssueAccessToken creates a signed, short-lived access token for the given
// user, role and audience (admin or customer space).
func IssueAccessToken(secret string, ttl time.Duration, userID uuid.UUID, role string, aud Audience) (string, error) {
if role == "" {
return "", fmt.Errorf("role must not be empty")
}
now := time.Now()
claims := Claims{
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID.String(),
Audience: jwt.ClaimStrings{string(aud)},
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
ID: uuid.NewString(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(secret))
if err != nil {
return "", fmt.Errorf("sign token: %w", err)
}
return signed, nil
}
// ParseAccessToken verifies signature, algorithm and every claim explicitly:
// - signing method must be HMAC (rejects "alg":"none" and any asymmetric swap attempt)
// - exp/iat/nbf must be present and internally consistent (no expired/not-yet-valid tokens)
// - aud must match exactly the expected audience (admin token can never pass as a customer token, or vice versa)
// - sub must be present and a well-formed UUID (matches our user ID format)
// - role must be present and non-empty
// - jti must be present (reserved for future revocation lookups)
func ParseAccessToken(secret string, tokenString string, expectedAud Audience) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(secret), nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
if err != nil {
return nil, fmt.Errorf("parse token: %w", err)
}
if !token.Valid {
return nil, errors.New("invalid token")
}
if claims.ExpiresAt == nil {
return nil, errors.New("missing exp claim")
}
if claims.IssuedAt == nil {
return nil, errors.New("missing iat claim")
}
if claims.ID == "" {
return nil, errors.New("missing jti claim")
}
if claims.Subject == "" {
return nil, errors.New("missing sub claim")
}
if _, err := uuid.Parse(claims.Subject); err != nil {
return nil, fmt.Errorf("invalid sub claim: %w", err)
}
if claims.Role == "" {
return nil, errors.New("missing role claim")
}
if len(claims.Audience) != 1 || claims.Audience[0] != string(expectedAud) {
return nil, fmt.Errorf("token audience does not match expected space %q", expectedAud)
}
return claims, nil
}
@@ -0,0 +1,82 @@
// Package security provides password hashing and JWT issuing/verification
// shared across auth modules.
package security
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argon2Time = 3
argon2Memory = 64 * 1024 // 64 MB
argon2Threads = 2
argon2SaltLen = 16
argon2KeyLen = 32
)
// HashPassword hashes a plaintext password using argon2id and returns the
// PHC-formatted string ($argon2id$v=19$m=...,t=...,p=...$salt$hash).
func HashPassword(password string) (string, error) {
salt := make([]byte, argon2SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
encoded := fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argon2Memory,
argon2Time,
argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
)
return encoded, nil
}
// VerifyPassword checks a plaintext password against a PHC-formatted argon2id hash.
func VerifyPassword(encodedHash, password string) (bool, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, fmt.Errorf("invalid hash format")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, fmt.Errorf("invalid hash version segment: %w", err)
}
if version != argon2.Version {
return false, fmt.Errorf("unsupported argon2 version: %d", version)
}
var memory uint32
var time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return false, fmt.Errorf("invalid hash params segment: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, fmt.Errorf("decode salt: %w", err)
}
wantHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, fmt.Errorf("decode hash: %w", err)
}
gotHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(wantHash)))
if subtle.ConstantTimeCompare(gotHash, wantHash) == 1 {
return true, nil
}
return false, nil
}