From b2894fca70ab95b24c64e69a649e362bb1f2975c Mon Sep 17 00:00:00 2001 From: David Folch Agulles Date: Wed, 26 Nov 2025 01:05:08 +0100 Subject: [PATCH] Add JWT authentication - Create register and login use cases - Implement JWT service for token generation and validation - Add authentication middleware to protect habit endpoints - Create auth HTTP handlers (register, login) - Update habit handlers to extract userID from JWT token - Register/login endpoints: POST /auth/register, POST /auth/login - Habit endpoints now require Bearer token in Authorization header - Tested: register -> create habit -> list habits works correctly --- cmd/api/main.go | 25 +++- .../application/commands/register_user.go | 58 +++++++++ internal/application/queries/login_user.go | 50 ++++++++ internal/infrastructure/auth/jwt.go | 59 +++++++++ internal/infrastructure/http/auth_handlers.go | 118 ++++++++++++++++++ .../infrastructure/http/auth_middleware.go | 46 +++++++ .../infrastructure/http/habit_handlers.go | 13 +- internal/infrastructure/http/router.go | 10 +- 8 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 internal/application/commands/register_user.go create mode 100644 internal/application/queries/login_user.go create mode 100644 internal/infrastructure/auth/jwt.go create mode 100644 internal/infrastructure/http/auth_handlers.go create mode 100644 internal/infrastructure/http/auth_middleware.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 5df3e19..c6f9900 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -4,9 +4,12 @@ import ( "fmt" "log" "net/http" + "strconv" + "strings" "habit-tracker-api/internal/application/commands" "habit-tracker-api/internal/application/queries" + "habit-tracker-api/internal/infrastructure/auth" "habit-tracker-api/internal/infrastructure/config" httpInfra "habit-tracker-api/internal/infrastructure/http" "habit-tracker-api/internal/infrastructure/persistence/sqlite" @@ -24,16 +27,27 @@ func main() { } defer db.Close() + jwtExpiryHours, err := parseJWTExpiry(cfg.JWTExpiry) + if err != nil { + log.Fatalf("Invalid JWT_EXPIRY: %v", err) + } + + jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours) + + userRepo := sqlite.NewUserRepository(db.Conn()) habitRepo := sqlite.NewHabitRepository(db.Conn()) entryRepo := sqlite.NewHabitEntryRepository(db.Conn()) + registerHandler := commands.NewRegisterUserHandler(userRepo) + loginHandler := queries.NewLoginUserHandler(userRepo) createHandler := commands.NewCreateHabitHandler(habitRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo) + authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, jwtService) habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, markHandler) - router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers) + router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers, authHandlers, jwtService) addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port) log.Printf("Server starting on %s", addr) @@ -42,3 +56,12 @@ func main() { log.Fatalf("Server failed: %v", err) } } + +func parseJWTExpiry(expiry string) (int, error) { + expiry = strings.TrimSpace(expiry) + if strings.HasSuffix(expiry, "h") { + hours := strings.TrimSuffix(expiry, "h") + return strconv.Atoi(hours) + } + return 0, fmt.Errorf("invalid format, expected format like '24h'") +} diff --git a/internal/application/commands/register_user.go b/internal/application/commands/register_user.go new file mode 100644 index 0000000..6b1e739 --- /dev/null +++ b/internal/application/commands/register_user.go @@ -0,0 +1,58 @@ +package commands + +import ( + "context" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/domain/repositories" + "habit-tracker-api/internal/shared/errors" + + "golang.org/x/crypto/bcrypt" +) + +type RegisterUserCommand struct { + Email string + Password string + Timezone string +} + +type RegisterUserHandler struct { + userRepo repositories.UserRepository +} + +func NewRegisterUserHandler(userRepo repositories.UserRepository) *RegisterUserHandler { + return &RegisterUserHandler{userRepo: userRepo} +} + +func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) { + if cmd.Email == "" || cmd.Password == "" { + return "", errors.ErrInvalidInput + } + + if len(cmd.Password) < 8 { + return "", errors.ErrInvalidInput + } + + existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email) + if existing != nil { + return "", errors.ErrAlreadyExists + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(cmd.Password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + timezone := cmd.Timezone + if timezone == "" { + timezone = "UTC" + } + + user := entities.NewUser(cmd.Email, string(hashedPassword), timezone) + + if err := h.userRepo.Create(ctx, user); err != nil { + return "", err + } + + return user.ID, nil +} diff --git a/internal/application/queries/login_user.go b/internal/application/queries/login_user.go new file mode 100644 index 0000000..6fe193c --- /dev/null +++ b/internal/application/queries/login_user.go @@ -0,0 +1,50 @@ +package queries + +import ( + "context" + + "habit-tracker-api/internal/domain/repositories" + "habit-tracker-api/internal/shared/errors" + + "golang.org/x/crypto/bcrypt" +) + +type LoginUserQuery struct { + Email string + Password string +} + +type LoginUserResult struct { + UserID string + Email string + Timezone string +} + +type LoginUserHandler struct { + userRepo repositories.UserRepository +} + +func NewLoginUserHandler(userRepo repositories.UserRepository) *LoginUserHandler { + return &LoginUserHandler{userRepo: userRepo} +} + +func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*LoginUserResult, error) { + if query.Email == "" || query.Password == "" { + return nil, errors.ErrInvalidInput + } + + user, err := h.userRepo.FindByEmail(ctx, query.Email) + if err != nil { + return nil, errors.ErrNotFound + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(query.Password)); err != nil { + return nil, errors.ErrNotFound + } + + return &LoginUserResult{ + UserID: user.ID, + Email: user.Email, + Timezone: user.Timezone, + }, nil +} diff --git a/internal/infrastructure/auth/jwt.go b/internal/infrastructure/auth/jwt.go new file mode 100644 index 0000000..6089747 --- /dev/null +++ b/internal/infrastructure/auth/jwt.go @@ -0,0 +1,59 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +type JWTService struct { + secret []byte + expiry time.Duration +} + +func NewJWTService(secret string, expiryHours int) *JWTService { + return &JWTService{ + secret: []byte(secret), + expiry: time.Duration(expiryHours) * time.Hour, + } +} + +func (s *JWTService) GenerateToken(userID, email string) (string, error) { + claims := Claims{ + UserID: userID, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.expiry)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(s.secret) +} + +func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return s.secret, nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} diff --git a/internal/infrastructure/http/auth_handlers.go b/internal/infrastructure/http/auth_handlers.go new file mode 100644 index 0000000..c20c8da --- /dev/null +++ b/internal/infrastructure/http/auth_handlers.go @@ -0,0 +1,118 @@ +package http + +import ( + "encoding/json" + "net/http" + + "habit-tracker-api/internal/application/commands" + "habit-tracker-api/internal/application/queries" + "habit-tracker-api/internal/infrastructure/auth" + "habit-tracker-api/internal/shared/errors" +) + +type AuthHandlers struct { + registerHandler *commands.RegisterUserHandler + loginHandler *queries.LoginUserHandler + jwtService *auth.JWTService +} + +func NewAuthHandlers( + registerHandler *commands.RegisterUserHandler, + loginHandler *queries.LoginUserHandler, + jwtService *auth.JWTService, +) *AuthHandlers { + return &AuthHandlers{ + registerHandler: registerHandler, + loginHandler: loginHandler, + jwtService: jwtService, + } +} + +type RegisterRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Timezone string `json:"timezone"` +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type AuthResponse struct { + Token string `json:"token"` + UserID string `json:"user_id"` +} + +func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + + cmd := commands.RegisterUserCommand{ + Email: req.Email, + Password: req.Password, + Timezone: req.Timezone, + } + + userID, err := h.registerHandler.Handle(r.Context(), cmd) + if err != nil { + if err == errors.ErrInvalidInput { + respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)") + return + } + if err == errors.ErrAlreadyExists { + respondError(w, http.StatusConflict, "Email already registered") + return + } + respondError(w, http.StatusInternalServerError, "Failed to register user") + return + } + + token, err := h.jwtService.GenerateToken(userID, req.Email) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to generate token") + return + } + + respondJSON(w, http.StatusCreated, AuthResponse{ + Token: token, + UserID: userID, + }) +} + +func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + + query := queries.LoginUserQuery{ + Email: req.Email, + Password: req.Password, + } + + result, err := h.loginHandler.Handle(r.Context(), query) + if err != nil { + if err == errors.ErrNotFound || err == errors.ErrInvalidInput { + respondError(w, http.StatusUnauthorized, "Invalid email or password") + return + } + respondError(w, http.StatusInternalServerError, "Failed to login") + return + } + + token, err := h.jwtService.GenerateToken(result.UserID, result.Email) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to generate token") + return + } + + respondJSON(w, http.StatusOK, AuthResponse{ + Token: token, + UserID: result.UserID, + }) +} diff --git a/internal/infrastructure/http/auth_middleware.go b/internal/infrastructure/http/auth_middleware.go new file mode 100644 index 0000000..6839197 --- /dev/null +++ b/internal/infrastructure/http/auth_middleware.go @@ -0,0 +1,46 @@ +package http + +import ( + "context" + "net/http" + "strings" + + "habit-tracker-api/internal/infrastructure/auth" +) + +type contextKey string + +const UserIDKey contextKey = "userID" + +func AuthMiddleware(jwtService *auth.JWTService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + respondError(w, http.StatusUnauthorized, "Missing authorization header") + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + respondError(w, http.StatusUnauthorized, "Invalid authorization header format") + return + } + + tokenString := parts[1] + claims, err := jwtService.ValidateToken(tokenString) + if err != nil { + respondError(w, http.StatusUnauthorized, "Invalid or expired token") + return + } + + ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func GetUserIDFromContext(ctx context.Context) (string, bool) { + userID, ok := ctx.Value(UserIDKey).(string) + return userID, ok +} diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index fbf5431..951e08a 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -37,7 +37,11 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { return } - userID := "user-123" + userID, ok := GetUserIDFromContext(r.Context()) + if !ok { + respondError(w, http.StatusUnauthorized, "User not authenticated") + return + } cmd := commands.CreateHabitCommand{ UserID: userID, @@ -65,7 +69,12 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { } func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) { - userID := "user-123" + userID, ok := GetUserIDFromContext(r.Context()) + if !ok { + respondError(w, http.StatusUnauthorized, "User not authenticated") + return + } + timezone := "UTC" query := queries.GetTodaysHabitsQuery{ diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index 8390d45..16c0164 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -3,12 +3,14 @@ package http import ( "net/http" + "habit-tracker-api/internal/infrastructure/auth" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" ) -func NewRouter(corsOrigins string, habitHandlers *HabitHandlers) *chi.Mux { +func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, jwtService *auth.JWTService) *chi.Mux { r := chi.NewRouter() r.Use(middleware.Logger) @@ -25,7 +27,13 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers) *chi.Mux { w.Write([]byte(`{"status":"ok"}`)) }) + r.Route("/api/v1/auth", func(r chi.Router) { + r.Post("/register", authHandlers.Register) + r.Post("/login", authHandlers.Login) + }) + r.Route("/api/v1/habits", func(r chi.Router) { + r.Use(AuthMiddleware(jwtService)) r.Post("/", habitHandlers.CreateHabit) r.Get("/today", habitHandlers.GetTodaysHabits) r.Post("/{id}/mark", habitHandlers.MarkHabit)