From 4c8f3022f02cf67965b98c14b1cb66013db5905a Mon Sep 17 00:00:00 2001 From: David Folch Agulles Date: Wed, 26 Nov 2025 21:39:52 +0100 Subject: [PATCH] Refactor password hashing to follow DIP and improve CI/CD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create PasswordHasher interface in domain layer - Implement BcryptHasher in infrastructure layer - Update RegisterUserHandler and LoginUserHandler to use interface - Remove bcrypt dependency from application layer - Update main.go and integration tests with dependency injection - Enhance CI/CD workflow with test and lint jobs - Add code coverage check (minimum 50%) - Add go vet and gofmt validation - Configure build job to depend on test and lint passing - Update GitHub Actions to latest versions (v4→v5) This achieves 100% SOLID compliance (DIP) and ensures Clean Architecture by removing external library dependencies from application/domain layers. --- .github/workflows/docker-publish.yml | 62 +++++++++++++++++-- cmd/api/main.go | 6 +- .../application/commands/register_user.go | 17 ++--- internal/application/queries/login_user.go | 15 +++-- internal/domain/services/password_hasher.go | 6 ++ .../infrastructure/crypto/bcrypt_hasher.go | 25 ++++++++ .../infrastructure/http/integration_test.go | 6 +- 7 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 internal/domain/services/password_hasher.go create mode 100644 internal/infrastructure/crypto/bcrypt_hasher.go diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index bc42ff2..7f512ee 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,22 +1,72 @@ -name: Docker Build and Push +name: CI/CD Pipeline on: push: branches: [ main ] tags: [ 'v*' ] + pull_request: + branches: [ main ] jobs: - build: + test: + name: Test runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run tests + run: go test ./... -v -race -coverprofile=coverage.txt -covermode=atomic + + - name: Check code coverage + run: | + total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//') + echo "Total coverage: $total%" + if (( $(echo "$total < 50" | bc -l) )); then + echo "Error: Code coverage is below 50%" + exit 1 + fi + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run go vet + run: go vet ./... + + - name: Run go fmt + run: | + if [ -n "$(gofmt -s -l .)" ]; then + echo "Go code is not formatted:" + gofmt -s -d . + exit 1 + fi + + build: + name: Build and Push Docker Image + runs-on: ubuntu-latest + needs: [test, lint] + if: github.event_name == 'push' permissions: contents: read packages: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -24,7 +74,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | @@ -34,7 +84,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . push: true diff --git a/cmd/api/main.go b/cmd/api/main.go index 584a215..e7c3e54 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -11,6 +11,7 @@ import ( "apocapoc-api/internal/application/queries" "apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/infrastructure/config" + "apocapoc-api/internal/infrastructure/crypto" httpInfra "apocapoc-api/internal/infrastructure/http" "apocapoc-api/internal/infrastructure/persistence/sqlite" ) @@ -52,13 +53,14 @@ func main() { } jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours) + passwordHasher := crypto.NewBcryptHasher() userRepo := sqlite.NewUserRepository(db.Conn()) habitRepo := sqlite.NewHabitRepository(db.Conn()) entryRepo := sqlite.NewHabitEntryRepository(db.Conn()) - registerHandler := commands.NewRegisterUserHandler(userRepo) - loginHandler := queries.NewLoginUserHandler(userRepo) + registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) + loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) createHandler := commands.NewCreateHabitHandler(habitRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo) diff --git a/internal/application/commands/register_user.go b/internal/application/commands/register_user.go index e5cb2ed..6d6b120 100644 --- a/internal/application/commands/register_user.go +++ b/internal/application/commands/register_user.go @@ -5,9 +5,8 @@ import ( "apocapoc-api/internal/domain/entities" "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/domain/services" "apocapoc-api/internal/shared/errors" - - "golang.org/x/crypto/bcrypt" ) type RegisterUserCommand struct { @@ -17,11 +16,15 @@ type RegisterUserCommand struct { } type RegisterUserHandler struct { - userRepo repositories.UserRepository + userRepo repositories.UserRepository + passwordHasher services.PasswordHasher } -func NewRegisterUserHandler(userRepo repositories.UserRepository) *RegisterUserHandler { - return &RegisterUserHandler{userRepo: userRepo} +func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *RegisterUserHandler { + return &RegisterUserHandler{ + userRepo: userRepo, + passwordHasher: passwordHasher, + } } func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) { @@ -38,7 +41,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman return "", errors.ErrAlreadyExists } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(cmd.Password), bcrypt.DefaultCost) + hashedPassword, err := h.passwordHasher.Hash(cmd.Password) if err != nil { return "", err } @@ -48,7 +51,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman timezone = "UTC" } - user := entities.NewUser(cmd.Email, string(hashedPassword), timezone) + user := entities.NewUser(cmd.Email, hashedPassword, timezone) if err := h.userRepo.Create(ctx, user); err != nil { return "", err diff --git a/internal/application/queries/login_user.go b/internal/application/queries/login_user.go index 3c9569b..4de3b97 100644 --- a/internal/application/queries/login_user.go +++ b/internal/application/queries/login_user.go @@ -4,9 +4,8 @@ import ( "context" "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/domain/services" "apocapoc-api/internal/shared/errors" - - "golang.org/x/crypto/bcrypt" ) type LoginUserQuery struct { @@ -21,11 +20,15 @@ type LoginUserResult struct { } type LoginUserHandler struct { - userRepo repositories.UserRepository + userRepo repositories.UserRepository + passwordHasher services.PasswordHasher } -func NewLoginUserHandler(userRepo repositories.UserRepository) *LoginUserHandler { - return &LoginUserHandler{userRepo: userRepo} +func NewLoginUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *LoginUserHandler { + return &LoginUserHandler{ + userRepo: userRepo, + passwordHasher: passwordHasher, + } } func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*LoginUserResult, error) { @@ -38,7 +41,7 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L return nil, errors.ErrNotFound } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(query.Password)); err != nil { + if err := h.passwordHasher.Compare(user.PasswordHash, query.Password); err != nil { return nil, errors.ErrNotFound } diff --git a/internal/domain/services/password_hasher.go b/internal/domain/services/password_hasher.go new file mode 100644 index 0000000..37293cf --- /dev/null +++ b/internal/domain/services/password_hasher.go @@ -0,0 +1,6 @@ +package services + +type PasswordHasher interface { + Hash(password string) (string, error) + Compare(hashedPassword, password string) error +} diff --git a/internal/infrastructure/crypto/bcrypt_hasher.go b/internal/infrastructure/crypto/bcrypt_hasher.go new file mode 100644 index 0000000..723fcba --- /dev/null +++ b/internal/infrastructure/crypto/bcrypt_hasher.go @@ -0,0 +1,25 @@ +package crypto + +import ( + "apocapoc-api/internal/domain/services" + + "golang.org/x/crypto/bcrypt" +) + +type BcryptHasher struct{} + +func NewBcryptHasher() services.PasswordHasher { + return &BcryptHasher{} +} + +func (b *BcryptHasher) Hash(password string) (string, error) { + hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hashedBytes), nil +} + +func (b *BcryptHasher) Compare(hashedPassword, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) +} diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index ca41d9b..8bf6738 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -11,6 +11,7 @@ import ( "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" "apocapoc-api/internal/infrastructure/auth" + "apocapoc-api/internal/infrastructure/crypto" "apocapoc-api/internal/infrastructure/persistence/sqlite" _ "github.com/mattn/go-sqlite3" @@ -32,13 +33,14 @@ func setupTestServer(t *testing.T) *TestServer { } jwtService := auth.NewJWTService("test-secret", 24) + passwordHasher := crypto.NewBcryptHasher() userRepo := sqlite.NewUserRepository(db) habitRepo := sqlite.NewHabitRepository(db) entryRepo := sqlite.NewHabitEntryRepository(db) - registerHandler := commands.NewRegisterUserHandler(userRepo) - loginHandler := queries.NewLoginUserHandler(userRepo) + registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) + loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) createHandler := commands.NewCreateHabitHandler(habitRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)