diff --git a/go.mod b/go.mod index b5f2326..7ec56b6 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/gofiber/schema v1.5.0 // indirect github.com/gofiber/swagger v1.1.1 // indirect github.com/gofiber/utils/v2 v2.0.0-beta.9 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // 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 @@ -59,11 +60,11 @@ require ( go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.39.0 // indirect + golang.org/x/crypto v0.40.0 // indirect golang.org/x/net v0.41.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect golang.org/x/tools v0.34.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index bbb8cb6..74bba5a 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA github.com/gofiber/swagger v1.1.1/go.mod h1:vtvY/sQAMc/lGTUCg0lqmBL7Ht9O7uzChpbvJeJQINw= github.com/gofiber/utils/v2 v2.0.0-beta.9 h1:IMb2TpF2bb1spuB63GuiOZJXFfq9VJe98ofFJoy0EAY= github.com/gofiber/utils/v2 v2.0.0-beta.9/go.mod h1:XjKLrtxE77EyWzzWGWAepv3NLclRSZkAG+Y+GfPcKeQ= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -122,15 +124,23 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= diff --git a/internal/app.go b/internal/app.go index f2bef65..fddfe5c 100644 --- a/internal/app.go +++ b/internal/app.go @@ -16,25 +16,37 @@ import ( func App(db *gorm.DB) *fiber.App { fmt.Println("Initializing App...") + // Task var tr repository.ITaskRepository = repository.New(db) var ts service.ITaskService = service.New(tr) var th handler.TaskHandler = *handler.New(ts) + // User & Auth + var ur repository.IUserRepository = repository.NewUserRepo(db) + var us service.IUserService = service.NewUserService(ur) + var ah handler.AuthHandler = *handler.NewAuthHandler(us) + app := fiber.New() app.Get("/health", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + // Monitoring prometheus := fiberprometheus.New("devtasker") prometheus.RegisterAt(app, "/metrics") app.Use(prometheus.Middleware) + // Middleware app.Use(middleware.Logger) + app.Use(middleware.Authorization) + // API Doc app.Get("/doc/*", swagger.HandlerDefault) + // API api := app.Group("/api") handler.TaskRouter(api, th) + handler.AuthRouter(api, ah) fmt.Println("App initiated successfully!") diff --git a/internal/dto/task.go b/internal/dto/task.go new file mode 100644 index 0000000..2a04ca4 --- /dev/null +++ b/internal/dto/task.go @@ -0,0 +1,14 @@ +package dto + +import "devtasker/internal/model" + +type CreateTaskRequest struct { + Title string `json:"title"` + Description string `json:"description"` +} + +type UpdateTaskRequest struct { + Title string `json:"title"` + Description string `json:"description"` + Status model.TaskStatus `json:"status"` +} diff --git a/internal/dto/user.go b/internal/dto/user.go new file mode 100644 index 0000000..6e18fd9 --- /dev/null +++ b/internal/dto/user.go @@ -0,0 +1,12 @@ +package dto + +type RegisterUserRequest struct { + Name string `json:"name"` + Username string `json:"username"` + Passwrod string `json:"password"` +} + +type LoginUserRequest struct { + Username string `json:"username"` + Passwrod string `json:"password"` +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..b4a25ec --- /dev/null +++ b/internal/handler/auth.go @@ -0,0 +1,57 @@ +package handler + +import ( + "devtasker/internal/dto" + "devtasker/internal/service" + "devtasker/internal/utils" + + "github.com/gofiber/fiber/v2" +) + +// ===== Router ===== +func AuthRouter(api fiber.Router, ah AuthHandler) { + api.Route("/auth", func(authRouter fiber.Router) { + authRouter.Post("/register", ah.Register) + authRouter.Post("/login", ah.Login) + }) +} + +// ===== Handler ===== +type AuthHandler struct { + s service.IUserService +} + +func NewAuthHandler(s service.IUserService) *AuthHandler { + return &AuthHandler{ + s: s, + } +} + +func (ah *AuthHandler) Register(c *fiber.Ctx) error { + rur := new(dto.RegisterUserRequest) + if err := c.BodyParser(rur); err != nil { + utils.ErrorLogger.Println("Failed to parse the body:\n", c.Body()) + return c.Status(fiber.StatusInternalServerError).JSON(err) + } + u, err := ah.s.Register(rur.Name, rur.Username, rur.Passwrod) + if err != nil { + utils.ErrorLogger.Println("Failed to register the user:\n", err) + return c.Status(fiber.StatusInternalServerError).JSON(err) + } + return c.JSON(u) +} + +func (ah *AuthHandler) Login(c *fiber.Ctx) error { + lur := new(dto.LoginUserRequest) + if err := c.BodyParser(lur); err != nil { + utils.ErrorLogger.Println("Failed to parse the body:\n", c.Body()) + return c.Status(fiber.StatusInternalServerError).JSON(err) + + } + token, err := ah.s.Login(lur.Username, lur.Passwrod) + if err != nil { + utils.ErrorLogger.Println("Failed to login the user:\n", err) + return c.Status(fiber.StatusInternalServerError).JSON(err) + } + return c.JSON(token) +} diff --git a/internal/handler/task.go b/internal/handler/task.go index 23cc9a5..f887022 100644 --- a/internal/handler/task.go +++ b/internal/handler/task.go @@ -1,7 +1,7 @@ package handler import ( - "devtasker/internal/model" + "devtasker/internal/dto" "devtasker/internal/service" "devtasker/internal/utils" @@ -41,7 +41,7 @@ func New(s service.ITaskService) *TaskHandler { // @Failure 500 {object} error // @Router /api/task [post] func (th *TaskHandler) CreateTask(c *fiber.Ctx) error { - ctr := new(model.CreateTaskRequest) + ctr := new(dto.CreateTaskRequest) if err := c.BodyParser(ctr); err != nil { utils.ErrorLogger.Println("Failed to parse the body:\n", c.Body()) return err @@ -103,7 +103,7 @@ func (th *TaskHandler) GetTaskByID(c *fiber.Ctx) error { // @Router /api/task/{id} [patch] func (th *TaskHandler) UpdateTask(c *fiber.Ctx) error { id := c.Params("id") - b := new(model.UpdateTaskRequest) + b := new(dto.UpdateTaskRequest) if err := c.BodyParser(b); err != nil { utils.ErrorLogger.Println("Failed to parse the body:\n", c.Body()) return err diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..58b2a91 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "devtasker/internal/utils" + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +func Authorization(c *fiber.Ctx) error { + if strings.Contains(c.Path(), "auth") { + return c.Next() + } + + bearerToken := c.Get("Authorization") + if !strings.HasPrefix(bearerToken, "Bearer ") { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Missing or malformed token", + }) + } + + token := strings.Split(bearerToken, " ")[1] + claims, ok := utils.ExtractClaims(token) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Token invalid", + }) + } + + if expRaw, ok := claims["exp"].(float64); ok { + expTime := time.Unix(int64(expRaw), 0) + if time.Now().After(expTime) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Token expired", + }) + } + } + + c.Locals("username", claims["username"]) + c.Locals("name", claims["name"]) + + return c.Next() +} diff --git a/internal/model/task.dto.go b/internal/model/task.dto.go deleted file mode 100644 index be20a30..0000000 --- a/internal/model/task.dto.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -type CreateTaskRequest struct { - Title string `json:"title"` - Description string `json:"description"` -} - -type UpdateTaskRequest struct { - Title string `json:"title"` - Description string `json:"description"` - Status TaskStatus `json:"status"` -} diff --git a/internal/model/task.go b/internal/model/task.go index 563581b..187df31 100644 --- a/internal/model/task.go +++ b/internal/model/task.go @@ -1,5 +1,7 @@ package model +import "time" + type TaskStatus string const ( @@ -11,9 +13,10 @@ const ( ) type Task struct { - ID string `json:"id"` + ID string `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` Title string `json:"title"` Description string `json:"description"` Status TaskStatus `json:"status"` - CreatedAt string `json:"created_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..d32f7e8 --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,12 @@ +package model + +import "time" + +type User struct { + ID string `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Name string `json:"name"` + Username string `json:"username" gorm:"unique"` + PasswordHash string `json:"password"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/repository/task.go b/internal/repository/task.go index d3034a3..493bd55 100644 --- a/internal/repository/task.go +++ b/internal/repository/task.go @@ -2,9 +2,7 @@ package repository import ( "devtasker/internal/model" - "time" - "github.com/google/uuid" "gorm.io/gorm" ) @@ -27,15 +25,12 @@ func New(db *gorm.DB) *TaskRepository { } func (tr *TaskRepository) CreateTask(title, description string) (model.Task, error) { - id := uuid.NewString() t := model.Task{ - ID: id, Title: title, Description: description, Status: model.Pending, - CreatedAt: time.Now().String(), } - tr.db.Save(t) + tr.db.Create(&t) return t, nil } diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..476dc59 --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,41 @@ +package repository + +import ( + "devtasker/internal/model" + + "gorm.io/gorm" +) + +type IUserRepository interface { + CreateUser(name, username, hashedPass string) (model.User, error) + GetUserByUsername(username string) (model.User, error) +} + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepo(db *gorm.DB) *UserRepository { + return &UserRepository{ + db: db, + } +} + +func (ur *UserRepository) CreateUser(name, username, hashedPass string) (model.User, error) { + u := model.User{ + Name: name, + Username: username, + PasswordHash: hashedPass, + } + ur.db.Create(&u) + return u, nil +} + +func (ur *UserRepository) GetUserByUsername(username string) (model.User, error) { + var user model.User + result := ur.db.First(&user, "username = ?", username) + if result.Error != nil { + return model.User{}, result.Error + } + return user, nil +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..858c70f --- /dev/null +++ b/internal/service/user.go @@ -0,0 +1,52 @@ +package service + +import ( + "devtasker/internal/model" + "devtasker/internal/repository" + "devtasker/internal/utils" + "fmt" +) + +type IUserService interface { + Register(name, username, pass string) (model.User, error) + Login(username, pass string) (string, error) +} + +type UserService struct { + r repository.IUserRepository +} + +func NewUserService(r repository.IUserRepository) *UserService { + return &UserService{ + r: r, + } +} + +func (us *UserService) Register(name, username, pass string) (model.User, error) { + hashPass, err := utils.HashPassword(pass) + if err != nil { + return model.User{}, err + } + fmt.Println(name, username, hashPass) + u, err := us.r.CreateUser(name, username, hashPass) + if err != nil { + return model.User{}, err + } + return u, nil +} + +func (us *UserService) Login(username, pass string) (string, error) { + u, err := us.r.GetUserByUsername(username) + if err != nil { + return "", err + } + res := utils.ComparePassword(u.PasswordHash, pass) + if !res { + return "", fmt.Errorf("username or password is wrong") + } + token, err := utils.GenerateToken(u) + if err != nil { + return "", err + } + return token, nil +} diff --git a/internal/utils/data.json b/internal/utils/data.json index 037d459..d1c354e 100644 --- a/internal/utils/data.json +++ b/internal/utils/data.json @@ -1,23 +1,17 @@ [ { - "id": "a7e9c9fa-0f91-4d3f-a72a-8b159a994f4d", "title": "Write project proposal", "description": "Draft the initial proposal for the DevTasker project.", - "status": "pending", - "created_at": "2025-07-05T08:00:00Z" + "status": "pending" }, { - "id": "0f13e431-3b3a-41d3-b8ce-1ec58e6aa54a", "title": "Implement task queue", "description": "Develop the job queue and worker pool system in Go.", - "status": "in-progress", - "created_at": "2025-07-04T14:30:00Z" + "status": "in-progress" }, { - "id": "6b65fcf6-77c1-4baf-9186-0b1f6c1cbe64", "title": "Create README", "description": "Write a comprehensive README for the GitHub repository.", - "status": "completed", - "created_at": "2025-07-03T10:15:00Z" + "status": "completed" } ] diff --git a/internal/utils/db.go b/internal/utils/db.go index 04e2847..f1546d8 100644 --- a/internal/utils/db.go +++ b/internal/utils/db.go @@ -46,5 +46,6 @@ func ConnectDb() *gorm.DB { func MigrateDb(db *gorm.DB) { fmt.Println("Running DB migrations...") db.AutoMigrate(&model.Task{}) + db.AutoMigrate(&model.User{}) fmt.Println("DB Migrations is succeed!") } diff --git a/internal/utils/jwt.go b/internal/utils/jwt.go new file mode 100644 index 0000000..60947f9 --- /dev/null +++ b/internal/utils/jwt.go @@ -0,0 +1,38 @@ +package utils + +import ( + "devtasker/internal/model" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func GenerateToken(user model.User) (string, error) { + key := []byte(os.Getenv("SECRET_KEY")) + t := jwt.NewWithClaims(jwt.SigningMethodHS256, + jwt.MapClaims{ + "name": user.Name, + "username": user.Username, + "exp": time.Now().Add(time.Duration(0.5 * float64(time.Hour))).Unix(), + }) + s, err := t.SignedString(key) + if err != nil { + return "", err + } + return s, nil +} + +func ExtractClaims(tokenStr string) (jwt.MapClaims, bool) { + key := []byte(os.Getenv("SECRET_KEY")) + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + return key, nil + }) + if err != nil { + return nil, false + } + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return claims, true + } + return nil, false +} diff --git a/internal/utils/password.go b/internal/utils/password.go new file mode 100644 index 0000000..774b172 --- /dev/null +++ b/internal/utils/password.go @@ -0,0 +1,13 @@ +package utils + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(pass string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(pass), 12) + return string(bytes), err +} + +func ComparePassword(encrypted string, pass string) bool { + err := bcrypt.CompareHashAndPassword([]byte(encrypted), []byte(pass)) + return err == nil +}