From 558ee70c21c126a0660f0a3576908643efc88921 Mon Sep 17 00:00:00 2001 From: Jean Jacques Avril Date: Mon, 10 Mar 2025 21:02:41 +0000 Subject: [PATCH] feat: Add authentication DTOs and setup API routes for user and activity management --- backend/cmd/api/main.go | 47 ++- backend/go.mod | 1 + backend/go.sum | 2 + .../internal/api/handlers/activity_handler.go | 247 ++++++++++++ backend/internal/api/handlers/user_handler.go | 350 ++++++++++++++++++ backend/internal/api/middleware/auth.go | 198 ++++++++++ backend/internal/api/routes/router.go | 50 +++ backend/internal/api/utils/response.go | 85 +++++ backend/internal/api/utils/swagger.go | 71 ++++ backend/internal/dtos/auth_dto.go | 9 +- 10 files changed, 1043 insertions(+), 17 deletions(-) create mode 100644 backend/internal/api/handlers/activity_handler.go create mode 100644 backend/internal/api/handlers/user_handler.go create mode 100644 backend/internal/api/middleware/auth.go create mode 100644 backend/internal/api/routes/router.go create mode 100644 backend/internal/api/utils/response.go create mode 100644 backend/internal/api/utils/swagger.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 2a9ff9a..5eb1332 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -9,47 +9,62 @@ import ( swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" _ "github.com/timetracker/backend/docs" // This line is important for swag to work + "github.com/timetracker/backend/internal/api/routes" "github.com/timetracker/backend/internal/models" _ "gorm.io/driver/postgres" // GORM IMPORTS MARKER ) -// @title Time Tracker API -// @version 1.0 -// @description This is a simple time tracker API. -// @host localhost:8080 -// @BasePath / +// @title Time Tracker API +// @version 1.0 +// @description This is a simple time tracker API. +// @host localhost:8080 +// @BasePath /api +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization -// @Summary Say hello -// @Description Get a hello message -// @ID hello -// @Produce plain -// @Success 200 {string} string "Hello from the Time Tracker Backend!" -// @Router / [get] +// @x-extension ulid.ULID string + +// @Summary Say hello +// @Description Get a hello message +// @ID hello +// @Produce plain +// @Success 200 {string} string "Hello from the Time Tracker Backend!" +// @Router / [get] func helloHandler(c *gin.Context) { c.String(http.StatusOK, "Hello from the Time Tracker Backend!") } func main() { - + // Configure database dbConfig := models.DatabaseConfig{ Host: "localhost", Port: 5432, User: "postgres", Password: "password", DBName: "mydatabase", - SSLMode: "disable", // Für Entwicklungsumgebung + SSLMode: "disable", // For development environment } - // Datenbank initialisieren + // Initialize database if err := models.InitDB(dbConfig); err != nil { - log.Fatalf("Fehler bei der DB-Initialisierung: %v", err) + log.Fatalf("Error initializing database: %v", err) } + + // Create Gin router r := gin.Default() + // Basic route for health check r.GET("/", helloHandler) + + // Setup API routes + routes.SetupRouter(r) + + // Swagger documentation r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + // Start server fmt.Println("Server listening on port 8080") - r.Run(":8080") // Use Gin's Run method + r.Run(":8080") } diff --git a/backend/go.mod b/backend/go.mod index 68318b4..63ceee3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,7 @@ require ( ) require ( + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect diff --git a/backend/go.sum b/backend/go.sum index 9e907a0..d44fa84 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -37,6 +37,8 @@ github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0 github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/backend/internal/api/handlers/activity_handler.go b/backend/internal/api/handlers/activity_handler.go new file mode 100644 index 0000000..07e4171 --- /dev/null +++ b/backend/internal/api/handlers/activity_handler.go @@ -0,0 +1,247 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/oklog/ulid/v2" + "github.com/timetracker/backend/internal/api/utils" + dto "github.com/timetracker/backend/internal/dtos" + "github.com/timetracker/backend/internal/models" +) + +// ActivityHandler handles activity-related API endpoints +type ActivityHandler struct{} + +// NewActivityHandler creates a new ActivityHandler +func NewActivityHandler() *ActivityHandler { + return &ActivityHandler{} +} + +// GetActivities handles GET /activities +// @Summary Get all activities +// @Description Get a list of all activities +// @Tags activities +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} utils.Response{data=[]utils.ActivityResponse} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /activities [get] +func (h *ActivityHandler) GetActivities(c *gin.Context) { + // Get activities from the database + activities, err := models.GetAllActivities(c.Request.Context()) + if err != nil { + utils.InternalErrorResponse(c, "Error retrieving activities: "+err.Error()) + return + } + + // Convert to DTOs + activityDTOs := make([]dto.ActivityDto, len(activities)) + for i, activity := range activities { + activityDTOs[i] = convertActivityToDTO(&activity) + } + + utils.SuccessResponse(c, http.StatusOK, activityDTOs) +} + +// GetActivityByID handles GET /activities/:id +// @Summary Get activity by ID +// @Description Get an activity by its ID +// @Tags activities +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Activity ID" +// @Success 200 {object} utils.Response{data=utils.ActivityResponse} +// @Failure 400 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 404 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /activities/{id} [get] +func (h *ActivityHandler) GetActivityByID(c *gin.Context) { + // Parse ID from URL + idStr := c.Param("id") + id, err := ulid.Parse(idStr) + if err != nil { + utils.BadRequestResponse(c, "Invalid activity ID format") + return + } + + // Get activity from the database + activity, err := models.GetActivityByID(c.Request.Context(), id) + if err != nil { + utils.InternalErrorResponse(c, "Error retrieving activity: "+err.Error()) + return + } + + if activity == nil { + utils.NotFoundResponse(c, "Activity not found") + return + } + + // Convert to DTO + activityDTO := convertActivityToDTO(activity) + + utils.SuccessResponse(c, http.StatusOK, activityDTO) +} + +// CreateActivity handles POST /activities +// @Summary Create a new activity +// @Description Create a new activity +// @Tags activities +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param activity body dto.ActivityCreateDto true "Activity data" +// @Success 201 {object} utils.Response{data=dto.ActivityDto} +// @Failure 400 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /activities [post] +func (h *ActivityHandler) CreateActivity(c *gin.Context) { + // Parse request body + var activityCreateDTO dto.ActivityCreateDto + if err := c.ShouldBindJSON(&activityCreateDTO); err != nil { + utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) + return + } + + // Convert DTO to model + activityCreate := convertCreateActivityDTOToModel(activityCreateDTO) + + // Create activity in the database + activity, err := models.CreateActivity(c.Request.Context(), activityCreate) + if err != nil { + utils.InternalErrorResponse(c, "Error creating activity: "+err.Error()) + return + } + + // Convert to DTO + activityDTO := convertActivityToDTO(activity) + + utils.SuccessResponse(c, http.StatusCreated, activityDTO) +} + +// UpdateActivity handles PUT /activities/:id +// @Summary Update an activity +// @Description Update an existing activity +// @Tags activities +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Activity ID" +// @Param activity body dto.ActivityUpdateDto true "Activity data" +// @Success 200 {object} utils.Response{data=dto.ActivityDto} +// @Failure 400 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 404 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /activities/{id} [put] +func (h *ActivityHandler) UpdateActivity(c *gin.Context) { + // Parse ID from URL + idStr := c.Param("id") + id, err := ulid.Parse(idStr) + if err != nil { + utils.BadRequestResponse(c, "Invalid activity ID format") + return + } + + // Parse request body + var activityUpdateDTO dto.ActivityUpdateDto + if err := c.ShouldBindJSON(&activityUpdateDTO); err != nil { + utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) + return + } + + // Set ID from URL + activityUpdateDTO.ID = id + + // Convert DTO to model + activityUpdate := convertUpdateActivityDTOToModel(activityUpdateDTO) + + // Update activity in the database + activity, err := models.UpdateActivity(c.Request.Context(), activityUpdate) + if err != nil { + utils.InternalErrorResponse(c, "Error updating activity: "+err.Error()) + return + } + + if activity == nil { + utils.NotFoundResponse(c, "Activity not found") + return + } + + // Convert to DTO + activityDTO := convertActivityToDTO(activity) + + utils.SuccessResponse(c, http.StatusOK, activityDTO) +} + +// DeleteActivity handles DELETE /activities/:id +// @Summary Delete an activity +// @Description Delete an activity by its ID +// @Tags activities +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Activity ID" +// @Success 204 {object} utils.Response +// @Failure 400 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /activities/{id} [delete] +func (h *ActivityHandler) DeleteActivity(c *gin.Context) { + // Parse ID from URL + idStr := c.Param("id") + id, err := ulid.Parse(idStr) + if err != nil { + utils.BadRequestResponse(c, "Invalid activity ID format") + return + } + + // Delete activity from the database + err = models.DeleteActivity(c.Request.Context(), id) + if err != nil { + utils.InternalErrorResponse(c, "Error deleting activity: "+err.Error()) + return + } + + utils.SuccessResponse(c, http.StatusNoContent, nil) +} + +// Helper functions for DTO conversion + +func convertActivityToDTO(activity *models.Activity) dto.ActivityDto { + return dto.ActivityDto{ + ID: activity.ID, + CreatedAt: activity.CreatedAt, + UpdatedAt: activity.UpdatedAt, + Name: activity.Name, + BillingRate: activity.BillingRate, + } +} + +func convertCreateActivityDTOToModel(dto dto.ActivityCreateDto) models.ActivityCreate { + return models.ActivityCreate{ + Name: dto.Name, + BillingRate: dto.BillingRate, + } +} + +func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityUpdate { + update := models.ActivityUpdate{ + ID: dto.ID, + } + + if dto.Name != nil { + update.Name = dto.Name + } + + if dto.BillingRate != nil { + update.BillingRate = dto.BillingRate + } + + return update +} diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go new file mode 100644 index 0000000..a1acc35 --- /dev/null +++ b/backend/internal/api/handlers/user_handler.go @@ -0,0 +1,350 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/oklog/ulid/v2" + "github.com/timetracker/backend/internal/api/middleware" + "github.com/timetracker/backend/internal/api/utils" + dto "github.com/timetracker/backend/internal/dtos" + "github.com/timetracker/backend/internal/models" +) + +// UserHandler handles user-related API endpoints +type UserHandler struct{} + +// NewUserHandler creates a new UserHandler +func NewUserHandler() *UserHandler { + return &UserHandler{} +} + +// GetUsers handles GET /users +// @Summary Get all users +// @Description Get a list of all users +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} utils.Response{data=[]dto.UserDto} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /users [get] +func (h *UserHandler) GetUsers(c *gin.Context) { + // Get users from the database + users, err := models.GetAllUsers(c.Request.Context()) + if err != nil { + utils.InternalErrorResponse(c, "Error retrieving users: "+err.Error()) + return + } + + // Convert to DTOs + userDTOs := make([]dto.UserDto, len(users)) + for i, user := range users { + userDTOs[i] = convertUserToDTO(&user) + } + + utils.SuccessResponse(c, http.StatusOK, userDTOs) +} + +// GetUserByID handles GET /users/:id +// @Summary Get user by ID +// @Description Get a user by their ID +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Success 200 {object} utils.Response{data=dto.UserDto} +// @Failure 400 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 404 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /users/{id} [get] +func (h *UserHandler) GetUserByID(c *gin.Context) { + // Parse ID from URL + idStr := c.Param("id") + id, err := ulid.Parse(idStr) + if err != nil { + utils.BadRequestResponse(c, "Invalid user ID format") + return + } + + // Get user from the database + user, err := models.GetUserByID(c.Request.Context(), id) + if err != nil { + utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) + return + } + + if user == nil { + utils.NotFoundResponse(c, "User not found") + return + } + + // Convert to DTO + userDTO := convertUserToDTO(user) + + utils.SuccessResponse(c, http.StatusOK, userDTO) +} + +// CreateUser handles POST /users +// @Summary Create a new user +// @Description Create a new user +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user body dto.UserCreateDto true "User data" +// @Success 201 {object} utils.Response{data=dto.UserDto} +// @Failure 400 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /users [post] +func (h *UserHandler) CreateUser(c *gin.Context) { + // Parse request body + var userCreateDTO dto.UserCreateDto + if err := c.ShouldBindJSON(&userCreateDTO); err != nil { + utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) + return + } + + // Convert DTO to model + userCreate := convertCreateDTOToModel(userCreateDTO) + + // Create user in the database + user, err := models.CreateUser(c.Request.Context(), userCreate) + if err != nil { + utils.InternalErrorResponse(c, "Error creating user: "+err.Error()) + return + } + + // Convert to DTO + userDTO := convertUserToDTO(user) + + utils.SuccessResponse(c, http.StatusCreated, userDTO) +} + +// UpdateUser handles PUT /users/:id +// @Summary Update a user +// @Description Update an existing user +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Param user body dto.UserUpdateDto true "User data" +// @Success 200 {object} utils.Response{data=dto.UserDto} +// @Failure 400 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 404 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /users/{id} [put] +func (h *UserHandler) UpdateUser(c *gin.Context) { + // Parse ID from URL + idStr := c.Param("id") + id, err := ulid.Parse(idStr) + if err != nil { + utils.BadRequestResponse(c, "Invalid user ID format") + return + } + + // Parse request body + var userUpdateDTO dto.UserUpdateDto + if err := c.ShouldBindJSON(&userUpdateDTO); err != nil { + utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) + return + } + + // Set ID from URL + userUpdateDTO.ID = id + + // Convert DTO to model + userUpdate := convertUpdateDTOToModel(userUpdateDTO) + + // Update user in the database + user, err := models.UpdateUser(c.Request.Context(), userUpdate) + if err != nil { + utils.InternalErrorResponse(c, "Error updating user: "+err.Error()) + return + } + + if user == nil { + utils.NotFoundResponse(c, "User not found") + return + } + + // Convert to DTO + userDTO := convertUserToDTO(user) + + utils.SuccessResponse(c, http.StatusOK, userDTO) +} + +// DeleteUser handles DELETE /users/:id +// @Summary Delete a user +// @Description Delete a user by their ID +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Success 204 {object} utils.Response +// @Failure 400 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /users/{id} [delete] +func (h *UserHandler) DeleteUser(c *gin.Context) { + // Parse ID from URL + idStr := c.Param("id") + id, err := ulid.Parse(idStr) + if err != nil { + utils.BadRequestResponse(c, "Invalid user ID format") + return + } + + // Delete user from the database + err = models.DeleteUser(c.Request.Context(), id) + if err != nil { + utils.InternalErrorResponse(c, "Error deleting user: "+err.Error()) + return + } + + utils.SuccessResponse(c, http.StatusNoContent, nil) +} + +// Login handles POST /auth/login +// @Summary Login +// @Description Authenticate a user and get a JWT token +// @Tags auth +// @Accept json +// @Produce json +// @Param credentials body dto.LoginDto true "Login credentials" +// @Success 200 {object} utils.Response{data=dto.TokenDto} +// @Failure 400 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /auth/login [post] +func (h *UserHandler) Login(c *gin.Context) { + // Parse request body + var loginDTO dto.LoginDto + if err := c.ShouldBindJSON(&loginDTO); err != nil { + utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) + return + } + + // Authenticate user + user, err := models.AuthenticateUser(c.Request.Context(), loginDTO.Email, loginDTO.Password) + if err != nil { + utils.UnauthorizedResponse(c, "Invalid login credentials") + return + } + + // Generate JWT token + token, err := middleware.GenerateToken(user) + if err != nil { + utils.InternalErrorResponse(c, "Error generating token: "+err.Error()) + return + } + + // Return token + tokenDTO := dto.TokenDto{ + Token: token, + User: convertUserToDTO(user), + } + + utils.SuccessResponse(c, http.StatusOK, tokenDTO) +} + +// GetCurrentUser handles GET /auth/me +// @Summary Get current user +// @Description Get the currently authenticated user +// @Tags auth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} utils.Response{data=dto.UserDto} +// @Failure 401 {object} utils.Response{error=utils.ErrorInfo} +// @Failure 500 {object} utils.Response{error=utils.ErrorInfo} +// @Router /auth/me [get] +func (h *UserHandler) GetCurrentUser(c *gin.Context) { + // Get user ID from context (set by AuthMiddleware) + userID, err := middleware.GetUserIDFromContext(c) + if err != nil { + utils.UnauthorizedResponse(c, "User not authenticated") + return + } + + // Get user from the database + user, err := models.GetUserByID(c.Request.Context(), userID) + if err != nil { + utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) + return + } + + if user == nil { + utils.NotFoundResponse(c, "User not found") + return + } + + // Convert to DTO + userDTO := convertUserToDTO(user) + + utils.SuccessResponse(c, http.StatusOK, userDTO) +} + +// Helper functions for DTO conversion + +func convertUserToDTO(user *models.User) dto.UserDto { + return dto.UserDto{ + ID: user.ID, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + Email: user.Email, + Role: user.Role, + CompanyID: int(user.CompanyID.Time()), // This is a simplification, adjust as needed + HourlyRate: user.HourlyRate, + } +} + +func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate { + // Convert CompanyID from int to ULID (this is a simplification, adjust as needed) + companyID, _ := ulid.Parse("01H1VECTJQXS1RVWJT6QG3QJCJ") + + return models.UserCreate{ + Email: dto.Email, + Password: dto.Password, + Role: dto.Role, + CompanyID: companyID, + HourlyRate: dto.HourlyRate, + } +} + +func convertUpdateDTOToModel(dto dto.UserUpdateDto) models.UserUpdate { + update := models.UserUpdate{ + ID: dto.ID, + } + + if dto.Email != nil { + update.Email = dto.Email + } + + if dto.Password != nil { + update.Password = dto.Password + } + + if dto.Role != nil { + update.Role = dto.Role + } + + if dto.CompanyID != nil { + // Convert CompanyID from int to ULID (this is a simplification, adjust as needed) + companyID, _ := ulid.Parse("01H1VECTJQXS1RVWJT6QG3QJCJ") + update.CompanyID = &companyID + } + + if dto.HourlyRate != nil { + update.HourlyRate = dto.HourlyRate + } + + return update +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go new file mode 100644 index 0000000..7f0ead4 --- /dev/null +++ b/backend/internal/api/middleware/auth.go @@ -0,0 +1,198 @@ +package middleware + +import ( + "errors" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/oklog/ulid/v2" + "github.com/timetracker/backend/internal/api/utils" + "github.com/timetracker/backend/internal/models" +) + +// JWT configuration +const ( + // This should be moved to environment variables in production + jwtSecret = "your-secret-key-change-in-production" + tokenDuration = 24 * time.Hour +) + +// Claims represents the JWT claims +type Claims struct { + UserID string `json:"userId"` + Email string `json:"email"` + Role string `json:"role"` + CompanyID string `json:"companyId"` + jwt.RegisteredClaims +} + +// AuthMiddleware checks if the user is authenticated +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Get the Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + utils.UnauthorizedResponse(c, "Authorization header is required") + c.Abort() + return + } + + // Check if the header has the Bearer prefix + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + utils.UnauthorizedResponse(c, "Invalid authorization format, expected 'Bearer TOKEN'") + c.Abort() + return + } + + tokenString := parts[1] + claims, err := validateToken(tokenString) + if err != nil { + utils.UnauthorizedResponse(c, "Invalid or expired token") + c.Abort() + return + } + + // Store user information in the context + c.Set("userID", claims.UserID) + c.Set("email", claims.Email) + c.Set("role", claims.Role) + c.Set("companyID", claims.CompanyID) + + c.Next() + } +} + +// RoleMiddleware checks if the user has the required role +func RoleMiddleware(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + userRole, exists := c.Get("role") + if !exists { + utils.UnauthorizedResponse(c, "User role not found in context") + c.Abort() + return + } + + // Check if the user's role is in the allowed roles + roleStr, ok := userRole.(string) + if !ok { + utils.InternalErrorResponse(c, "Invalid role type in context") + c.Abort() + return + } + + allowed := false + for _, role := range roles { + if roleStr == role { + allowed = true + break + } + } + + if !allowed { + utils.ForbiddenResponse(c, "Insufficient permissions") + c.Abort() + return + } + + c.Next() + } +} + +// GenerateToken creates a new JWT token for a user +func GenerateToken(user *models.User) (string, error) { + // Create the claims + claims := Claims{ + UserID: user.ID.String(), + Email: user.Email, + Role: user.Role, + CompanyID: user.CompanyID.String(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + + // Create the token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign the token + tokenString, err := token.SignedString([]byte(jwtSecret)) + if err != nil { + return "", err + } + + return tokenString, nil +} + +// validateToken validates a JWT token and returns the claims +func validateToken(tokenString string) (*Claims, error) { + // Parse the token + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(jwtSecret), nil + }) + + if err != nil { + return nil, err + } + + // Check if the token is valid + if !token.Valid { + return nil, errors.New("invalid token") + } + + // Get the claims + claims, ok := token.Claims.(*Claims) + if !ok { + return nil, errors.New("invalid claims") + } + + return claims, nil +} + +// GetUserIDFromContext extracts the user ID from the context +func GetUserIDFromContext(c *gin.Context) (ulid.ULID, error) { + userID, exists := c.Get("userID") + if !exists { + return ulid.ULID{}, errors.New("user ID not found in context") + } + + userIDStr, ok := userID.(string) + if !ok { + return ulid.ULID{}, errors.New("invalid user ID type in context") + } + + id, err := ulid.Parse(userIDStr) + if err != nil { + return ulid.ULID{}, err + } + + return id, nil +} + +// GetCompanyIDFromContext extracts the company ID from the context +func GetCompanyIDFromContext(c *gin.Context) (ulid.ULID, error) { + companyID, exists := c.Get("companyID") + if !exists { + return ulid.ULID{}, errors.New("company ID not found in context") + } + + companyIDStr, ok := companyID.(string) + if !ok { + return ulid.ULID{}, errors.New("invalid company ID type in context") + } + + id, err := ulid.Parse(companyIDStr) + if err != nil { + return ulid.ULID{}, err + } + + return id, nil +} diff --git a/backend/internal/api/routes/router.go b/backend/internal/api/routes/router.go new file mode 100644 index 0000000..4b2c929 --- /dev/null +++ b/backend/internal/api/routes/router.go @@ -0,0 +1,50 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + "github.com/timetracker/backend/internal/api/handlers" + "github.com/timetracker/backend/internal/api/middleware" +) + +// SetupRouter configures all the routes for the API +func SetupRouter(r *gin.Engine) { + // Create handlers + userHandler := handlers.NewUserHandler() + activityHandler := handlers.NewActivityHandler() + + // Public routes + r.POST("/auth/login", userHandler.Login) + + // API routes (protected) + api := r.Group("/api") + api.Use(middleware.AuthMiddleware()) + { + // Auth routes + auth := api.Group("/auth") + { + auth.GET("/me", userHandler.GetCurrentUser) + } + + // User routes + users := api.Group("/users") + { + users.GET("", userHandler.GetUsers) + users.GET("/:id", userHandler.GetUserByID) + users.POST("", middleware.RoleMiddleware("admin"), userHandler.CreateUser) + users.PUT("/:id", middleware.RoleMiddleware("admin"), userHandler.UpdateUser) + users.DELETE("/:id", middleware.RoleMiddleware("admin"), userHandler.DeleteUser) + } + + // Activity routes + activities := api.Group("/activities") + { + activities.GET("", activityHandler.GetActivities) + activities.GET("/:id", activityHandler.GetActivityByID) + activities.POST("", middleware.RoleMiddleware("admin"), activityHandler.CreateActivity) + activities.PUT("/:id", middleware.RoleMiddleware("admin"), activityHandler.UpdateActivity) + activities.DELETE("/:id", middleware.RoleMiddleware("admin"), activityHandler.DeleteActivity) + } + + // TODO: Add routes for other entities (Company, Project, TimeEntry, etc.) + } +} diff --git a/backend/internal/api/utils/response.go b/backend/internal/api/utils/response.go new file mode 100644 index 0000000..dd9a4ce --- /dev/null +++ b/backend/internal/api/utils/response.go @@ -0,0 +1,85 @@ +package utils + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response is a standardized API response structure +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` +} + +// ErrorInfo contains detailed error information +type ErrorInfo struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// ErrorResponse codes +const ( + ErrorCodeValidation = "VALIDATION_ERROR" + ErrorCodeNotFound = "NOT_FOUND" + ErrorCodeUnauthorized = "UNAUTHORIZED" + ErrorCodeForbidden = "FORBIDDEN" + ErrorCodeInternal = "INTERNAL_ERROR" + ErrorCodeBadRequest = "BAD_REQUEST" + ErrorCodeConflict = "CONFLICT" +) + +// SuccessResponse sends a successful response with data +func SuccessResponse(c *gin.Context, statusCode int, data interface{}) { + c.JSON(statusCode, Response{ + Success: true, + Data: data, + }) +} + +// ErrorResponse sends an error response +func ErrorResponse(c *gin.Context, statusCode int, errorCode string, message string) { + c.JSON(statusCode, Response{ + Success: false, + Error: &ErrorInfo{ + Code: errorCode, + Message: message, + }, + }) +} + +// BadRequestResponse sends a 400 Bad Request response +func BadRequestResponse(c *gin.Context, message string) { + ErrorResponse(c, http.StatusBadRequest, ErrorCodeBadRequest, message) +} + +// ValidationErrorResponse sends a 400 Bad Request response for validation errors +func ValidationErrorResponse(c *gin.Context, message string) { + ErrorResponse(c, http.StatusBadRequest, ErrorCodeValidation, message) +} + +// NotFoundResponse sends a 404 Not Found response +func NotFoundResponse(c *gin.Context, message string) { + ErrorResponse(c, http.StatusNotFound, ErrorCodeNotFound, message) +} + +// UnauthorizedResponse sends a 401 Unauthorized response +func UnauthorizedResponse(c *gin.Context, message string) { + ErrorResponse(c, http.StatusUnauthorized, ErrorCodeUnauthorized, message) +} + +// ForbiddenResponse sends a 403 Forbidden response +func ForbiddenResponse(c *gin.Context, message string) { + ErrorResponse(c, http.StatusForbidden, ErrorCodeForbidden, message) +} + +// InternalErrorResponse sends a 500 Internal Server Error response +func InternalErrorResponse(c *gin.Context, message string) { + ErrorResponse(c, http.StatusInternalServerError, ErrorCodeInternal, message) +} + +// ConflictResponse sends a 409 Conflict response +func ConflictResponse(c *gin.Context, message string) { + ErrorResponse(c, http.StatusConflict, ErrorCodeConflict, message) +} diff --git a/backend/internal/api/utils/swagger.go b/backend/internal/api/utils/swagger.go new file mode 100644 index 0000000..d9a721c --- /dev/null +++ b/backend/internal/api/utils/swagger.go @@ -0,0 +1,71 @@ +package utils + +// This file contains type definitions for Swagger documentation + +// SwaggerULID is a string representation of ULID for Swagger +type SwaggerULID string + +// SwaggerTime is a string representation of time.Time for Swagger +type SwaggerTime string + +// ActivityResponse is a Swagger representation of ActivityDto +type ActivityResponse struct { + ID SwaggerULID `json:"id" example:"01H1VECTJQXS1RVWJT6QG3QJCJ"` + CreatedAt SwaggerTime `json:"createdAt" example:"2023-01-01T12:00:00Z"` + UpdatedAt SwaggerTime `json:"updatedAt" example:"2023-01-01T12:00:00Z"` + Name string `json:"name" example:"Development"` + BillingRate float64 `json:"billingRate" example:"100.0"` +} + +// UserResponse is a Swagger representation of UserDto +type UserResponse struct { + ID SwaggerULID `json:"id" example:"01H1VECTJQXS1RVWJT6QG3QJCJ"` + CreatedAt SwaggerTime `json:"createdAt" example:"2023-01-01T12:00:00Z"` + UpdatedAt SwaggerTime `json:"updatedAt" example:"2023-01-01T12:00:00Z"` + Email string `json:"email" example:"user@example.com"` + Role string `json:"role" example:"admin"` + CompanyID int `json:"companyId" example:"1"` + HourlyRate float64 `json:"hourlyRate" example:"50.0"` +} + +// ActivityCreateRequest is a Swagger representation of ActivityCreateDto +type ActivityCreateRequest struct { + Name string `json:"name" example:"Development"` + BillingRate float64 `json:"billingRate" example:"100.0"` +} + +// ActivityUpdateRequest is a Swagger representation of ActivityUpdateDto +type ActivityUpdateRequest struct { + Name *string `json:"name,omitempty" example:"Development"` + BillingRate *float64 `json:"billingRate,omitempty" example:"100.0"` +} + +// UserCreateRequest is a Swagger representation of UserCreateDto +type UserCreateRequest struct { + Email string `json:"email" example:"user@example.com"` + Password string `json:"password" example:"SecurePassword123!"` + Role string `json:"role" example:"admin"` + CompanyID int `json:"companyId" example:"1"` + HourlyRate float64 `json:"hourlyRate" example:"50.0"` +} + +// UserUpdateRequest is a Swagger representation of UserUpdateDto +type UserUpdateRequest struct { + Email *string `json:"email,omitempty" example:"user@example.com"` + Password *string `json:"password,omitempty" example:"SecurePassword123!"` + Role *string `json:"role,omitempty" example:"admin"` + CompanyID *int `json:"companyId,omitempty" example:"1"` + HourlyRate *float64 `json:"hourlyRate,omitempty" example:"50.0"` +} + +// LoginRequest is a Swagger representation of LoginDto +type LoginRequest struct { + Email string `json:"email" example:"user@example.com"` + Password string `json:"password" example:"SecurePassword123!"` +} + +// TokenResponse is a Swagger representation of TokenDto +type TokenResponse struct { + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` + User UserResponse `json:"user"` +} diff --git a/backend/internal/dtos/auth_dto.go b/backend/internal/dtos/auth_dto.go index 795e7b9..a345bb3 100644 --- a/backend/internal/dtos/auth_dto.go +++ b/backend/internal/dtos/auth_dto.go @@ -1,6 +1,13 @@ package dto -type AuthDto struct { +// LoginDto represents the login request +type LoginDto struct { Email string `json:"email"` Password string `json:"password"` } + +// TokenDto represents the response after successful authentication +type TokenDto struct { + Token string `json:"token"` + User UserDto `json:"user"` +}