diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bf7867d..48baed4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,6 +10,8 @@ on: jobs: ci: runs-on: ubuntu-latest + env: + TESTCONTAINER_DOCKER_NETWORK: nestled-testcontainers steps: - name: Checkout @@ -29,6 +31,9 @@ jobs: - name: Build run: go build ./... + - name: Create Docker network for testcontainers + run: docker network create ${{ env.TESTCONTAINER_DOCKER_NETWORK }} + - name: Test run: go test ./... diff --git a/bin/nestled b/bin/nestled index d8b0c9b..478845d 100755 Binary files a/bin/nestled and b/bin/nestled differ diff --git a/cmd/main/main.go b/cmd/main/main.go index ee9b1a3..2c2506b 100644 --- a/cmd/main/main.go +++ b/cmd/main/main.go @@ -35,12 +35,14 @@ func main() { // services userService := services.NewUserService(userRepository) + authService := services.NewAuthService(userRepository, configuration.JWT) // Initialize handlers userHandler := handlers.NewUserHandler(userService, logger) healthHandler := handlers.NewHealthHandler(database) + authHandler := handlers.NewAuthHandler(authService) - routes.SetupRoutes(r, userHandler, healthHandler) + routes.SetupRoutes(r, userHandler, healthHandler, authHandler) // Start the server if err := r.Run(); err != nil { diff --git a/config.yml b/config.yml index 82e2b72..57b9f67 100644 --- a/config.yml +++ b/config.yml @@ -3,4 +3,7 @@ database: port: 5432 user: postgres password: password - name: nestled_db \ No newline at end of file + name: nestled_db +jwt: + secret: "your_secret_key" + expiration: 6 \ No newline at end of file diff --git a/go.mod b/go.mod index e04646b..a43ecdf 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-migrate/migrate v3.5.4+incompatible // indirect github.com/golang-migrate/migrate/v4 v4.19.1 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index e72f881..3ef0f66 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,8 @@ 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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA= github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..8508fb8 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,35 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Token struct { + Value string + ExpirationTime time.Time +} + +func GenerateToken(userId uuid.UUID, secretKey string, expireHours int) (*Token, error) { + expirationTime := time.Now().Add(time.Hour * time.Duration(expireHours)).Local().UTC() + + claims := jwt.MapClaims{ + "userId": userId.String(), + "exp": expirationTime, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(secretKey)) + + if err != nil { + return nil, errors.New("failed to generate token") + } + + return &Token{ + Value: signed, + ExpirationTime: expirationTime, + }, nil +} diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go new file mode 100644 index 0000000..651a6a4 --- /dev/null +++ b/internal/auth/jwt_test.go @@ -0,0 +1,21 @@ +package auth + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestGeneratingToken(t *testing.T) { + userId := uuid.New() + secretKey := "test_secret_key" + expireHours := 6 + + token, err := GenerateToken(userId, secretKey, expireHours) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + assert.NotNil(t, token.Value, "expected token value to be set, got nil") + +} diff --git a/internal/config/configuration.go b/internal/config/configuration.go index da01ff5..dbb2bee 100644 --- a/internal/config/configuration.go +++ b/internal/config/configuration.go @@ -14,8 +14,14 @@ type DatabaseConfig struct { Name string } +type JWTConfig struct { + Secret string `mapstructure:"secret"` + Expiration int `mapstructure:"expiration"` +} + type Config struct { Database DatabaseConfig + JWT JWTConfig } func LoadConfig() (*Config, error) { @@ -31,6 +37,8 @@ func LoadConfig() (*Config, error) { viper.BindEnv("database.user", "DB_USER") viper.BindEnv("database.password", "DB_PASSWORD") viper.BindEnv("database.name", "DB_NAME") + viper.BindEnv("jwt.secret", "JWT_SECRET_KEY") + viper.BindEnv("jwt.expiration", "JWT_EXPIRE_HOURS") if err := viper.ReadInConfig(); err != nil { return nil, fmt.Errorf("fatal error config file: %w", err) diff --git a/internal/config/configuration_test.go b/internal/config/configuration_test.go index ff53aba..f5cb575 100644 --- a/internal/config/configuration_test.go +++ b/internal/config/configuration_test.go @@ -22,4 +22,9 @@ func TestLoadConfigSuccessfully(t *testing.T) { assert.Equal(t, dbConfig.Password, "password") assert.Equal(t, dbConfig.Name, "nestled_db") + jwtConfig := configuration.JWT + + assert.Equal(t, "your_secret_key", jwtConfig.Secret) + assert.Equal(t, 6, jwtConfig.Expiration) + } diff --git a/internal/dto/login_request.go b/internal/dto/login_request.go new file mode 100644 index 0000000..94e40c9 --- /dev/null +++ b/internal/dto/login_request.go @@ -0,0 +1,6 @@ +package dto + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} diff --git a/internal/dto/token_response.go b/internal/dto/token_response.go new file mode 100644 index 0000000..f8ea4d9 --- /dev/null +++ b/internal/dto/token_response.go @@ -0,0 +1,6 @@ +package dto + +type TokenResponse struct { + Token string `json:"token"` + ExpirationTime int64 `json:"expiration_time"` +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 456138c..20a7eed 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -6,4 +6,5 @@ var ( ErrUsernameAlreadyExists = errors.New("username already exists") ErrEmailAlreadyExists = errors.New("email already exists") ErrPasswordTooWeak = errors.New("password is too weak") + CredentialsInvalid = errors.New("invalid credentials") ) diff --git a/internal/handlers/auth_handler.go b/internal/handlers/auth_handler.go new file mode 100644 index 0000000..410978b --- /dev/null +++ b/internal/handlers/auth_handler.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "errors" + + "github.com/albertoadami/nestled/internal/dto" + appErrorrs "github.com/albertoadami/nestled/internal/errors" + "github.com/albertoadami/nestled/internal/services" + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + authService services.AuthService +} + +func NewAuthHandler(authService services.AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + } +} + +func (h *AuthHandler) GenerateToken(c *gin.Context) { + var request dto.LoginRequest + + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + token, err := h.authService.GenerateToken(request.Username, request.Password) + + switch { + case errors.Is(err, appErrorrs.CredentialsInvalid): + c.JSON(401, &dto.ErrorResponse{ + Message: err.Error(), + Details: "Invalid username or password", + }) + default: + tokenResponse := &dto.TokenResponse{ + Token: token.Value, + ExpirationTime: token.ExpirationTime.UnixMilli(), + } + c.JSON(200, tokenResponse) + } + +} diff --git a/internal/handlers/auth_handler_test.go b/internal/handlers/auth_handler_test.go new file mode 100644 index 0000000..8c3bd75 --- /dev/null +++ b/internal/handlers/auth_handler_test.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/albertoadami/nestled/internal/auth" + appErrorrs "github.com/albertoadami/nestled/internal/errors" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +type mockAuthService struct { + generateTokenFn func(username string, password string) (*auth.Token, error) +} + +func (m *mockAuthService) GenerateToken(username string, password string) (*auth.Token, error) { + return m.generateTokenFn(username, password) +} + +func setupUserAuthRouter(mockService *mockAuthService) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAuthHandler(mockService) + router.POST("/api/v1/auth/token", handler.GenerateToken) + return router +} + +func TestGenerateTokenSueccessfully(t *testing.T) { + + mockService := &mockAuthService{ + generateTokenFn: func(username string, password string) (*auth.Token, error) { + return &auth.Token{Value: "mocked.jwt.token"}, nil + }, + } + router := setupUserAuthRouter(mockService) + + body := `{"username":"test","password":"secret123"}` + req, _ := http.NewRequest("POST", "/api/v1/auth/token", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "mocked.jwt.token") +} + +func TestGenerateTokenInvalidCredentials(t *testing.T) { + mockService := &mockAuthService{ + generateTokenFn: func(username string, password string) (*auth.Token, error) { + return nil, appErrorrs.CredentialsInvalid + }, + } + router := setupUserAuthRouter(mockService) + + body := `{"username":"test","password":"wrong"}` + req, _ := http.NewRequest("POST", "/api/v1/auth/token", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Invalid username or password") +} diff --git a/internal/model/user.go b/internal/model/user.go index a695e03..301879e 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -13,11 +13,11 @@ const ( ) type User struct { - Id uuid.UUID - Username string - FirstName string - LastName string - Email string - PasswordHash string - Status UserStatus + Id uuid.UUID `db:"id"` + Username string `db:"username"` + FirstName string `db:"first_name"` + LastName string `db:"last_name"` + Email string `db:"email"` + PasswordHash string `db:"password_hash"` + Status UserStatus `db:"status"` } diff --git a/internal/repositories/user_repository.go b/internal/repositories/user_repository.go index d5a9ca8..9cdaaf6 100644 --- a/internal/repositories/user_repository.go +++ b/internal/repositories/user_repository.go @@ -10,6 +10,7 @@ import ( type UserRepository interface { CreateUser(user *model.User) (uuid.UUID, error) + GetUserByUsername(username string) (*model.User, error) } type userRepository struct { @@ -50,3 +51,15 @@ func (r *userRepository) CreateUser(user *model.User) (uuid.UUID, error) { return id, nil } + +func (r *userRepository) GetUserByUsername(username string) (*model.User, error) { + query := `SELECT id, username, first_name, last_name, email, password_hash, status + FROM users + WHERE username = $1 AND status != 'BLOCKED'::user_status` + var user model.User + err := r.db.Get(&user, query, username) + if err != nil { + return nil, err + } + return &user, nil +} diff --git a/internal/repositories/user_repository_test.go b/internal/repositories/user_repository_test.go index 1204206..a8f1436 100644 --- a/internal/repositories/user_repository_test.go +++ b/internal/repositories/user_repository_test.go @@ -9,6 +9,7 @@ import ( "github.com/albertoadami/nestled/internal/testhelpers" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" ) func createTestUser() *model.User { @@ -104,3 +105,43 @@ func TestCreateUserFailedDueToDuplicateEmail(t *testing.T) { t.Fatalf("expected error %v, got %v", apperrors.ErrEmailAlreadyExists, err) } } + +func TestGetUserByUsernameSucessfully(t *testing.T) { + + db, terminate := testhelpers.SetupPostgres(t) + defer terminate() + truncateUsers(t, db) + + userRepo := NewUserRepository(db) + user := createTestUser() + _, err := userRepo.CreateUser(user) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + retrievedUser, err := userRepo.GetUserByUsername(user.Username) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if retrievedUser.Username != user.Username { + t.Fatalf("expected username %v, got %v", user.Username, retrievedUser.Username) + } + +} + +func TestGetUserByUsernameFailedDueToNonExistingUser(t *testing.T) { + + db, terminate := testhelpers.SetupPostgres(t) + defer terminate() + truncateUsers(t, db) + + userRepo := NewUserRepository(db) + + result, err := userRepo.GetUserByUsername("non-existing-username") + if err == nil { + t.Fatal("expected error, got nil") + } + + assert.Nil(t, result, "expected result to be nil") +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 35dcc15..d7fa28c 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -7,10 +7,10 @@ import ( const ApiPrefix = "/api/v1" -func SetupRoutes(r *gin.Engine, userHandler *handlers.UserHandler, healthHandler *handlers.HealthHandler) { +func SetupRoutes(r *gin.Engine, userHandler *handlers.UserHandler, healthHandler *handlers.HealthHandler, authHandler *handlers.AuthHandler) { r.GET("/health", healthHandler.Health) apiGroup := r.Group(ApiPrefix) apiGroup.POST("/register", userHandler.RegisterUser) - + apiGroup.POST("/auth/token", authHandler.GenerateToken) } diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go new file mode 100644 index 0000000..a6e9786 --- /dev/null +++ b/internal/services/auth_service.go @@ -0,0 +1,48 @@ +package services + +import ( + "github.com/albertoadami/nestled/internal/auth" + "github.com/albertoadami/nestled/internal/config" + "github.com/albertoadami/nestled/internal/crypto" + "github.com/albertoadami/nestled/internal/errors" + "github.com/albertoadami/nestled/internal/repositories" +) + +type AuthService interface { + GenerateToken(username string, password string) (*auth.Token, error) +} + +type authService struct { + userRepository repositories.UserRepository + JWtConfig config.JWTConfig +} + +func NewAuthService(userRepository repositories.UserRepository, jwtConfig config.JWTConfig) AuthService { + return &authService{ + userRepository: userRepository, + JWtConfig: jwtConfig, + } +} + +func (s *authService) GenerateToken(username string, password string) (*auth.Token, error) { + user, err := s.userRepository.GetUserByUsername(username) + if err != nil { + return nil, err + } + + if user == nil { + return nil, errors.CredentialsInvalid + } + + valid := crypto.CheckPassword(password, user.PasswordHash) + if valid { + jwtToken, err := auth.GenerateToken(user.Id, s.JWtConfig.Secret, s.JWtConfig.Expiration) + if err != nil { + return nil, err + } + return jwtToken, nil + + } + + return nil, errors.CredentialsInvalid +}