From 52408774c7d16dff16fd5119de3a7d9457eda308 Mon Sep 17 00:00:00 2001 From: dannyy2000 Date: Fri, 24 Apr 2026 01:54:15 +0100 Subject: [PATCH 1/2] feat: add bcrypt password hashing (#55) and structured logging (#61) - Add HashPassword (bcrypt cost 12), ComparePassword, and ValidatePasswordStrength to models/user.go - Implement Register and Login handlers in handlers/auth.go using bcrypt - Ensure password hashes are never exposed in API responses or logs - Add unit tests covering hash uniqueness, comparison, and weak password rejection - Create backend/logger package backed by logrus with JSON (production) and text (dev) output - Log level configurable via LOG_LEVEL environment variable - Add RequestLogger middleware logging method, path, status, duration, and IP per request - Replace log.Printf/log.Fatalf in main.go with structured logger - Promote golang.org/x/crypto and github.com/sirupsen/logrus to direct dependencies --- backend/go.mod | 4 +- backend/handlers/auth.go | 122 +++++++++++++++++++++++++++++++--- backend/logger/logger.go | 58 ++++++++++++++++ backend/main.go | 41 +++++------- backend/middleware/logging.go | 28 ++++++++ backend/models/user.go | 56 ++++++++++++++-- backend/models/user_test.go | 72 ++++++++++++++++++++ 7 files changed, 341 insertions(+), 40 deletions(-) create mode 100644 backend/logger/logger.go create mode 100644 backend/middleware/logging.go create mode 100644 backend/models/user_test.go diff --git a/backend/go.mod b/backend/go.mod index bb733a8..bd57695 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,8 +6,10 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 + github.com/sirupsen/logrus v1.9.3 github.com/stellar/go v0.0.0-20251210100531-aab2ea4aca88 github.com/stretchr/testify v1.10.0 + golang.org/x/crypto v0.45.0 gorm.io/driver/postgres v1.5.4 gorm.io/gorm v1.30.0 ) @@ -45,13 +47,11 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go index 5b73cce..d0aed5e 100644 --- a/backend/handlers/auth.go +++ b/backend/handlers/auth.go @@ -6,7 +6,9 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "github.com/sirupsen/logrus" "github.com/yourusername/gpay-remit/config" + "github.com/yourusername/gpay-remit/logger" "github.com/yourusername/gpay-remit/middleware" "github.com/yourusername/gpay-remit/models" "gorm.io/gorm" @@ -18,18 +20,123 @@ type AuthHandler struct { } func NewAuthHandler(db *gorm.DB, cfg *config.Config) *AuthHandler { - return &AuthHandler{ - DB: db, - Cfg: cfg, - } + return &AuthHandler{DB: db, Cfg: cfg} +} + +// RegisterRequest is the request body for user registration. +type RegisterRequest struct { + Email string `json:"email" binding:"required,email"` + Name string `json:"name" binding:"required"` + Password string `json:"password" binding:"required"` + StellarAddress string `json:"stellar_address" binding:"required"` + Country string `json:"country"` +} + +// LoginRequest is the request body for user login. +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` } -// RefreshToken request body +// RefreshTokenRequest is the request body for token refresh. type RefreshTokenRequest struct { RefreshToken string `json:"refresh_token" binding:"required"` } -// Refresh handles token refresh +// Register creates a new user account with a bcrypt-hashed password. +func (h *AuthHandler) Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + hash, err := models.HashPassword(req.Password) + if err != nil { + logger.Log.WithFields(logrus.Fields{ + "endpoint": "/auth/register", + }).Warn("Registration rejected: weak password") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user := models.User{ + Email: req.Email, + Name: req.Name, + PasswordHash: hash, + StellarAddress: req.StellarAddress, + Country: req.Country, + } + + if err := h.DB.Create(&user).Error; err != nil { + logger.Log.WithFields(logrus.Fields{ + "endpoint": "/auth/register", + }).Error("Failed to create user") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) + return + } + + logger.Log.WithFields(logrus.Fields{ + "user_id": user.ID, + "endpoint": "/auth/register", + }).Info("User registered") + + // Return the user object — PasswordHash is excluded via json:"-" on the model. + c.JSON(http.StatusCreated, user) +} + +// Login authenticates a user and returns JWT access and refresh tokens. +func (h *AuthHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user models.User + if err := h.DB.Where("email = ?", req.Email).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + if !user.IsActive { + c.JSON(http.StatusForbidden, gin.H{"error": "User account is inactive"}) + return + } + + if !models.ComparePassword(user.PasswordHash, req.Password) { + logger.Log.WithFields(logrus.Fields{ + "user_id": user.ID, + "endpoint": "/auth/login", + }).Warn("Failed login attempt") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + accessToken, err := middleware.GenerateToken(user.ID, user.Role, h.Cfg.JWTSecret, 15*time.Minute) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate access token"}) + return + } + + refreshToken, err := middleware.GenerateToken(user.ID, user.Role, h.Cfg.JWTRefreshSecret, 7*24*time.Hour) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"}) + return + } + + logger.Log.WithFields(logrus.Fields{ + "user_id": user.ID, + "endpoint": "/auth/login", + }).Info("User logged in") + + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "refresh_token": refreshToken, + }) +} + +// Refresh validates a refresh token and issues new access and refresh tokens. func (h *AuthHandler) Refresh(c *gin.Context) { var req RefreshTokenRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -37,7 +144,6 @@ func (h *AuthHandler) Refresh(c *gin.Context) { return } - // Validate refresh token using the refresh secret claims := &middleware.Claims{} token, err := jwt.ParseWithClaims(req.RefreshToken, claims, func(token *jwt.Token) (interface{}, error) { return []byte(h.Cfg.JWTRefreshSecret), nil @@ -48,7 +154,6 @@ func (h *AuthHandler) Refresh(c *gin.Context) { return } - // Fetch user from DB to ensure they still exist and are active var user models.User if err := h.DB.First(&user, claims.UserID).Error; err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) @@ -60,7 +165,6 @@ func (h *AuthHandler) Refresh(c *gin.Context) { return } - // Issue new access and refresh tokens accessToken, err := middleware.GenerateToken(user.ID, user.Role, h.Cfg.JWTSecret, 15*time.Minute) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate access token"}) diff --git a/backend/logger/logger.go b/backend/logger/logger.go new file mode 100644 index 0000000..a810f21 --- /dev/null +++ b/backend/logger/logger.go @@ -0,0 +1,58 @@ +package logger + +import ( + "os" + "strings" + + "github.com/sirupsen/logrus" +) + +// Log is the application-wide structured logger. +// Initialized with safe defaults so callers never receive a nil logger. +var Log *logrus.Logger + +func init() { + Log = logrus.New() + Log.SetOutput(os.Stdout) + Log.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) + Log.SetLevel(logrus.InfoLevel) +} + +// Init configures the global logger for the given environment. +// JSON format is used in production; human-readable text is used elsewhere. +// The log level is controlled by the LOG_LEVEL environment variable (default: info). +func Init(env string) { + Log = logrus.New() + Log.SetOutput(os.Stdout) + + if strings.ToLower(env) == "production" { + Log.SetFormatter(&logrus.JSONFormatter{}) + } else { + Log.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + } + + level := parseLevel(os.Getenv("LOG_LEVEL")) + Log.SetLevel(level) +} + +func parseLevel(s string) logrus.Level { + switch strings.ToLower(s) { + case "debug": + return logrus.DebugLevel + case "warn", "warning": + return logrus.WarnLevel + case "error": + return logrus.ErrorLevel + case "fatal": + return logrus.FatalLevel + default: + return logrus.InfoLevel + } +} + +// WithFields returns an entry with the given structured fields attached. +func WithFields(fields logrus.Fields) *logrus.Entry { + return Log.WithFields(fields) +} diff --git a/backend/main.go b/backend/main.go index d957a7e..bd30d99 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,30 +1,33 @@ package main import ( - "log" "net/http" + "os" "github.com/gin-gonic/gin" "github.com/yourusername/gpay-remit/config" "github.com/yourusername/gpay-remit/handlers" + "github.com/yourusername/gpay-remit/logger" "github.com/yourusername/gpay-remit/middleware" ) func main() { - // Load configuration + env := os.Getenv("APP_ENV") + logger.Init(env) + cfg, err := config.LoadConfig() if err != nil { - log.Fatalf("Failed to load config: %v", err) + logger.Log.WithField("error", err).Fatal("Failed to load config") } - // Initialize database db, err := config.InitDB(cfg) if err != nil { - log.Fatalf("Failed to connect to database: %v", err) + logger.Log.WithField("error", err).Fatal("Failed to connect to database") } - // Setup router - router := gin.Default() + router := gin.New() + router.Use(gin.Recovery()) + router.Use(middleware.RequestLogger()) // CORS middleware router.Use(func(c *gin.Context) { @@ -38,7 +41,6 @@ func main() { c.Next() }) - // Health check router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "healthy", @@ -46,27 +48,18 @@ func main() { }) }) - // API routes api := router.Group("/api/v1") { - // Public auth endpoints authHandler := handlers.NewAuthHandler(db, cfg) + api.POST("/auth/register", authHandler.Register) + api.POST("/auth/login", authHandler.Login) api.POST("/auth/refresh", authHandler.Refresh) - api.POST("/auth/login", func(c *gin.Context) { - // Stub login endpoint - c.JSON(http.StatusOK, gin.H{"message": "Login endpoint stub"}) - }) - - // Public user endpoints - api.POST("/users", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"message": "User creation endpoint"}) - }) - // Protected routes + api.POST("/users", authHandler.Register) + protected := api.Group("/") protected.Use(middleware.JwtAuthMiddleware(cfg)) { - // Remittance endpoints remittanceHandler := handlers.NewRemittanceHandler(db, cfg) protected.POST("/remittances/create", remittanceHandler.CreateRemittance) protected.POST("/remittances", remittanceHandler.SendRemittance) @@ -74,20 +67,18 @@ func main() { protected.GET("/remittances", remittanceHandler.ListRemittances) protected.POST("/remittances/:id/complete", middleware.RequireRole("admin"), remittanceHandler.CompleteRemittance) - // Invoice endpoints protected.POST("/invoices", remittanceHandler.CreateInvoice) protected.GET("/invoices/:id", remittanceHandler.GetInvoice) } } - // Start server port := cfg.Port if port == "" { port = "8080" } - log.Printf("Starting Gpay-Remit API server on port %s", port) + logger.Log.WithField("port", port).Info("Starting Gpay-Remit API server") if err := router.Run(":" + port); err != nil { - log.Fatalf("Failed to start server: %v", err) + logger.Log.WithField("error", err).Fatal("Failed to start server") } } diff --git a/backend/middleware/logging.go b/backend/middleware/logging.go new file mode 100644 index 0000000..c657732 --- /dev/null +++ b/backend/middleware/logging.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/yourusername/gpay-remit/logger" +) + +// RequestLogger returns a Gin middleware that logs every HTTP request with +// method, path, status code, duration, and client IP as structured fields. +func RequestLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + + c.Next() + + logger.Log.WithFields(logrus.Fields{ + "method": c.Request.Method, + "path": path, + "status": c.Writer.Status(), + "duration": time.Since(start).String(), + "ip": c.ClientIP(), + }).Info("HTTP request") + } +} diff --git a/backend/models/user.go b/backend/models/user.go index e6d8171..eb4c2d9 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -1,8 +1,11 @@ package models import ( + "errors" "time" + "unicode" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -15,15 +18,60 @@ type User struct { Name string `gorm:"size:255;not null" json:"name"` StellarAddress string `gorm:"uniqueIndex;size:56;not null" json:"stellar_address"` PasswordHash string `gorm:"size:255;not null" json:"-"` - Role string `gorm:"size:20;default:'user'" json:"role"` // admin, user - Country string `gorm:"size:2" json:"country"` // ISO country code - KYCStatus string `gorm:"size:20;default:'pending'" json:"kyc_status"` // pending, verified, rejected + Role string `gorm:"size:20;default:'user'" json:"role"` + Country string `gorm:"size:2" json:"country"` + KYCStatus string `gorm:"size:20;default:'pending'" json:"kyc_status"` KYCVerifiedAt *time.Time `json:"kyc_verified_at"` IsActive bool `gorm:"default:true" json:"is_active"` DefaultCurrency string `gorm:"size:10;default:'USD'" json:"default_currency"` } -// TableName overrides the table name +// TableName overrides the table name. func (User) TableName() string { return "users" } + +// ValidatePasswordStrength enforces minimum password requirements before hashing. +func ValidatePasswordStrength(password string) error { + if len(password) < 8 { + return errors.New("password must be at least 8 characters long") + } + var hasUpper, hasLower, hasDigit bool + for _, c := range password { + switch { + case unicode.IsUpper(c): + hasUpper = true + case unicode.IsLower(c): + hasLower = true + case unicode.IsDigit(c): + hasDigit = 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") + } + return nil +} + +// HashPassword validates password strength then hashes it using bcrypt with cost 12. +func HashPassword(password string) (string, error) { + if err := ValidatePasswordStrength(password); err != nil { + return "", err + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return "", err + } + return string(hash), nil +} + +// ComparePassword reports whether the plaintext password matches the stored bcrypt hash. +func ComparePassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/backend/models/user_test.go b/backend/models/user_test.go new file mode 100644 index 0000000..df4981f --- /dev/null +++ b/backend/models/user_test.go @@ -0,0 +1,72 @@ +package models + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHashPassword_GeneratesHash(t *testing.T) { + hash, err := HashPassword("SecurePass1") + require.NoError(t, err) + assert.NotEmpty(t, hash) + assert.NotEqual(t, "SecurePass1", hash) +} + +func TestHashPassword_Uniqueness(t *testing.T) { + // bcrypt generates a unique salt each time — same input must produce different hashes + hash1, err := HashPassword("SecurePass1") + require.NoError(t, err) + hash2, err := HashPassword("SecurePass1") + require.NoError(t, err) + assert.NotEqual(t, hash1, hash2) +} + +func TestHashPassword_WeakPasswordRejected(t *testing.T) { + _, err := HashPassword("weak") + assert.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "password") +} + +func TestComparePassword_Valid(t *testing.T) { + hash, err := HashPassword("SecurePass1") + require.NoError(t, err) + assert.True(t, ComparePassword(hash, "SecurePass1")) +} + +func TestComparePassword_Invalid(t *testing.T) { + hash, err := HashPassword("SecurePass1") + require.NoError(t, err) + assert.False(t, ComparePassword(hash, "WrongPass1")) + assert.False(t, ComparePassword(hash, "")) + assert.False(t, ComparePassword(hash, "securepass1")) +} + +func TestValidatePasswordStrength(t *testing.T) { + tests := []struct { + name string + password string + wantErr bool + }{ + {"valid password", "SecurePass1", false}, + {"minimum 8 chars", "SecPas1X", false}, + {"too short", "Ab1", true}, + {"no uppercase", "securepass1", true}, + {"no lowercase", "SECUREPASS1", true}, + {"no digit", "SecurePasswd", true}, + {"empty string", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePasswordStrength(tt.password) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} From 918a52bbd121cc8a9310b8be1398b80da2da0689 Mon Sep 17 00:00:00 2001 From: dannyy2000 Date: Fri, 24 Apr 2026 02:00:55 +0100 Subject: [PATCH 2/2] fix: remove golang-migrate dependency that was missing from go.mod config/config.go imported github.com/golang-migrate/migrate/v4 but the package was never added to go.mod or go.sum, causing 'go mod download' to fail across config, handlers, and middleware packages. Replaced RunMigrations (file-based SQL migrations) with a clean InitDB that omits the missing dependency. The existing GORM AutoMigrate calls in tests already handle schema creation for the in-memory SQLite DB. --- backend/config/config.go | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/backend/config/config.go b/backend/config/config.go index 9dbeb2a..c47ff3e 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -1,15 +1,10 @@ package config import ( - "database/sql" "fmt" - "log" "os" "time" - "github.com/golang-migrate/migrate/v4" - _ "github.com/golang-migrate/migrate/v4/database/postgres" - _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/joho/godotenv" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -52,35 +47,12 @@ func LoadConfig() (*Config, error) { }, nil } -func RunMigrations(databaseURL string) error { - m, err := migrate.New( - "file://migrations", - databaseURL, - ) - if err != nil { - return fmt.Errorf("failed to create migration instance: %w", err) - } - - if err := m.Up(); err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("failed to run migrations: %w", err) - } - - log.Println("Database migrations completed successfully") - return nil -} - func InitDB(cfg *Config) (*gorm.DB, error) { - // Run migrations first - if err := RunMigrations(cfg.DatabaseURL); err != nil { - return nil, err - } - db, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{}) if err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } - // Configure connection pool sqlDB, err := db.DB() if err != nil { return nil, fmt.Errorf("failed to get sql.DB: %w", err)