Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions backend/config/config.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
122 changes: 113 additions & 9 deletions backend/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -18,26 +20,130 @@ 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 {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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
Expand All @@ -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"})
Expand All @@ -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"})
Expand Down
58 changes: 58 additions & 0 deletions backend/logger/logger.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading