security improvements

This commit is contained in:
Fran Jurmanović
2025-06-29 21:59:41 +02:00
parent 7fdda06dba
commit caba5bae70
30 changed files with 3929 additions and 147 deletions

View File

@@ -3,6 +3,8 @@ package configs
import (
"log"
"os"
"github.com/joho/godotenv"
)
var (
@@ -14,12 +16,14 @@ var (
)
func init() {
Secret = getEnv("APP_SECRET", "default-secret-for-dev-use-only")
SecretCode = getEnv("APP_SECRET_CODE", "another-secret-for-dev-use-only")
EncryptionKey = getEnv("ENCRYPTION_KEY", "a-secure-32-byte-long-key-!!!!!!") // Fallback MUST be 32 bytes for AES-256
godotenv.Load()
// Fail fast if critical environment variables are missing
Secret = getEnvRequired("APP_SECRET")
SecretCode = getEnvRequired("APP_SECRET_CODE")
EncryptionKey = getEnvRequired("ENCRYPTION_KEY")
if len(EncryptionKey) != 32 {
log.Fatal("ENCRYPTION_KEY must be 32 bytes long")
log.Fatal("ENCRYPTION_KEY must be exactly 32 bytes long for AES-256")
}
}
@@ -31,3 +35,13 @@ func getEnv(key, fallback string) string {
log.Printf("Environment variable %s not set, using fallback.", key)
return fallback
}
// getEnvRequired retrieves an environment variable and fails if it's not set.
// This should be used for critical configuration that must not have defaults.
func getEnvRequired(key string) string {
if value, exists := os.LookupEnv(key); exists && value != "" {
return value
}
log.Fatalf("Required environment variable %s is not set or is empty", key)
return "" // This line will never be reached due to log.Fatalf
}

View File

@@ -44,9 +44,9 @@ func Migrate(db *gorm.DB) {
&model.StateHistory{},
&model.SteamCredentials{},
&model.SystemConfig{},
&model.User{},
&model.Role{},
&model.Permission{},
&model.Role{},
&model.User{},
)
if err != nil {
@@ -55,6 +55,10 @@ func Migrate(db *gorm.DB) {
db.FirstOrCreate(&model.ApiModel{Api: "Works"})
// Run security migrations - temporarily disabled until migration is fixed
// TODO: Implement proper migration system
logging.Info("Database migration system needs to be implemented")
Seed(db)
}
@@ -80,8 +84,6 @@ func Seed(db *gorm.DB) error {
return nil
}
func seedTracks(db *gorm.DB) error {
tracks := []model.Track{
{Name: "monza", UniquePitBoxes: 29, PrivateServerSlots: 60},

View File

@@ -2,16 +2,18 @@ package jwt
import (
"acc-server-manager/local/model"
"crypto/rand"
"encoding/base64"
"errors"
"log"
"os"
"time"
"github.com/golang-jwt/jwt/v4"
)
// SecretKey is the secret key for signing the JWT.
// It is recommended to use a long, complex string for this.
// In a production environment, this should be loaded from a secure configuration source.
var SecretKey = []byte("your-secret-key")
// SecretKey holds the JWT signing key loaded from environment
var SecretKey []byte
// Claims represents the JWT claims.
type Claims struct {
@@ -19,6 +21,36 @@ type Claims struct {
jwt.RegisteredClaims
}
// init initializes the JWT secret key from environment variable
func init() {
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
log.Fatal("JWT_SECRET environment variable is required and cannot be empty")
}
// Decode base64 secret if it looks like base64, otherwise use as-is
if decoded, err := base64.StdEncoding.DecodeString(jwtSecret); err == nil && len(decoded) >= 32 {
SecretKey = decoded
} else {
SecretKey = []byte(jwtSecret)
}
// Ensure minimum key length for security
if len(SecretKey) < 32 {
log.Fatal("JWT_SECRET must be at least 32 bytes long for security")
}
}
// GenerateSecretKey generates a cryptographically secure random key for JWT signing
// This is a utility function for generating new secrets, not used in normal operation
func GenerateSecretKey() string {
key := make([]byte, 64) // 512 bits
if _, err := rand.Read(key); err != nil {
log.Fatal("Failed to generate random key: ", err)
}
return base64.StdEncoding.EncodeToString(key)
}
// GenerateToken generates a new JWT for a given user.
func GenerateToken(user *model.User) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)

View File

@@ -0,0 +1,82 @@
package password
import (
"errors"
"os"
"golang.org/x/crypto/bcrypt"
)
const (
// MinPasswordLength defines the minimum password length
MinPasswordLength = 8
// BcryptCost defines the cost factor for bcrypt hashing
BcryptCost = 12
)
// HashPassword hashes a plain text password using bcrypt
func HashPassword(password string) (string, error) {
if len(password) < MinPasswordLength {
return "", errors.New("password must be at least 8 characters long")
}
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
return "", err
}
return string(hashedBytes), nil
}
// VerifyPassword verifies a plain text password against a hashed password
func VerifyPassword(hashedPassword, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}
// ValidatePasswordStrength validates password complexity requirements
func ValidatePasswordStrength(password string) error {
if len(password) < MinPasswordLength {
return errors.New("password must be at least 8 characters long")
}
if os.Getenv("ENFORCE_PASSWORD_STRENGTH") == "true" {
if len(password) < MinPasswordLength {
return errors.New("password must be at least 8 characters long")
}
hasUpper := false
hasLower := false
hasDigit := false
hasSpecial := false
for _, char := range password {
switch {
case char >= 'A' && char <= 'Z':
hasUpper = true
case char >= 'a' && char <= 'z':
hasLower = true
case char >= '0' && char <= '9':
hasDigit = true
case char >= '!' && char <= '/' || char >= ':' && char <= '@' || char >= '[' && char <= '`' || char >= '{' && char <= '~':
hasSpecial = true
}
}
if !hasUpper {
return errors.New("password must contain at least one uppercase letter")
}
if !hasLower {
return errors.New("password must contain at least one lowercase letter")
}
if !hasDigit {
return errors.New("password must contain at least one digit")
}
if !hasSpecial {
return errors.New("password must contain at least one special character")
}
return nil
}
return nil
}

View File

@@ -2,8 +2,10 @@ package server
import (
"acc-server-manager/local/api"
"acc-server-manager/local/middleware/security"
"acc-server-manager/local/utl/logging"
"os"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
@@ -15,8 +17,25 @@ import (
func Start(di *dig.Container) *fiber.App {
app := fiber.New(fiber.Config{
EnablePrintRoutes: true,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
BodyLimit: 10 * 1024 * 1024, // 10MB
})
// Initialize security middleware
securityMW := security.NewSecurityMiddleware()
// Add security middleware stack
app.Use(securityMW.SecurityHeaders())
app.Use(securityMW.LogSecurityEvents())
app.Use(securityMW.TimeoutMiddleware(30 * time.Second))
app.Use(securityMW.RequestSizeLimit(10 * 1024 * 1024)) // 10MB
app.Use(securityMW.ValidateUserAgent())
app.Use(securityMW.ValidateContentType("application/json", "application/x-www-form-urlencoded", "multipart/form-data"))
app.Use(securityMW.InputSanitization())
app.Use(securityMW.RateLimit(100, 1*time.Minute)) // 100 requests per minute global
app.Use(helmet.New())
allowedOrigin := os.Getenv("CORS_ALLOWED_ORIGIN")
@@ -25,8 +44,11 @@ func Start(di *dig.Container) *fiber.App {
}
app.Use(cors.New(cors.Config{
AllowOrigins: allowedOrigin,
AllowHeaders: "Origin, Content-Type, Accept",
AllowOrigins: allowedOrigin,
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
AllowCredentials: true,
MaxAge: 86400, // 24 hours
}))
app.Get("/swagger/*", swagger.HandlerDefault)

View File

@@ -154,6 +154,9 @@ func (instance *AccServerInstance) UpdateState(callback func(state *model.Server
}
func (instance *AccServerInstance) UpdatePlayerCount(count int) {
if (count < 0) {
return
}
instance.UpdateState(func (state *model.ServerState, changes *[]StateChange) {
if (count == state.PlayerCount) {
return