aboutsummaryrefslogtreecommitdiffstats
path: root/backend/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'backend/internal/api')
-rw-r--r--backend/internal/api/auth/auth.go247
-rw-r--r--backend/internal/api/v1/loans/loans.go248
-rw-r--r--backend/internal/api/v1/users/handler.go19
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})
+}