diff --git a/auth-jwt/.env.sample b/auth-jwt/.env.sample index e30d19b78e..8a18db58f1 100644 --- a/auth-jwt/.env.sample +++ b/auth-jwt/.env.sample @@ -3,4 +3,5 @@ DB_PORT=5432 DB_USER=postgres DB_PASSWORD= DB_NAME= -SECRET= \ No newline at end of file +SECRET= +ACCESS_TOKEN_TTL_MINUTES=15 diff --git a/auth-jwt/README.md b/auth-jwt/README.md index 68c74b2edb..957be3416c 100644 --- a/auth-jwt/README.md +++ b/auth-jwt/README.md @@ -64,6 +64,8 @@ The following endpoints are available in the API: - **POST /api/auth/register**: Register a new user. - **POST /api/auth/login**: Authenticate a user and return a JWT. +- **POST /api/auth/logout**: Logout a user and revoke their JWT. +- **POST /api/auth/refresh-token**: Refreshes a user token and return a JWT. - **GET /api/user/:id**: Get a user (requires a valid JWT). - **POST /api/user**: Create a new user. - **PATCH /api/user/:id**: Update a user (requires a valid JWT). diff --git a/auth-jwt/database/connect.go b/auth-jwt/database/connect.go deleted file mode 100644 index 3584ebf14a..0000000000 --- a/auth-jwt/database/connect.go +++ /dev/null @@ -1,32 +0,0 @@ -package database - -import ( - "fmt" - "strconv" - - "api-fiber-gorm/config" - "api-fiber-gorm/model" - - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -// ConnectDB connect to db -func ConnectDB() { - var err error - p := config.Config("DB_PORT") - port, err := strconv.ParseUint(p, 10, 32) - if err != nil { - panic("failed to parse database port") - } - - dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", config.Config("DB_HOST"), port, config.Config("DB_USER"), config.Config("DB_PASSWORD"), config.Config("DB_NAME")) - DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if err != nil { - panic("failed to connect database") - } - - fmt.Println("Connection Opened to Database") - DB.AutoMigrate(&model.Product{}, &model.User{}) - fmt.Println("Database Migrated") -} diff --git a/auth-jwt/database/database.go b/auth-jwt/database/database.go index ffbda1379f..7c348dbf19 100644 --- a/auth-jwt/database/database.go +++ b/auth-jwt/database/database.go @@ -1,6 +1,32 @@ package database -import "gorm.io/gorm" +import ( + "fmt" + + "auth-jwt-gorm/config" + "auth-jwt-gorm/models" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) // DB gorm connector var DB *gorm.DB + +// ConnectDB connect to db +func ConnectDB() { + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + config.Config("DB_HOST"), + config.Config("DB_PORT"), + config.Config("DB_USER"), + config.Config("DB_PASSWORD"), + config.Config("DB_NAME")) + + var err error + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + panic("failed to connect database") + } + + DB.AutoMigrate(&models.Product{}, &models.User{}) +} diff --git a/auth-jwt/go.mod b/auth-jwt/go.mod index ff36c4207f..25f40f1e6d 100644 --- a/auth-jwt/go.mod +++ b/auth-jwt/go.mod @@ -1,4 +1,4 @@ -module api-fiber-gorm +module auth-jwt-gorm go 1.23.0 @@ -6,6 +6,7 @@ require ( github.com/gofiber/contrib/jwt v1.1.2 github.com/gofiber/fiber/v2 v2.52.9 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.40.0 gorm.io/driver/postgres v1.6.0 @@ -15,7 +16,6 @@ require ( require ( github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect diff --git a/auth-jwt/handler/api.go b/auth-jwt/handler/api.go deleted file mode 100644 index 7803877b86..0000000000 --- a/auth-jwt/handler/api.go +++ /dev/null @@ -1,8 +0,0 @@ -package handler - -import "github.com/gofiber/fiber/v2" - -// Hello handle api status -func Hello(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"status": "success", "message": "Hello i'm ok!", "data": nil}) -} diff --git a/auth-jwt/handler/auth.go b/auth-jwt/handler/auth.go deleted file mode 100644 index 1774a05b58..0000000000 --- a/auth-jwt/handler/auth.go +++ /dev/null @@ -1,114 +0,0 @@ -package handler - -import ( - "errors" - "net/mail" - "time" - - "api-fiber-gorm/config" - "api-fiber-gorm/database" - "api-fiber-gorm/model" - - "gorm.io/gorm" - - "github.com/gofiber/fiber/v2" - "github.com/golang-jwt/jwt/v5" - "golang.org/x/crypto/bcrypt" -) - -// CheckPasswordHash compare password with hash -func CheckPasswordHash(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil -} - -func getUserByEmail(e string) (*model.User, error) { - db := database.DB - var user model.User - if err := db.Where(&model.User{Email: e}).First(&user).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return nil, err - } - return &user, nil -} - -func getUserByUsername(u string) (*model.User, error) { - db := database.DB - var user model.User - if err := db.Where(&model.User{Username: u}).First(&user).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return nil, err - } - return &user, nil -} - -func isEmail(email string) bool { - _, err := mail.ParseAddress(email) - return err == nil -} - -// Login get user and password -func Login(c *fiber.Ctx) error { - type LoginInput struct { - Identity string `json:"identity"` - Password string `json:"password"` - } - type UserData struct { - ID uint `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - } - input := new(LoginInput) - var userData UserData - - if err := c.BodyParser(&input); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "error", "message": "Error on login request", "data": err}) - } - - identity := input.Identity - pass := input.Password - userModel, err := new(model.User), *new(error) - - if isEmail(identity) { - userModel, err = getUserByEmail(identity) - } else { - userModel, err = getUserByUsername(identity) - } - - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"status": "error", "message": "Internal Server Error", "data": err}) - } else if userModel == nil { - CheckPasswordHash(pass, "") - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "error", "message": "Invalid identity or password", "data": err}) - } else { - userData = UserData{ - ID: userModel.ID, - Username: userModel.Username, - Email: userModel.Email, - Password: userModel.Password, - } - } - - if !CheckPasswordHash(pass, userData.Password) { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "error", "message": "Invalid identity or password", "data": nil}) - } - - token := jwt.New(jwt.SigningMethodHS256) - - claims := token.Claims.(jwt.MapClaims) - claims["username"] = userData.Username - claims["user_id"] = userData.ID - claims["exp"] = time.Now().Add(time.Hour * 72).Unix() - - t, err := token.SignedString([]byte(config.Config("SECRET"))) - if err != nil { - return c.SendStatus(fiber.StatusInternalServerError) - } - - return c.JSON(fiber.Map{"status": "success", "message": "Success login", "data": t}) -} diff --git a/auth-jwt/handler/product.go b/auth-jwt/handler/product.go deleted file mode 100644 index e3316dcd86..0000000000 --- a/auth-jwt/handler/product.go +++ /dev/null @@ -1,53 +0,0 @@ -package handler - -import ( - "api-fiber-gorm/database" - "api-fiber-gorm/model" - - "github.com/gofiber/fiber/v2" -) - -// GetAllProducts query all products -func GetAllProducts(c *fiber.Ctx) error { - db := database.DB - var products []model.Product - db.Find(&products) - return c.JSON(fiber.Map{"status": "success", "message": "All products", "data": products}) -} - -// GetProduct query product -func GetProduct(c *fiber.Ctx) error { - id := c.Params("id") - db := database.DB - var product model.Product - db.Find(&product, id) - if product.Title == "" { - return c.Status(404).JSON(fiber.Map{"status": "error", "message": "No product found with ID", "data": nil}) - } - return c.JSON(fiber.Map{"status": "success", "message": "Product found", "data": product}) -} - -// CreateProduct new product -func CreateProduct(c *fiber.Ctx) error { - db := database.DB - product := new(model.Product) - if err := c.BodyParser(product); err != nil { - return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Couldn't create product", "data": err}) - } - db.Create(&product) - return c.JSON(fiber.Map{"status": "success", "message": "Created product", "data": product}) -} - -// DeleteProduct delete product -func DeleteProduct(c *fiber.Ctx) error { - id := c.Params("id") - db := database.DB - - var product model.Product - db.First(&product, id) - if product.Title == "" { - return c.Status(404).JSON(fiber.Map{"status": "error", "message": "No product found with ID", "data": nil}) - } - db.Delete(&product) - return c.JSON(fiber.Map{"status": "success", "message": "Product successfully deleted", "data": nil}) -} diff --git a/auth-jwt/handler/user.go b/auth-jwt/handler/user.go deleted file mode 100644 index feb04fa625..0000000000 --- a/auth-jwt/handler/user.go +++ /dev/null @@ -1,140 +0,0 @@ -package handler - -import ( - "strconv" - - "api-fiber-gorm/database" - "api-fiber-gorm/model" - - "github.com/gofiber/fiber/v2" - "github.com/golang-jwt/jwt/v5" - "golang.org/x/crypto/bcrypt" -) - -func hashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) - return string(bytes), err -} - -func validToken(t *jwt.Token, id string) bool { - n, err := strconv.Atoi(id) - if err != nil { - return false - } - - claims := t.Claims.(jwt.MapClaims) - uid := int(claims["user_id"].(float64)) - - return uid == n -} - -func validUser(id string, p string) bool { - db := database.DB - var user model.User - db.First(&user, id) - if user.Username == "" { - return false - } - if !CheckPasswordHash(p, user.Password) { - return false - } - return true -} - -// GetUser get a user -func GetUser(c *fiber.Ctx) error { - id := c.Params("id") - db := database.DB - var user model.User - db.Find(&user, id) - if user.Username == "" { - return c.Status(404).JSON(fiber.Map{"status": "error", "message": "No user found with ID", "data": nil}) - } - return c.JSON(fiber.Map{"status": "success", "message": "User found", "data": user}) -} - -// CreateUser new user -func CreateUser(c *fiber.Ctx) error { - type NewUser struct { - Username string `json:"username"` - Email string `json:"email"` - } - - db := database.DB - user := new(model.User) - if err := c.BodyParser(user); err != nil { - return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Review your input", "data": err}) - } - - hash, err := hashPassword(user.Password) - if err != nil { - return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Couldn't hash password", "data": err}) - } - - user.Password = hash - if err := db.Create(&user).Error; err != nil { - return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Couldn't create user", "data": err}) - } - - newUser := NewUser{ - Email: user.Email, - Username: user.Username, - } - - return c.JSON(fiber.Map{"status": "success", "message": "Created user", "data": newUser}) -} - -// UpdateUser update user -func UpdateUser(c *fiber.Ctx) error { - type UpdateUserInput struct { - Names string `json:"names"` - } - var uui UpdateUserInput - if err := c.BodyParser(&uui); err != nil { - return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Review your input", "data": err}) - } - id := c.Params("id") - token := c.Locals("user").(*jwt.Token) - - if !validToken(token, id) { - return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Invalid token id", "data": nil}) - } - - db := database.DB - var user model.User - - db.First(&user, id) - user.Names = uui.Names - db.Save(&user) - - return c.JSON(fiber.Map{"status": "success", "message": "User successfully updated", "data": user}) -} - -// DeleteUser delete user -func DeleteUser(c *fiber.Ctx) error { - type PasswordInput struct { - Password string `json:"password"` - } - var pi PasswordInput - if err := c.BodyParser(&pi); err != nil { - return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Review your input", "data": err}) - } - id := c.Params("id") - token := c.Locals("user").(*jwt.Token) - - if !validToken(token, id) { - return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Invalid token id", "data": nil}) - } - - if !validUser(id, pi.Password) { - return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Not valid user", "data": nil}) - } - - db := database.DB - var user model.User - - db.First(&user, id) - - db.Delete(&user) - return c.JSON(fiber.Map{"status": "success", "message": "User successfully deleted", "data": nil}) -} diff --git a/auth-jwt/handlers/api.go b/auth-jwt/handlers/api.go new file mode 100644 index 0000000000..ee168b6ee6 --- /dev/null +++ b/auth-jwt/handlers/api.go @@ -0,0 +1,12 @@ +package handlers + +import "github.com/gofiber/fiber/v2" + +// Hello handle api status +func Hello(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "success", + "message": "Hello i'm ok!", + "data": nil, + }) +} diff --git a/auth-jwt/handlers/auth.go b/auth-jwt/handlers/auth.go new file mode 100644 index 0000000000..7656b0c071 --- /dev/null +++ b/auth-jwt/handlers/auth.go @@ -0,0 +1,194 @@ +package handlers + +import ( + "errors" + "time" + + "auth-jwt-gorm/services" + + "github.com/gofiber/fiber/v2" +) + +type RegisterRequest struct { + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` +} + +type RegisterResponse struct { + Id string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type LoginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +type RefreshResponse struct { + Token string `json:"token"` +} + +// AuthHandler contains HTTP handlers for authentication +type AuthHandler struct { + authService *services.AuthService +} + +// NewAuthHandler creates a new auth handler +func NewAuthHandler(authService *services.AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + } +} + +// Login get user and password +func (ah *AuthHandler) Login(c *fiber.Ctx) error { + var input LoginRequest + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "status": "error", + "message": "Error on login request", + "data": nil, + }) + } + // Authenticate the user + refreshToken, accessToken, err := ah.authService.LoginWithRefresh(input.Email, input.Password, time.Duration(30*24*time.Hour)) + if err != nil { + if errors.Is(err, services.ErrInvalidCredentials) { + + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "status": "error", + "message": "Invalid credentials", + "data": nil, + }) + } else { + + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "status": "error", + "message": "Internal Server Error", + "data": nil, + }) + } + } + + c.Cookie(&fiber.Cookie{ + Name: "jwt", + Value: accessToken, + Expires: time.Now().Add(time.Hour * 72), + HTTPOnly: true, + }) + + // Return the token + response := LoginResponse{AccessToken: accessToken, RefreshToken: refreshToken} + + return c.JSON(fiber.Map{ + "status": "success", + "message": "Success login", + "data": response, + }) +} + +func (ah *AuthHandler) Logout(c *fiber.Ctx) error { + // Clear cookie + c.Cookie(&fiber.Cookie{ + Name: "jwt", + Value: "", + Expires: time.Now().Add(-time.Hour), + HTTPOnly: true, + }) + + return c.JSON(fiber.Map{ + "status": "success", + "message": "Success logout", + "data": nil, + }) +} + +func (ah *AuthHandler) Register(c *fiber.Ctx) error { + var input RegisterRequest + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "status": "error", + "message": "Error on register request", + "data": nil, + }) + } + user, err := ah.authService.Register(input.Email, input.Username, input.Password) + if err != nil { + if errors.Is(err, services.ErrEmailInUse) { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{ + "status": "error", + "message": "Email already in use", + "data": nil, + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "status": "error", + "message": "Error on registering user", + "data": nil, + }) + } + + newUser := RegisterResponse{ + Email: user.Email, + Username: user.Username, + } + + return c.JSON(fiber.Map{ + "status": "success", + "message": "Success register", + "data": newUser, + }) +} + +func (ah *AuthHandler) RefreshToken(c *fiber.Ctx) error { + var input RefreshRequest + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "status": "error", + "message": "Invalid request payload", + "data": nil, + }) + } + token, err := ah.authService.RefreshAccessToken(input.RefreshToken) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) || errors.Is(err, services.ErrExpiredToken) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "status": "error", + "message": "Invalid or expired refresh token", + "data": nil, + }) + } else { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "status": "error", + "message": "Internal server error", + "data": nil, + }) + } + } + // Clear cookie + c.Cookie(&fiber.Cookie{ + Name: "jwt", + Value: token, + Expires: time.Now().Add(time.Hour * 72), + HTTPOnly: true, + }) + + response := RefreshResponse{Token: token} + + return c.JSON(fiber.Map{ + "status": "success", + "message": "Success refresh token", + "data": response, + }) +} diff --git a/auth-jwt/handlers/product.go b/auth-jwt/handlers/product.go new file mode 100644 index 0000000000..a04bc15f4a --- /dev/null +++ b/auth-jwt/handlers/product.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "auth-jwt-gorm/models" + + "github.com/gofiber/fiber/v2" +) + +// AuthHandler contains HTTP handlers for authentication +type ProductHandler struct { + productRepo *models.ProductRepository +} + +// NewAuthHandler creates a new auth handler +func NewProductHandler(productRepo *models.ProductRepository) *ProductHandler { + return &ProductHandler{ + productRepo: productRepo, + } +} + +// GetAllProducts query all products +func (ph *ProductHandler) GetAllProducts(c *fiber.Ctx) error { + products, err := ph.productRepo.GetAll() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "status": "error", + "message": "Failed to retrieve products", + "data": err, + }) + } + + return c.JSON(fiber.Map{ + "status": "success", + "message": "All products", + "data": products, + }) +} + +// GetProduct query product +func (ph *ProductHandler) GetProduct(c *fiber.Ctx) error { + product, err := ph.productRepo.GetById(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "status": "error", + "message": "No product found with ID", + "data": nil, + }) + } + return c.JSON(fiber.Map{ + "status": "success", + "message": "Product found", + "data": product, + }) +} + +// CreateProduct new product +func (ph *ProductHandler) CreateProduct(c *fiber.Ctx) error { + var product models.Product + if err := c.BodyParser(&product); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "status": "error", + "message": "Review your input", + "data": err, + }) + } + + if err := ph.productRepo.Create(&product); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "status": "error", + "message": "Couldn't create product", + "data": err, + }) + } + + return c.JSON(fiber.Map{ + "status": "success", + "message": "Created product", + "data": product, + }) +} + +// DeleteProduct delete product +func (ph *ProductHandler) DeleteProduct(c *fiber.Ctx) error { + product, err := ph.productRepo.GetById(c.Params("id")) + if err != nil || product == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "status": "error", + "message": "No product found with ID", + "data": nil, + }) + } + + if err = ph.productRepo.Delete(c.Params("id")); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "status": "error", + "message": "Delete product failed!", + "data": nil, + }) + } + + return c.JSON(fiber.Map{ + "status": "success", + "message": "Product successfully deleted", + "data": nil, + }) +} diff --git a/auth-jwt/handlers/user.go b/auth-jwt/handlers/user.go new file mode 100644 index 0000000000..878c67ea7a --- /dev/null +++ b/auth-jwt/handlers/user.go @@ -0,0 +1,195 @@ +package handlers + +import ( + "auth-jwt-gorm/models" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +// AuthHandler contains HTTP handlers for authentication +type UserHandler struct { + userRepo *models.UserRepository +} + +// NewAuthHandler creates a new auth handler +func NewUserHandler(userRepo *models.UserRepository) *UserHandler { + return &UserHandler{ + userRepo: userRepo, + } +} + +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + return string(bytes), err +} + +func validToken(t *jwt.Token, id string) bool { + claims, ok := t.Claims.(jwt.MapClaims) + if !ok { + return false + } + sub, ok := claims["sub"].(string) + if !ok { + return false + } + return sub == id +} + +// GetUser get a user +func (uh *UserHandler) GetUser(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return err + } + user, err := uh.userRepo.GetUserByID(uint(id)) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "status": "error", + "message": "No user found with ID", + "data": nil, + }) + } + return c.JSON(fiber.Map{ + "status": "success", + "message": "User found", + "data": user, + }) +} + +// CreateUser new user +func (uh *UserHandler) CreateUser(c *fiber.Ctx) error { + var user models.User + if err := c.BodyParser(&user); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "status": "error", + "message": "Review your input", + "data": err, + }) + } + + if _, err := uh.userRepo.GetUserByEmail(user.Email); err == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "status": "error", + "message": "User already exists", + "data": nil, + }) + } + + hash, err := hashPassword(user.Password) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "status": "error", + "message": "Couldn't hash password", + "data": err, + }) + } + + user.Password = hash + if _, err := uh.userRepo.CreateUser(user.Email, user.Username, user.Password); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "status": "error", + "message": "Couldn't create user", + "data": err, + }) + } + + return c.JSON(fiber.Map{ + "status": "success", + "message": "Created user", + "data": fiber.Map{ + "username": user.Username, + "email": user.Email, + }, + }) +} + +// UpdateUser update user +func (uh *UserHandler) UpdateUser(c *fiber.Ctx) error { + var input struct { + Names string `json:"names"` + } + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "status": "error", + "message": "Review your input", + "data": err, + }) + } + + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return err + } + + tok, ok := c.Locals("user").(*jwt.Token) + if !ok || !validToken(tok, strconv.Itoa(id)) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "status": "error", + "message": "Invalid token id", + "data": nil, + }) + } + + user, err := uh.userRepo.GetUserByID(uint(id)) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "status": "error", + "message": "User not found", + "data": nil, + }) + } + + user.Names = input.Names + updatedUser, err := uh.userRepo.UpdateUser(uint(id), *user) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "status": "error", + "message": "User update failed", + "data": nil, + }) + } + + return c.JSON(fiber.Map{ + "status": "success", + "message": "User successfully updated", + "data": updatedUser, + }) +} + +// DeleteUser delete user +func (uh *UserHandler) DeleteUser(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return err + } + + tok, ok := c.Locals("user").(*jwt.Token) + if !ok || !validToken(tok, strconv.Itoa(id)) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "status": "error", + "message": "Invalid token id", + "data": nil, + }) + } + + err = uh.userRepo.DeleteUser(uint(id)) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "status": "error", + "message": "User not found", + "data": nil, + }) + } + + c.Locals("user", nil) + c.ClearCookie("jwt") + + return c.JSON(fiber.Map{ + "status": "success", + "message": "User successfully deleted", + "data": nil, + }) +} diff --git a/auth-jwt/main.go b/auth-jwt/main.go index e840abfce2..fd74b38550 100644 --- a/auth-jwt/main.go +++ b/auth-jwt/main.go @@ -3,9 +3,8 @@ package main import ( "log" - "api-fiber-gorm/database" - "api-fiber-gorm/router" - + "auth-jwt-gorm/database" + "auth-jwt-gorm/router" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" ) diff --git a/auth-jwt/middleware/auth.go b/auth-jwt/middleware/auth.go index 73ed530ae5..c7de492381 100644 --- a/auth-jwt/middleware/auth.go +++ b/auth-jwt/middleware/auth.go @@ -1,25 +1,31 @@ package middleware import ( - "api-fiber-gorm/config" + "os" jwtware "github.com/gofiber/contrib/jwt" "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" ) // Protected protect routes func Protected() fiber.Handler { return jwtware.New(jwtware.Config{ - SigningKey: jwtware.SigningKey{Key: []byte(config.Config("SECRET"))}, - ErrorHandler: jwtError, - }) -} + SigningKey: jwtware.SigningKey{JWTAlg: jwt.SigningMethodHS256.Name, Key: os.Getenv("SECRET")}, + ErrorHandler: func(c *fiber.Ctx, err error) error { + status := fiber.StatusUnauthorized + message := "Invalid or expired JWT" -func jwtError(c *fiber.Ctx, err error) error { - if err.Error() == "Missing or malformed JWT" { - return c.Status(fiber.StatusBadRequest). - JSON(fiber.Map{"status": "error", "message": "Missing or malformed JWT", "data": nil}) - } - return c.Status(fiber.StatusUnauthorized). - JSON(fiber.Map{"status": "error", "message": "Invalid or expired JWT", "data": nil}) + if err.Error() == "Missing or malformed JWT" { + status = fiber.StatusBadRequest + message = "Missing or malformed JWT" + } + + return c.Status(status).JSON(fiber.Map{ + "status": "error", + "message": message, + "data": nil, + }) + }, + }) } diff --git a/auth-jwt/model/product.go b/auth-jwt/model/product.go deleted file mode 100644 index 2278f83193..0000000000 --- a/auth-jwt/model/product.go +++ /dev/null @@ -1,11 +0,0 @@ -package model - -import "gorm.io/gorm" - -// Product struct -type Product struct { - gorm.Model - Title string `gorm:"not null" json:"title"` - Description string `gorm:"not null" json:"description"` - Amount int `gorm:"not null" json:"amount"` -} diff --git a/auth-jwt/model/user.go b/auth-jwt/model/user.go deleted file mode 100644 index cfca8546c5..0000000000 --- a/auth-jwt/model/user.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -import "gorm.io/gorm" - -// User struct -type User struct { - gorm.Model - Username string `gorm:"uniqueIndex;not null" json:"username"` - Email string `gorm:"uniqueIndex;not null" json:"email"` - Password string `gorm:"not null" json:"password"` - Names string `json:"names"` -} diff --git a/auth-jwt/models/product.go b/auth-jwt/models/product.go new file mode 100644 index 0000000000..1d9ff4d5a8 --- /dev/null +++ b/auth-jwt/models/product.go @@ -0,0 +1,47 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Product struct +type Product struct { + gorm.Model + Title string `gorm:"not null" json:"title"` + Description string `gorm:"not null" json:"description"` + Amount int `gorm:"not null" json:"amount"` +} + + +type ProductRepository struct { + db *gorm.DB +} + +func NewProductRepository(db *gorm.DB) *ProductRepository { + return &ProductRepository{db: db} +} + +func (r *ProductRepository) Create(product *Product) error { + return r.db.Create(product).Error +} + +func (r *ProductRepository) GetAll() ([]Product, error) { + var products []Product + err := r.db.Find(&products).Error + return products, err +} + +func (r *ProductRepository) GetById(id string) (*Product, error) { + var product Product + err := r.db.First(&product, "id = ?", id).Error + return &product, err +} + +func (r *ProductRepository) Update(product *Product) error { + return r.db.Save(product).Error +} + +func (r *ProductRepository) Delete(id string) error { + return r.db.Delete(&Product{}, "id = ?", id).Error +} + diff --git a/auth-jwt/models/refresh_token.go b/auth-jwt/models/refresh_token.go new file mode 100644 index 0000000000..bf362c4763 --- /dev/null +++ b/auth-jwt/models/refresh_token.go @@ -0,0 +1,58 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// RefreshToken represents a refresh token in the system +type RefreshToken struct { + gorm.Model + UserId uint `gorm:"not null" json:"user_id"` + Token string `gorm:"uniqueIndex;not null" json:"token"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + Revoked bool `gorm:"not null" json:"revoked"` +} + +// RefreshTokenRepository handles database operations for refresh tokens +type RefreshTokenRepository struct { + db *gorm.DB +} + +// NewRefreshTokenRepository creates a new refresh token repository +func NewRefreshTokenRepository(db *gorm.DB) *RefreshTokenRepository { + return &RefreshTokenRepository{db: db} +} + +// CreateRefreshToken creates a new refresh token for a user +func (r *RefreshTokenRepository) CreateRefreshToken(userId uint, ttl time.Duration) (*RefreshToken, error) { + token := &RefreshToken{ + Token: uuid.New().String(), + UserId: userId, + ExpiresAt: time.Now().Add(ttl), + Revoked: false, + } + if err := r.db.Create(token).Error; err != nil { + return nil, err + } + return token, nil +} + +// GetRefreshToken retrieves a refresh token by its token string +func (r *RefreshTokenRepository) GetRefreshToken(tokenString string) (*RefreshToken, error) { + var token RefreshToken + if err := r.db.Where("token = ?", tokenString).First(&token).Error; err != nil { + return nil, err + } + return &token, nil +} + +// RevokeRefreshToken marks a refresh token as revoked +func (r *RefreshTokenRepository) RevokeRefreshToken(tokenString string) error { + if err := r.db.Model(&RefreshToken{}).Where("token = ?", tokenString).Update("revoked", true).Error; err != nil { + return err + } + return nil +} diff --git a/auth-jwt/models/user.go b/auth-jwt/models/user.go new file mode 100644 index 0000000000..b132e25b67 --- /dev/null +++ b/auth-jwt/models/user.go @@ -0,0 +1,83 @@ +package models + +import ( + "time" + "gorm.io/gorm" +) + +// User struct +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex;not null" json:"username"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Password string `gorm:"not null" json:"password"` + Names string `json:"names"` + LastLogin *time.Time `json:"last_login"` +} + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db: db} +} + +// CreateUser adds a new user to the database +func (r *UserRepository) CreateUser(email, username, passwordHash string) (*User, error) { + user := &User{ + Email: email, + Username: username, + Password: passwordHash, + } + + if err := r.db.Create(user).Error; err != nil { + return nil, err + } + + return user, nil +} + +// GetUserByEmail retrieves a user by their email address +func (r *UserRepository) GetUserByEmail(email string) (*User, error) { + var user User + if err := r.db.Where("email = ?", email).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// GetUserByID retrieves a user by their ID +func (r *UserRepository) GetUserByID(id uint) (*User, error) { + var user User + if err := r.db.Where("id = ?", id).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) DeleteUser(id uint) error { + var user User + if err := r.db.Where("id = ?", id).Delete(&user).Error; err != nil { + return err + } + return nil +} + +func (r *UserRepository) UpdateUser(id uint,updateUser User) (*User, error) { + var user User + if err := r.db.Where("id = ?", id).First(&user).Error; err != nil { + return nil, err + } + user.Email = updateUser.Email + user.Username = updateUser.Username + user.Password = updateUser.Password + user.Names = updateUser.Names + + if err := r.db.Save(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + + diff --git a/auth-jwt/router/router.go b/auth-jwt/router/router.go index 8f6099860e..d6290ad0ee 100644 --- a/auth-jwt/router/router.go +++ b/auth-jwt/router/router.go @@ -1,8 +1,13 @@ package router import ( - "api-fiber-gorm/handler" - "api-fiber-gorm/middleware" + "auth-jwt-gorm/database" + "auth-jwt-gorm/handlers" + "auth-jwt-gorm/middleware" + "auth-jwt-gorm/models" + "auth-jwt-gorm/services" + "os" + "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/logger" @@ -12,23 +17,44 @@ import ( func SetupRoutes(app *fiber.App) { // Middleware api := app.Group("/api", logger.New()) - api.Get("/", handler.Hello) + api.Get("/", handlers.Hello) // Auth + userRepo := models.NewUserRepository(database.DB) + refreshTokenRepo := models.NewRefreshTokenRepository(database.DB) + jwtSecret := os.Getenv("SECRET") + if jwtSecret == "" { + panic("SECRET environment variable is required") + } + accessTTL := 15 * time.Minute + if ttlEnv := os.Getenv("ACCESS_TOKEN_TTL_MINUTES"); ttlEnv != "" { + if ttl, err := time.ParseDuration(ttlEnv + "m"); err == nil { + accessTTL = ttl + } + } + authService := services.NewAuthService(userRepo, refreshTokenRepo, jwtSecret, accessTTL) + authHandler := handlers.NewAuthHandler(authService) + auth := api.Group("/auth") - auth.Post("/login", handler.Login) + auth.Post("/login", authHandler.Login) + auth.Post("/register", authHandler.Register) + auth.Post("/logout", authHandler.Logout) + auth.Post("/refresh-token", authHandler.RefreshToken) // User - user := api.Group("/user") - user.Get("/:id", handler.GetUser) - user.Post("/", handler.CreateUser) - user.Patch("/:id", middleware.Protected(), handler.UpdateUser) - user.Delete("/:id", middleware.Protected(), handler.DeleteUser) + userHandler := handlers.NewUserHandler(userRepo) + user := api.Group("/users") + user.Get("/:id", middleware.Protected(), userHandler.GetUser) + user.Patch("/:id", middleware.Protected(), userHandler.UpdateUser) + user.Delete("/:id", middleware.Protected(), userHandler.DeleteUser) // Product - product := api.Group("/product") - product.Get("/", handler.GetAllProducts) - product.Get("/:id", handler.GetProduct) - product.Post("/", middleware.Protected(), handler.CreateProduct) - product.Delete("/:id", middleware.Protected(), handler.DeleteProduct) + productRepo := models.NewProductRepository(database.DB) + productHandler := handlers.NewProductHandler(productRepo) + + product := api.Group("/products") + product.Get("/", productHandler.GetAllProducts) + product.Get("/:id", productHandler.GetProduct) + product.Post("/", middleware.Protected(), productHandler.CreateProduct) + product.Delete("/:id", middleware.Protected(), productHandler.DeleteProduct) } diff --git a/auth-jwt/services/auth.go b/auth-jwt/services/auth.go new file mode 100644 index 0000000000..5ce31a37aa --- /dev/null +++ b/auth-jwt/services/auth.go @@ -0,0 +1,182 @@ +package services + +import ( + "auth-jwt-gorm/models" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "gorm.io/gorm" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidToken = errors.New("invalid token") + ErrExpiredToken = errors.New("token has expired") + ErrEmailInUse = errors.New("email already in use") +) + +// AuthService provides authentication functionality +type AuthService struct { + userRepo *models.UserRepository + refreshTokenRepo *models.RefreshTokenRepository + jwtSecret []byte + accessTokenTTL time.Duration +} + +// NewAuthService creates a new authentication service +func NewAuthService(userRepo *models.UserRepository, refreshTokenRepo *models.RefreshTokenRepository, jwtSecret string, accessTokenTTL time.Duration) *AuthService { + return &AuthService{ + userRepo: userRepo, + refreshTokenRepo: refreshTokenRepo, + jwtSecret: []byte(jwtSecret), + accessTokenTTL: accessTokenTTL, + } +} + +func (s *AuthService) GetUserByEmail(email string) (*models.User, error) { + user, err := s.userRepo.GetUserByEmail(email) + if err != nil { + return nil, err + } + + return user, nil +} + +// Register creates a new user with the provided credentials +func (s *AuthService) Register(email, username, password string) (*models.User, error) { + // Check if user already exists + _, err := s.userRepo.GetUserByEmail(email) + if err == nil { + return nil, ErrEmailInUse + } + // Return database errors; only proceed if user not found + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + // Hash the password + hashedPassword, err := HashPassword(password) + if err != nil { + return nil, err + } + + user, err := s.userRepo.CreateUser(email, username, hashedPassword) + if err != nil { + return nil, err + } + return user, nil +} + +// generateAccessToken creates a new JWT access token +func (s *AuthService) generateAccessToken(user *models.User) (string, error) { + // Set the expiration time + expirationTime := time.Now().Add(s.accessTokenTTL) + + // Create the JWT claims + claims := jwt.MapClaims{ + "sub": user.ID, // subject (user ID) + "username": user.Username, // custom claim + "email": user.Email, // custom claim + "exp": expirationTime.Unix(), // expiration time + "iat": time.Now().Unix(), // issued at time + } + + // Create the token with claims + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign the token with our secret key + tokenString, err := token.SignedString(s.jwtSecret) + if err != nil { + return "", err + } + + return tokenString, nil +} + +// ValidateToken verifies a JWT token and returns the claims +func (s *AuthService) ValidateToken(tokenString string) (jwt.MapClaims, 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, ErrInvalidToken + } + return s.jwtSecret, nil + }) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + return nil, ErrInvalidToken + } + + // Extract and validate claims + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return claims, nil + } + + return nil, ErrInvalidToken +} + +// LoginWithRefresh authenticates a user and returns both access and refresh tokens +func (s *AuthService) LoginWithRefresh(email, password string, refreshTokenTTL time.Duration) (accessToken string, refreshToken string, err error) { + // Get the user from the database + user, err := s.userRepo.GetUserByEmail(email) + if err != nil { + return "", "", ErrInvalidCredentials + } + + // Verify the password + if err := VerifyPassword(user.Password, password); err != nil { + return "", "", ErrInvalidCredentials + } + + // Generate an access token + accessToken, err = s.generateAccessToken(user) + if err != nil { + return "", "", err + } + + // Create a refresh token + token, err := s.refreshTokenRepo.CreateRefreshToken(user.ID, refreshTokenTTL) + if err != nil { + return "", "", err + } + + return accessToken, token.Token, nil +} + +// RefreshAccessToken creates a new access token using a refresh token +func (s *AuthService) RefreshAccessToken(refreshTokenString string) (string, error) { + // Retrieve the refresh token + token, err := s.refreshTokenRepo.GetRefreshToken(refreshTokenString) + if err != nil { + return "", ErrInvalidToken + } + + // Check if the token is valid + if token.Revoked { + return "", ErrInvalidToken + } + + // Check if the token has expired + if time.Now().After(token.ExpiresAt) { + return "", ErrExpiredToken + } + + // Get the user + user, err := s.userRepo.GetUserByID(token.UserId) + if err != nil { + return "", err + } + + // Generate a new access token + accessToken, err := s.generateAccessToken(user) + if err != nil { + return "", err + } + + return accessToken, nil +} diff --git a/auth-jwt/services/password.go b/auth-jwt/services/password.go new file mode 100644 index 0000000000..607f169d9a --- /dev/null +++ b/auth-jwt/services/password.go @@ -0,0 +1,21 @@ +package services + +import "golang.org/x/crypto/bcrypt" + +// HashPassword creates a bcrypt hash from a plain-text password +func HashPassword(password string) (string, error) { + // The cost determines how computationally expensive the hash is + // Higher is more secure but slower (default is 10) + hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hashedBytes), nil +} + +// VerifyPassword checks if the provided password matches the stored hash +func VerifyPassword(hashedPassword, providedPassword string) error { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(providedPassword)) +} + +