This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
package middleware
import (
"net/http"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
)
// AdminMiddleware checks if the user has admin role
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Type assert the user to your User model
userModel, ok := user.(*models.User)
if !ok {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}
// Check if user is admin
if userModel.Role != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden - Admin access required"})
return
}
c.Next()
}
}
+120
View File
@@ -0,0 +1,120 @@
package middleware
import (
"net/http"
"strings"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// JWTAuth is a middleware that checks for a valid JWT token
func JWTAuth(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Admin token shortcut: if a valid admin access token is provided, set admin role
if config.AppConfig != nil && config.AppConfig.AdminAccessToken != "" {
header := c.GetHeader("X-Admin-Token")
if header != "" && header == config.AppConfig.AdminAccessToken {
c.Set("userRole", "admin")
c.Set("user", &models.User{Role: "admin"})
c.Next()
return
}
}
authHeader := c.GetHeader("Authorization")
var tokenString string
if authHeader != "" {
// Extract the token from the header (format: "Bearer <token>")
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) == 2 && tokenParts[0] == "Bearer" {
tokenString = tokenParts[1]
} else {
// If header present but malformed, reject early
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
c.Abort()
return
}
} else {
// Fallback: try HttpOnly cookie set by server
if cookie, err := c.Request.Cookie("auth_token"); err == nil {
tokenString = cookie.Value
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header or auth cookie is required"})
c.Abort()
return
}
}
claims, err := utils.ParseJWT(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.Abort()
return
}
// Check if user exists
var user models.User
if err := db.First(&user, claims.UserID).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
c.Abort()
return
}
// Add user information to the context
c.Set("user", &user)
// Also expose parsed JWT claims for helpers/utilities
c.Set("claims", claims)
c.Set("userID", user.ID)
c.Set("userRole", user.Role)
c.Next()
}
}
// DevBypass checks for special dev header and grants admin role when not in production
func DevBypass() gin.HandlerFunc {
return func(c *gin.Context) {
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
if strings.ToLower(c.GetHeader("X-Dev-Admin")) == "true" {
c.Set("userRole", "admin")
// set a placeholder user
c.Set("user", &models.User{Role: "admin"})
c.Next()
return
}
}
c.Next()
}
}
// RoleAuth is a middleware that checks if the user has the required role
// Admin always has access. Editor has access to content creation (articles, activities).
func RoleAuth(requiredRole string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, exists := c.Get("userRole")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User role not found"})
c.Abort()
return
}
// Admin always has full access
if userRole == "admin" {
c.Next()
return
}
// Check if user has the required role
if userRole != requiredRole {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
c.Abort()
return
}
c.Next()
}
}
+148
View File
@@ -0,0 +1,148 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// CSRF token store (in-memory, consider Redis for production)
type csrfStore struct {
sync.RWMutex
tokens map[string]time.Time
}
var store = &csrfStore{
tokens: make(map[string]time.Time),
}
// Clean expired tokens periodically
func init() {
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
store.Lock()
now := time.Now()
for token, expiry := range store.tokens {
if now.After(expiry) {
delete(store.tokens, token)
}
}
store.Unlock()
}
}()
}
// generateToken creates a cryptographically secure random token
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// CSRFProtection middleware validates CSRF tokens for state-changing operations
func CSRFProtection() gin.HandlerFunc {
return func(c *gin.Context) {
// Skip CSRF for GET, HEAD, OPTIONS (safe methods)
if c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" {
c.Next()
return
}
// Skip CSRF for public API endpoints that use Bearer tokens
// (Bearer token auth provides similar protection)
authHeader := c.GetHeader("Authorization")
if authHeader != "" && len(authHeader) > 7 && authHeader[:7] == "Bearer " {
c.Next()
return
}
// Get token from header or form
token := c.GetHeader("X-CSRF-Token")
if token == "" {
token = c.PostForm("csrf_token")
}
if token == "" {
c.JSON(http.StatusForbidden, gin.H{"error": "CSRF token missing"})
c.Abort()
return
}
// Validate token
store.RLock()
expiry, exists := store.tokens[token]
store.RUnlock()
if !exists || time.Now().After(expiry) {
c.JSON(http.StatusForbidden, gin.H{"error": "Invalid or expired CSRF token"})
c.Abort()
return
}
c.Next()
}
}
// GetCSRFToken returns a new CSRF token (GET endpoint)
func GetCSRFToken(c *gin.Context) {
token, err := generateToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate CSRF token"})
return
}
// Store token with 1 hour expiry
store.Lock()
store.tokens[token] = time.Now().Add(1 * time.Hour)
store.Unlock()
// Return token in response
c.JSON(http.StatusOK, gin.H{"csrf_token": token})
}
// CSRFCookie sets CSRF token as a cookie (alternative approach)
func CSRFCookie() gin.HandlerFunc {
return func(c *gin.Context) {
// Check if token already exists in cookie
existingToken, err := c.Cookie("csrf_token")
if err != nil || existingToken == "" {
// Generate new token
token, err := generateToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate CSRF token"})
c.Abort()
return
}
// Store in memory
store.Lock()
store.tokens[token] = time.Now().Add(24 * time.Hour)
store.Unlock()
// Set cookie
c.SetCookie(
"csrf_token",
token,
86400, // 24 hours
"/",
"",
c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https", // Secure flag
false, // HttpOnly = false (needs to be read by JS)
)
c.Set("csrf_token", token)
} else {
c.Set("csrf_token", existingToken)
}
c.Next()
}
}
+141
View File
@@ -0,0 +1,141 @@
package middleware
import (
"net"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// limiterKey identifies a client + path pair
type limiterKey struct {
IP string
Path string
}
type counter struct {
Count int
ExpiresAt time.Time
}
// in-memory store (process local)
var (
limitStore = struct {
sync.Mutex
m map[limiterKey]*counter
}{m: make(map[limiterKey]*counter)}
)
// RateLimit returns a middleware that limits requests to `max` per given `window` per IP and path.
func RateLimit(max int, window time.Duration) gin.HandlerFunc {
if max <= 0 {
max = 10
}
if window <= 0 {
window = time.Minute
}
return func(c *gin.Context) {
ip := clientIP(c.Request)
key := limiterKey{IP: ip, Path: c.FullPath()}
limitStore.Lock()
ct, ok := limitStore.m[key]
now := time.Now()
if !ok || now.After(ct.ExpiresAt) {
ct = &counter{Count: 0, ExpiresAt: now.Add(window)}
limitStore.m[key] = ct
}
if ct.Count >= max {
retryAfter := int(ct.ExpiresAt.Sub(now).Seconds())
limitStore.Unlock()
c.Header("Retry-After", strconvItoaSafe(retryAfter))
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Příliš mnoho požadavků, zkuste to prosím později."})
c.Abort()
return
}
ct.Count++
limitStore.Unlock()
c.Next()
}
}
func clientIP(r *http.Request) string {
// Prefer X-Forwarded-For if present (first IP)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if p := parseFirstIP(xff); p != "" {
return p
}
}
// Fallback to RemoteAddr
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
return host
}
return r.RemoteAddr
}
func parseFirstIP(s string) string {
for _, part := range splitAndTrim(s, ',') {
ip := net.ParseIP(part)
if ip != nil {
return ip.String()
}
}
return ""
}
func splitAndTrim(s string, sep rune) []string {
var out []string
cur := make([]rune, 0, len(s))
for _, ch := range s {
if ch == sep {
part := string(cur)
cur = cur[:0]
if t := trimSpace(part); t != "" {
out = append(out, t)
}
continue
}
cur = append(cur, ch)
}
if t := trimSpace(string(cur)); t != "" {
out = append(out, t)
}
return out
}
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}
func strconvItoaSafe(i int) string {
// Avoid importing strconv just for small header value
if i == 0 {
return "0"
}
neg := false
if i < 0 {
neg = true
i = -i
}
buf := make([]byte, 0, 12)
for i > 0 {
d := byte(i % 10)
buf = append([]byte{'0' + d}, buf...)
i /= 10
}
if neg {
buf = append([]byte{'-'}, buf...)
}
return string(buf)
}
+114
View File
@@ -0,0 +1,114 @@
package middleware
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// RequestSizeLimit limits the size of request bodies
func RequestSizeLimit(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip for upload endpoints (they have their own limits)
if strings.Contains(c.Request.URL.Path, "/upload") {
c.Next()
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
c.Next()
}
}
// SanitizeHeaders removes potentially dangerous headers
func SanitizeHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Remove server information leakage
c.Writer.Header().Del("Server")
c.Writer.Header().Del("X-Powered-By")
c.Next()
}
}
// ValidateContentType ensures proper content type for POST/PUT/PATCH
func ValidateContentType() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" {
contentType := c.GetHeader("Content-Type")
// Allow multipart for file uploads
if strings.Contains(c.Request.URL.Path, "/upload") {
c.Next()
return
}
// Require JSON for API endpoints
if !strings.Contains(contentType, "application/json") {
c.JSON(http.StatusUnsupportedMediaType, gin.H{
"error": "Content-Type must be application/json",
})
c.Abort()
return
}
}
c.Next()
}
}
// RequestID adds a unique request ID for tracing
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}
func generateRequestID() string {
// Simple request ID generation
b := make([]byte, 16)
_, _ = io.ReadFull(bytes.NewReader([]byte(strings.Repeat("0123456789abcdef", 2))), b)
return string(b)
}
// SecurityAuditLog logs security-relevant events
type SecurityEvent struct {
Type string
UserID uint
IP string
Path string
Method string
RequestID string
Details map[string]interface{}
}
func LogSecurityEvent(c *gin.Context, eventType string, details map[string]interface{}) {
event := SecurityEvent{
Type: eventType,
IP: c.ClientIP(),
Path: c.Request.URL.Path,
Method: c.Request.Method,
RequestID: c.GetString("request_id"),
Details: details,
}
if userID, exists := c.Get("user_id"); exists {
if uid, ok := userID.(uint); ok {
event.UserID = uid
}
}
// Log to your logger
// logger.Warn("SECURITY_EVENT: type=%s user_id=%d ip=%s path=%s",
// event.Type, event.UserID, event.IP, event.Path)
}
+75
View File
@@ -0,0 +1,75 @@
package middleware
import (
"fotbal-club/internal/config"
"github.com/gin-gonic/gin"
)
// SecurityHeaders adds comprehensive security headers to all responses
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Prevent MIME type sniffing
c.Header("X-Content-Type-Options", "nosniff")
// Prevent clickjacking
c.Header("X-Frame-Options", "DENY")
// Referrer policy
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// XSS Protection (legacy, but still useful)
c.Header("X-XSS-Protection", "1; mode=block")
// Permissions Policy (formerly Feature-Policy)
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()")
// HSTS for HTTPS connections
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
c.Header("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
}
// Strict Content-Security-Policy
csp := buildCSP(config.AppConfig.AppEnv == "production")
c.Header("Content-Security-Policy", csp)
// Additional security headers
c.Header("X-Permitted-Cross-Domain-Policies", "none")
c.Header("Cross-Origin-Embedder-Policy", "require-corp")
c.Header("Cross-Origin-Opener-Policy", "same-origin")
c.Header("Cross-Origin-Resource-Policy", "same-origin")
c.Next()
}
}
// buildCSP creates a strict Content-Security-Policy
func buildCSP(production bool) string {
if production {
// Strict production CSP
return "default-src 'self'; " +
"script-src 'self' https://fonts.googleapis.com https://umami.tdvorak.dev; " +
"style-src 'self' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com data:; " +
"img-src 'self' data: https: blob:; " +
"connect-src 'self' https://umami.tdvorak.dev https://zonerama.tdvorak.dev; " +
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'; " +
"upgrade-insecure-requests;"
}
// Development CSP - slightly relaxed for local development
return "default-src 'self'; " +
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://fonts.googleapis.com https://umami.tdvorak.dev; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com data:; " +
"img-src 'self' data: https: http: blob:; " +
"connect-src 'self' https: http: ws: wss:; " +
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none';"
}