diff options
Diffstat (limited to 'backend/internal/api')
-rw-r--r-- | backend/internal/api/auth/auth.go | 247 | ||||
-rw-r--r-- | backend/internal/api/v1/loans/loans.go | 248 | ||||
-rw-r--r-- | backend/internal/api/v1/users/handler.go | 19 |
3 files changed, 514 insertions, 0 deletions
diff --git a/backend/internal/api/auth/auth.go b/backend/internal/api/auth/auth.go new file mode 100644 index 0000000..2f8fa6a --- /dev/null +++ b/backend/internal/api/auth/auth.go @@ -0,0 +1,247 @@ +package auth + +import ( + "finance/backend/internal/config" + "finance/backend/internal/database" + "finance/backend/internal/models" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// Request and Response Types + +// SignupRequest represents the data needed for registering a new user +type SignupRequest struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` +} + +// LoginRequest represents the data needed for user login +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +// AuthResponse is returned on successful authentication +type AuthResponse struct { + Token string `json:"token"` + User models.User `json:"user"` +} + +// JWT Token Functions + +// GenerateJWT creates a new JWT token for a given user ID. +func GenerateJWT(userID uint, cfg *config.Config) (string, error) { + // Create the claims + claims := jwt.MapClaims{ + "sub": userID, // Subject (user ID) + "iss": "finance-app", // Issuer + "aud": "finance-app-users", // Audience + "exp": time.Now().Add(time.Hour * 24).Unix(), // Expiration time (e.g., 24 hours) + "iat": time.Now().Unix(), // Issued at + } + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign token with secret + signedToken, err := token.SignedString([]byte(cfg.JWTSecret)) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return signedToken, nil +} + +// ValidateJWT validates a JWT token and extracts the user ID from it. +// Returns the user ID if the token is valid, or an error if it's not. +func ValidateJWT(tokenString string, cfg *config.Config) (uint, error) { + // Parse the token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + // Return the secret key + return []byte(cfg.JWTSecret), nil + }) + + if err != nil { + return 0, fmt.Errorf("failed to parse token: %w", err) + } + + // Check if token is valid + if !token.Valid { + return 0, fmt.Errorf("invalid token") + } + + // Extract claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return 0, fmt.Errorf("invalid token claims") + } + + // Verify expiration time + if exp, ok := claims["exp"].(float64); ok { + if time.Now().Unix() > int64(exp) { + return 0, fmt.Errorf("token expired") + } + } else { + return 0, fmt.Errorf("invalid expiration claim") + } + + // Extract user ID from subject claim + sub, ok := claims["sub"].(float64) + if !ok { + return 0, fmt.Errorf("invalid subject claim") + } + + // Convert to uint and return + return uint(sub), nil +} + +// Handler Functions + +// Signup handles user registration +func Signup(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + var req SignupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Check if user with this email already exists + var existingUser models.User + result := database.DB.Where("email = ?", req.Email).First(&existingUser) + if result.RowsAffected > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "User with this email already exists"}) + return + } + + // Create new user + user := models.User{ + Name: req.Name, + Email: req.Email, + } + + // Set password (this will hash it) + if err := user.SetPassword(req.Password); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process password"}) + return + } + + // Save user to database + if err := database.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) + return + } + + // Generate JWT token + token, err := GenerateJWT(user.ID, cfg) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate authentication token"}) + return + } + + // Clear password hash before returning user data + user.PasswordHash = "" + + c.JSON(http.StatusCreated, AuthResponse{ + Token: token, + User: user, + }) + } +} + +// Login handles user authentication +func Login(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Find user by email + var user models.User + result := database.DB.Where("email = ?", req.Email).First(&user) + if result.Error != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) + return + } + + // Check password + if err := user.CheckPassword(req.Password); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) + return + } + + // Generate JWT token + token, err := GenerateJWT(user.ID, cfg) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate authentication token"}) + return + } + + // Clear password hash before returning user data + user.PasswordHash = "" + + c.JSON(http.StatusOK, AuthResponse{ + Token: token, + User: user, + }) + } +} + +// AuthMiddleware validates JWT tokens and adds user data to the context +func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) + c.Abort() + return + } + + // Check if the Authorization header has the Bearer prefix + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"}) + c.Abort() + return + } + + // Extract the token + tokenString := parts[1] + + // Validate the token + userID, err := ValidateJWT(tokenString, cfg) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) + c.Abort() + return + } + + // Find the user in the database + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + c.Abort() + return + } + + // Set user data in context + c.Set("userID", userID) + c.Set("user", user) + + c.Next() + } +} diff --git a/backend/internal/api/v1/loans/loans.go b/backend/internal/api/v1/loans/loans.go new file mode 100644 index 0000000..06d96b0 --- /dev/null +++ b/backend/internal/api/v1/loans/loans.go @@ -0,0 +1,248 @@ +package loans + +import ( + "net/http" + "strconv" + "time" + + "finance/backend/internal/database" + "finance/backend/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// GetLoans returns all loans for the authenticated user +func GetLoans() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userObj := user.(*models.User) + var loans []models.Loan + + // Fetch all loans for the user + if err := database.DB.Where("user_id = ?", userObj.ID).Find(&loans).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loans"}) + return + } + + c.JSON(http.StatusOK, gin.H{"loans": loans}) + } +} + +// GetLoanByID returns a specific loan by ID +func GetLoanByID() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(*models.User) + + // Get loan ID from URL parameter + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"}) + return + } + + var loan models.Loan + + // Fetch the loan and ensure it belongs to the authenticated user + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"loan": loan}) + } +} + +// CreateLoan creates a new loan +func CreateLoan() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(*models.User) + + // Define a struct to bind the request JSON + var input struct { + Name string `json:"name" binding:"required"` + OriginalAmount int64 `json:"originalAmount" binding:"required"` + CurrentBalance int64 `json:"currentBalance" binding:"required"` + InterestRate float64 `json:"interestRate"` + StartDate string `json:"startDate" binding:"required"` + EndDate string `json:"endDate" binding:"required"` + AccountID *uint `json:"accountId"` + } + + // Bind JSON to struct + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Parse dates + startDate, err := time.Parse("2006-01-02", input.StartDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start date format"}) + return + } + + endDate, err := time.Parse("2006-01-02", input.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format"}) + return + } + + // Create loan object + loan := models.Loan{ + UserID: userObj.ID, + Name: input.Name, + OriginalAmount: input.OriginalAmount, + CurrentBalance: input.CurrentBalance, + InterestRate: input.InterestRate, + StartDate: startDate, + EndDate: endDate, + AccountID: input.AccountID, + } + + // Save to database + if err := database.DB.Create(&loan).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create loan"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"loan": loan}) + } +} + +// UpdateLoan updates an existing loan +func UpdateLoan() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(*models.User) + + // Get loan ID from URL parameter + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"}) + return + } + + // Check if the loan exists and belongs to the user + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"}) + } + return + } + + // Define a struct to bind the request JSON + var input struct { + Name string `json:"name"` + CurrentBalance int64 `json:"currentBalance"` + InterestRate float64 `json:"interestRate"` + EndDate string `json:"endDate"` + AccountID *uint `json:"accountId"` + } + + // Bind JSON to struct + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update fields if provided + if input.Name != "" { + loan.Name = input.Name + } + if input.CurrentBalance != 0 { + loan.CurrentBalance = input.CurrentBalance + } + if input.InterestRate != 0 { + loan.InterestRate = input.InterestRate + } + if input.EndDate != "" { + endDate, err := time.Parse("2006-01-02", input.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format"}) + return + } + loan.EndDate = endDate + } + if input.AccountID != nil { + loan.AccountID = input.AccountID + } + + // Save updates to database + if err := database.DB.Save(&loan).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update loan"}) + return + } + + c.JSON(http.StatusOK, gin.H{"loan": loan}) + } +} + +// DeleteLoan deletes a loan +func DeleteLoan() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(*models.User) + + // Get loan ID from URL parameter + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"}) + return + } + + // Check if the loan exists and belongs to the user + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"}) + } + return + } + + // Delete the loan + if err := database.DB.Delete(&loan).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete loan"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Loan deleted successfully"}) + } +} diff --git a/backend/internal/api/v1/users/handler.go b/backend/internal/api/v1/users/handler.go new file mode 100644 index 0000000..9e53147 --- /dev/null +++ b/backend/internal/api/v1/users/handler.go @@ -0,0 +1,19 @@ +package users + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetCurrentUser returns the authenticated user's information +func GetCurrentUser(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"user": user}) +} |