mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
upload
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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';"
|
||||
}
|
||||
Reference in New Issue
Block a user