Skip to content
Santekno.com | Level Up Your Engineering Skills
EN
📖 0%
11 Nov 2026 · 25 min read ·Article 86 / 208
Go

Golang User Service: Auth JWT bcrypt Redis Rate Limiting Clean Architecture

A complete implementation of the Santekno Shop User Service. RegisterUser, LoginUser, RefreshToken with bcrypt, JWT HS256, Redis rate limiting, and refresh token rotation — all from the spec.

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

A golang user service with JWT, bcrypt, and Redis rate limiting is the first concrete Santekno Shop implementation that turns the spec into running code. From the spec at the start of the series: RegisterUser (4 ACs) and LoginUser (4 ACs); from the earlier skeleton: the stubs are already in place. Now we fill in the full implementation according to clean architecture.

In this article we build the auth layer end-to-end — bcrypt password hashing at cost 12, JWT HS256, Redis-based rate limiting, and refresh token rotation — then verify every AC through unit tests with mocks. Every security decision (email enumeration, timing attack, token rotation) is discussed explicitly so this is not merely copying code, but understanding the reasoning.


06.01 RegisterUser UseCase: Full Implementation

The register usecase is the orchestration of the registration business rules: email normalization, validation, uniqueness check, password hashing, then save. The implementation below maps each step to a specific AC from the spec.

go
 1// internal/usecase/auth/register.go
 2
 3package auth
 4
 5import (
 6    "context"
 7    "fmt"
 8    "strings"
 9    "time"
10    "unicode"
11
12    "github.com/google/uuid"
13    "golang.org/x/crypto/bcrypt"
14    "github.com/santekno/santekno-shop/internal/domain/user"
15)
16
17const bcryptCost = 12
18
19func (uc *RegisterUserUseCase) Execute(ctx context.Context, input RegisterInput) (*RegisterOutput, error) {
20    // Normalize email
21    email := strings.ToLower(strings.TrimSpace(input.Email))
22
23    // AC4: Validate email format
24    if !isValidEmail(email) {
25        return nil, user.ErrInvalidEmail
26    }
27
28    // AC3: Validate password strength
29    if err := user.ValidatePassword(input.Password); err != nil {
30        return nil, err
31    }
32
33    // Validate name
34    name := strings.TrimSpace(input.Name)
35    if len(name) < 2 || len(name) > 100 {
36        return nil, fmt.Errorf("registerUser.Execute: name must be 2-100 chars")
37    }
38
39    // AC2: Check email already registered
40    existing, err := uc.userRepo.GetByEmail(ctx, email)
41    if err != nil {
42        return nil, fmt.Errorf("registerUser.Execute: %w", err)
43    }
44    if existing != nil {
45        return nil, user.ErrEmailAlreadyRegistered
46    }
47
48    // Hash password — bcrypt cost 12
49    hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcryptCost)
50    if err != nil {
51        return nil, fmt.Errorf("registerUser.Execute: hashing password: %w", err)
52    }
53
54    // AC1: Create user
55    u := &user.User{
56        ID:           uuid.New(),
57        Email:        email,
58        PasswordHash: string(hash),
59        Name:         name,
60        IsActive:     true,
61        CreatedAt:    time.Now().UTC(),
62        UpdatedAt:    time.Now().UTC(),
63    }
64
65    if err := uc.userRepo.Create(ctx, u); err != nil {
66        return nil, fmt.Errorf("registerUser.Execute: %w", err)
67    }
68
69    return &RegisterOutput{
70        ID:        u.ID,
71        Email:     u.Email,
72        Name:      u.Name,
73        CreatedAt: u.CreatedAt,
74    }, nil
75}
76
77// isValidEmail: basic validation — stricter checks can use a library
78func isValidEmail(email string) bool {
79    parts := strings.Split(email, "@")
80    if len(parts) != 2 { return false }
81    if len(parts[0]) == 0 || len(parts[1]) == 0 { return false }
82    if !strings.Contains(parts[1], ".") { return false }
83    return true
84}

Notice the AC1–AC4 comments in the code: each logic branch can be traced directly back to an acceptance criterion in the spec — this is the traceability at the heart of SDD.


06.02 RegisterUser Tests

An implementation without tests is not complete in SDD. The test suite below verifies every register AC — from the happy path to duplicate email, weak password, and email normalization — using a mock repository.

go
  1// internal/usecase/auth/register_test.go
  2
  3package auth_test
  4
  5import (
  6    "context"
  7    "testing"
  8
  9    "github.com/stretchr/testify/assert"
 10    "github.com/stretchr/testify/suite"
 11    "go.uber.org/mock/gomock"
 12
 13    "github.com/santekno/santekno-shop/internal/domain/user"
 14    "github.com/santekno/santekno-shop/internal/usecase/auth"
 15    "github.com/santekno/santekno-shop/testutil/mock"
 16)
 17
 18type RegisterUserSuite struct {
 19    suite.Suite
 20    ctrl     *gomock.Controller
 21    userRepo *mock.MockUserRepository
 22    uc       *auth.RegisterUserUseCase
 23}
 24
 25func (s *RegisterUserSuite) SetupTest() {
 26    s.ctrl = gomock.NewController(s.T())
 27    s.userRepo = mock.NewMockUserRepository(s.ctrl)
 28    s.uc = auth.NewRegisterUserUseCase(s.userRepo)
 29}
 30
 31func (s *RegisterUserSuite) TearDownTest() {
 32    s.ctrl.Finish()
 33}
 34
 35// AC1: Registration succeeds
 36func (s *RegisterUserSuite) TestRegister_ValidInput_ReturnsUser() {
 37    s.userRepo.EXPECT().
 38        GetByEmail(gomock.Any(), "user@example.com").
 39        Return(nil, nil) // not found
 40    s.userRepo.EXPECT().
 41        Create(gomock.Any(), gomock.Any()).
 42        Return(nil)
 43
 44    out, err := s.uc.Execute(context.Background(), auth.RegisterInput{
 45        Email: "user@example.com", Password: "Secret123", Name: "John Doe",
 46    })
 47
 48    s.NoError(err)
 49    s.Equal("user@example.com", out.Email)
 50    s.Equal("John Doe", out.Name)
 51    s.NotEmpty(out.ID)
 52}
 53
 54// AC2: Email already registered
 55func (s *RegisterUserSuite) TestRegister_EmailExists_ReturnsEmailAlreadyRegistered() {
 56    existingUser := &user.User{Email: "user@example.com"}
 57    s.userRepo.EXPECT().
 58        GetByEmail(gomock.Any(), "user@example.com").
 59        Return(existingUser, nil)
 60
 61    _, err := s.uc.Execute(context.Background(), auth.RegisterInput{
 62        Email: "user@example.com", Password: "Secret123", Name: "John",
 63    })
 64
 65    s.ErrorIs(err, user.ErrEmailAlreadyRegistered)
 66}
 67
 68// AC3: Weak password
 69func (s *RegisterUserSuite) TestRegister_WeakPassword_ReturnsWeakPassword() {
 70    _, err := s.uc.Execute(context.Background(), auth.RegisterInput{
 71        Email: "user@example.com", Password: "short", Name: "John",
 72    })
 73    s.ErrorIs(err, user.ErrWeakPassword)
 74}
 75
 76// AC3: Password without a digit
 77func (s *RegisterUserSuite) TestRegister_PasswordNoDigit_ReturnsWeakPassword() {
 78    _, err := s.uc.Execute(context.Background(), auth.RegisterInput{
 79        Email: "user@example.com", Password: "NoDigitPass", Name: "John",
 80    })
 81    s.ErrorIs(err, user.ErrWeakPassword)
 82}
 83
 84// AC4: Invalid email
 85func (s *RegisterUserSuite) TestRegister_InvalidEmail_ReturnsInvalidEmail() {
 86    _, err := s.uc.Execute(context.Background(), auth.RegisterInput{
 87        Email: "not-an-email", Password: "Secret123", Name: "John",
 88    })
 89    s.ErrorIs(err, user.ErrInvalidEmail)
 90}
 91
 92// Email is lowercased
 93func (s *RegisterUserSuite) TestRegister_EmailNormalized_LowercaseStored() {
 94    s.userRepo.EXPECT().
 95        GetByEmail(gomock.Any(), "user@example.com"). // lowercase
 96        Return(nil, nil)
 97    s.userRepo.EXPECT().
 98        Create(gomock.Any(), gomock.AssignableToTypeOf(&user.User{})).
 99        DoAndReturn(func(_ interface{}, u *user.User) error {
100            s.Equal("user@example.com", u.Email) // verify lowercase
101            return nil
102        })
103
104    _, err := s.uc.Execute(context.Background(), auth.RegisterInput{
105        Email: "USER@EXAMPLE.COM", Password: "Secret123", Name: "John",
106    })
107    s.NoError(err)
108}
109
110func TestRegisterUserSuite(t *testing.T) {
111    suite.Run(t, new(RegisterUserSuite))
112}

With mocks, these tests run fast without a database, and each s.ErrorIs(...) becomes living proof that the related AC is genuinely covered.


06.03 JWT Token Service

Auth needs two kinds of token: a self-contained access token (JWT) and an opaque refresh token whose hash is stored. The service below handles generating and validating both with HS256.

go
 1// pkg/token/jwt.go
 2
 3package token
 4
 5import (
 6    "crypto/rand"
 7    "encoding/hex"
 8    "fmt"
 9    "time"
10
11    "github.com/golang-jwt/jwt/v5"
12    "github.com/google/uuid"
13)
14
15type Claims struct {
16    UserID uuid.UUID
17    Email  string
18}
19
20type JWTService struct {
21    accessSecret  []byte
22    refreshExpiry time.Duration
23    accessExpiry  time.Duration
24}
25
26func NewJWTService(accessSecret string) *JWTService {
27    return &JWTService{
28        accessSecret:  []byte(accessSecret),
29        accessExpiry:  time.Hour,
30        refreshExpiry: 7 * 24 * time.Hour,
31    }
32}
33
34type jwtClaims struct {
35    UserID string `json:"uid"`
36    Email  string `json:"email"`
37    jwt.RegisteredClaims
38}
39
40func (s *JWTService) GenerateAccess(claims Claims) (string, time.Time, error) {
41    expiresAt := time.Now().Add(s.accessExpiry)
42    jc := jwtClaims{
43        UserID: claims.UserID.String(),
44        Email:  claims.Email,
45        RegisteredClaims: jwt.RegisteredClaims{
46            ExpiresAt: jwt.NewNumericDate(expiresAt),
47            IssuedAt:  jwt.NewNumericDate(time.Now()),
48            Subject:   claims.UserID.String(),
49        },
50    }
51    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jc)
52    signed, err := token.SignedString(s.accessSecret)
53    if err != nil {
54        return "", time.Time{}, fmt.Errorf("token.GenerateAccess: %w", err)
55    }
56    return signed, expiresAt, nil
57}
58
59// GenerateRefresh: 32 random bytes as an opaque token
60func (s *JWTService) GenerateRefresh() (string, time.Time, error) {
61    b := make([]byte, 32)
62    if _, err := rand.Read(b); err != nil {
63        return "", time.Time{}, fmt.Errorf("token.GenerateRefresh: %w", err)
64    }
65    expiresAt := time.Now().Add(s.refreshExpiry)
66    return hex.EncodeToString(b), expiresAt, nil
67}
68
69func (s *JWTService) ValidateAccess(tokenStr string) (*Claims, error) {
70    token, err := jwt.ParseWithClaims(tokenStr, &jwtClaims{},
71        func(t *jwt.Token) (interface{}, error) {
72            if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
73                return nil, fmt.Errorf("unexpected signing method")
74            }
75            return s.accessSecret, nil
76        })
77    if err != nil {
78        return nil, fmt.Errorf("token.ValidateAccess: %w", err)
79    }
80
81    jc, ok := token.Claims.(*jwtClaims)
82    if !ok || !token.Valid {
83        return nil, fmt.Errorf("token.ValidateAccess: invalid claims")
84    }
85
86    uid, err := uuid.Parse(jc.UserID)
87    if err != nil {
88        return nil, fmt.Errorf("token.ValidateAccess: invalid user_id: %w", err)
89    }
90
91    return &Claims{UserID: uid, Email: jc.Email}, nil
92}

The *jwt.SigningMethodHMAC check in ValidateAccess prevents algorithm-confusion attacks — a token claiming a different algorithm is rejected outright, not verified the wrong way.


06.04 LoginUser UseCase

Login combines rate limiting, credential verification, and token issuance. The implementation below deliberately returns the same error for a missing email and a wrong password — email enumeration prevention.

go
  1// internal/usecase/auth/login.go
  2
  3package auth
  4
  5import (
  6    "context"
  7    "crypto/sha256"
  8    "encoding/hex"
  9    "fmt"
 10    "strings"
 11    "time"
 12
 13    "github.com/google/uuid"
 14    "golang.org/x/crypto/bcrypt"
 15
 16    "github.com/santekno/santekno-shop/internal/domain/user"
 17    "github.com/santekno/santekno-shop/pkg/token"
 18)
 19
 20type LoginUserUseCase struct {
 21    userRepo    user.Repository
 22    tokenSvc    *token.JWTService
 23    rateLimiter RateLimiter
 24}
 25
 26// RateLimiter interface — implemented by Redis
 27type RateLimiter interface {
 28    Allow(ctx context.Context, key string, maxAttempts int, windowSecs int) (bool, error)
 29}
 30
 31func NewLoginUserUseCase(userRepo user.Repository, tokenSvc *token.JWTService, rl RateLimiter) *LoginUserUseCase {
 32    return &LoginUserUseCase{
 33        userRepo:    userRepo,
 34        tokenSvc:    tokenSvc,
 35        rateLimiter: rl,
 36    }
 37}
 38
 39func (uc *LoginUserUseCase) Execute(ctx context.Context, input LoginInput) (*LoginOutput, error) {
 40    // AC4: Rate limiting per IP
 41    key := "login:ip:" + input.IP
 42    allowed, err := uc.rateLimiter.Allow(ctx, key, 5, 900) // 5 attempts / 15 min
 43    if err != nil {
 44        return nil, fmt.Errorf("loginUser.Execute: rate limit check: %w", err)
 45    }
 46    if !allowed {
 47        return nil, user.ErrTooManyAttempts
 48    }
 49
 50    email := strings.ToLower(strings.TrimSpace(input.Email))
 51
 52    // Find user — AC2 & AC3: same error for both failures
 53    u, err := uc.userRepo.GetByEmail(ctx, email)
 54    if err != nil {
 55        return nil, fmt.Errorf("loginUser.Execute: %w", err)
 56    }
 57    if u == nil {
 58        return nil, user.ErrInvalidCredentials // AC2: email not found
 59    }
 60
 61    // Verify password
 62    if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(input.Password)); err != nil {
 63        return nil, user.ErrInvalidCredentials // AC3: wrong password
 64    }
 65
 66    // AC1: Generate tokens
 67    accessToken, _, err := uc.tokenSvc.GenerateAccess(token.Claims{
 68        UserID: u.ID,
 69        Email:  u.Email,
 70    })
 71    if err != nil {
 72        return nil, fmt.Errorf("loginUser.Execute: %w", err)
 73    }
 74
 75    refreshTokenStr, refreshExpiry, err := uc.tokenSvc.GenerateRefresh()
 76    if err != nil {
 77        return nil, fmt.Errorf("loginUser.Execute: %w", err)
 78    }
 79
 80    // Hash refresh token before storing
 81    hash := sha256.Sum256([]byte(refreshTokenStr))
 82    tokenHash := hex.EncodeToString(hash[:])
 83
 84    rt := &user.RefreshToken{
 85        ID:        uuid.New(),
 86        UserID:    u.ID,
 87        TokenHash: tokenHash,
 88        ExpiresAt: refreshExpiry,
 89        CreatedAt: time.Now().UTC(),
 90    }
 91
 92    if err := uc.userRepo.SaveRefreshToken(ctx, rt); err != nil {
 93        return nil, fmt.Errorf("loginUser.Execute: saving refresh token: %w", err)
 94    }
 95
 96    return &LoginOutput{
 97        AccessToken:  accessToken,
 98        RefreshToken: refreshTokenStr,
 99        ExpiresIn:    3600,
100        TokenType:    "Bearer",
101    }, nil
102}

The refresh token is hashed with SHA-256 before being stored, so even a database leak does not hand an attacker a token they can use directly.


06.05 Redis Rate Limiter

The RateLimiter interface in the login usecase is fulfilled by a Redis implementation. The rate limiter below uses a combination of INCR + EXPIRE in a single pipeline for efficiency.

go
 1// internal/infrastructure/redis/rate_limiter.go
 2
 3package redisinfra
 4
 5import (
 6    "context"
 7    "fmt"
 8    "time"
 9
10    "github.com/redis/go-redis/v9"
11)
12
13type RateLimiter struct {
14    client *redis.Client
15}
16
17func NewRateLimiter(client *redis.Client) *RateLimiter {
18    return &RateLimiter{client: client}
19}
20
21// Allow: returns true if still within the limit, false if exceeded
22func (r *RateLimiter) Allow(ctx context.Context, key string, maxAttempts int, windowSecs int) (bool, error) {
23    pipe := r.client.Pipeline()
24
25    incr := pipe.Incr(ctx, key)
26    pipe.Expire(ctx, key, time.Duration(windowSecs)*time.Second)
27
28    if _, err := pipe.Exec(ctx); err != nil {
29        return false, fmt.Errorf("rateLimiter.Allow: %w", err)
30    }
31
32    count := incr.Val()
33    return count <= int64(maxAttempts), nil
34}

Because Allow fulfills the interface injected into the usecase, login can be tested with a mock rate limiter without a real Redis, while production uses this implementation.


06.06 PostgreSQL User Repository

The repository is the only layer that touches the database. The postgres implementation below enforces the nil, nil not-found pattern and parameterized queries in every method.

go
  1// internal/repository/postgres/user_repository.go
  2
  3package postgres
  4
  5import (
  6    "context"
  7    "fmt"
  8    "time"
  9
 10    "github.com/google/uuid"
 11    "github.com/jackc/pgx/v5"
 12    "github.com/jackc/pgx/v5/pgxpool"
 13
 14    "github.com/santekno/santekno-shop/internal/domain/user"
 15)
 16
 17type UserRepository struct {
 18    db *pgxpool.Pool
 19}
 20
 21func NewUserRepository(db *pgxpool.Pool) *UserRepository {
 22    return &UserRepository{db: db}
 23}
 24
 25func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
 26    _, err := r.db.Exec(ctx,
 27        `INSERT INTO users (id, email, password_hash, name, is_active, created_at, updated_at)
 28         VALUES ($1, $2, $3, $4, $5, $6, $7)`,
 29        u.ID, u.Email, u.PasswordHash, u.Name, u.IsActive, u.CreatedAt, u.UpdatedAt,
 30    )
 31    if err != nil {
 32        return fmt.Errorf("userRepo.Create: %w", err)
 33    }
 34    return nil
 35}
 36
 37func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) {
 38    var u user.User
 39    err := r.db.QueryRow(ctx,
 40        `SELECT id, email, password_hash, name, is_active, created_at, updated_at
 41         FROM users WHERE email = $1`,
 42        email,
 43    ).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.IsActive, &u.CreatedAt, &u.UpdatedAt)
 44
 45    if err != nil {
 46        if err == pgx.ErrNoRows {
 47            return nil, nil // not found — not an error
 48        }
 49        return nil, fmt.Errorf("userRepo.GetByEmail: %w", err)
 50    }
 51    return &u, nil
 52}
 53
 54func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*user.User, error) {
 55    var u user.User
 56    err := r.db.QueryRow(ctx,
 57        `SELECT id, email, password_hash, name, is_active, created_at, updated_at
 58         FROM users WHERE id = $1`,
 59        id,
 60    ).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.IsActive, &u.CreatedAt, &u.UpdatedAt)
 61
 62    if err != nil {
 63        if err == pgx.ErrNoRows {
 64            return nil, nil
 65        }
 66        return nil, fmt.Errorf("userRepo.GetByID: %w", err)
 67    }
 68    return &u, nil
 69}
 70
 71func (r *UserRepository) SaveRefreshToken(ctx context.Context, rt *user.RefreshToken) error {
 72    _, err := r.db.Exec(ctx,
 73        `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at)
 74         VALUES ($1, $2, $3, $4, $5)`,
 75        rt.ID, rt.UserID, rt.TokenHash, rt.ExpiresAt, rt.CreatedAt,
 76    )
 77    if err != nil {
 78        return fmt.Errorf("userRepo.SaveRefreshToken: %w", err)
 79    }
 80    return nil
 81}
 82
 83func (r *UserRepository) GetRefreshToken(ctx context.Context, tokenHash string) (*user.RefreshToken, error) {
 84    var rt user.RefreshToken
 85    err := r.db.QueryRow(ctx,
 86        `SELECT id, user_id, token_hash, expires_at, created_at
 87         FROM refresh_tokens WHERE token_hash = $1 AND expires_at > $2`,
 88        tokenHash, time.Now().UTC(),
 89    ).Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt)
 90
 91    if err != nil {
 92        if err == pgx.ErrNoRows {
 93            return nil, nil
 94        }
 95        return nil, fmt.Errorf("userRepo.GetRefreshToken: %w", err)
 96    }
 97    return &rt, nil
 98}
 99
100func (r *UserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error {
101    _, err := r.db.Exec(ctx,
102        `DELETE FROM refresh_tokens WHERE token_hash = $1`,
103        tokenHash,
104    )
105    if err != nil {
106        return fmt.Errorf("userRepo.DeleteRefreshToken: %w", err)
107    }
108    return nil
109}

Every query uses $1, $2, and so on placeholders — no string concatenation — so the whole repository is immune to SQL injection by design.


06.07 Auth Handler: Full Implementation

The handler translates an HTTP request into usecase input and an error into a response. The handler below adds binding, request validation, and IP extraction for rate limiting.

go
 1// delivery/http/handler/auth_handler.go
 2
 3package handler
 4
 5import (
 6    "net/http"
 7    "strings"
 8
 9    "github.com/labstack/echo/v4"
10    "github.com/santekno/santekno-shop/internal/usecase/auth"
11    "github.com/santekno/santekno-shop/pkg/apierror"
12)
13
14type registerRequest struct {
15    Email    string `json:"email"    validate:"required,email"`
16    Password string `json:"password" validate:"required,min=8"`
17    Name     string `json:"name"     validate:"required,min=2,max=100"`
18}
19
20type loginRequest struct {
21    Email    string `json:"email"    validate:"required,email"`
22    Password string `json:"password" validate:"required"`
23}
24
25func (h *AuthHandler) Register(c echo.Context) error {
26    var req registerRequest
27    if err := c.Bind(&req); err != nil {
28        return c.JSON(http.StatusBadRequest, apierror.Response{Error: "INVALID_REQUEST"})
29    }
30    if err := c.Validate(&req); err != nil {
31        return c.JSON(http.StatusBadRequest, apierror.Response{Error: "VALIDATION_FAILED", Detail: err.Error()})
32    }
33
34    out, err := h.registerUC.Execute(c.Request().Context(), auth.RegisterInput{
35        Email:    req.Email,
36        Password: req.Password,
37        Name:     req.Name,
38    })
39    if err != nil {
40        return apierror.Handle(c, err)
41    }
42
43    return c.JSON(http.StatusCreated, map[string]interface{}{
44        "id":         out.ID,
45        "email":      out.Email,
46        "name":       out.Name,
47        "created_at": out.CreatedAt,
48    })
49}
50
51func (h *AuthHandler) Login(c echo.Context) error {
52    var req loginRequest
53    if err := c.Bind(&req); err != nil {
54        return c.JSON(http.StatusBadRequest, apierror.Response{Error: "INVALID_REQUEST"})
55    }
56
57    // Get IP for rate limiting
58    ip := c.RealIP()
59    if forwarded := c.Request().Header.Get("X-Forwarded-For"); forwarded != "" {
60        ip = strings.Split(forwarded, ",")[0]
61    }
62
63    out, err := h.loginUC.Execute(c.Request().Context(), auth.LoginInput{
64        Email:    req.Email,
65        Password: req.Password,
66        IP:       ip,
67    })
68    if err != nil {
69        return apierror.Handle(c, err)
70    }
71
72    return c.JSON(http.StatusOK, map[string]interface{}{
73        "access_token":  out.AccessToken,
74        "refresh_token": out.RefreshToken,
75        "expires_in":    out.ExpiresIn,
76        "token_type":    out.TokenType,
77    })
78}

The handler stays thin: it only handles HTTP and delegates all business rules to the usecase, then hands error mapping to apierror.Handle.


06.08 JWT Middleware: Full Implementation

The middleware stub from the skeleton is now completed with real token validation. The middleware below validates the Bearer token, extracts the claims, and places the user ID into the context.

go
 1// delivery/http/middleware/auth.go
 2
 3package middleware
 4
 5import (
 6    "net/http"
 7    "strings"
 8
 9    "github.com/google/uuid"
10    "github.com/labstack/echo/v4"
11    "github.com/santekno/santekno-shop/pkg/token"
12)
13
14const (
15    UserIDKey    = "user_id"
16    UserEmailKey = "user_email"
17)
18
19func JWT(tokenSvc *token.JWTService) echo.MiddlewareFunc {
20    return func(next echo.HandlerFunc) echo.HandlerFunc {
21        return func(c echo.Context) error {
22            authHeader := c.Request().Header.Get("Authorization")
23            if !strings.HasPrefix(authHeader, "Bearer ") {
24                return c.JSON(http.StatusUnauthorized,
25                    map[string]string{"error": "UNAUTHORIZED"})
26            }
27
28            tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
29            claims, err := tokenSvc.ValidateAccess(tokenStr)
30            if err != nil {
31                return c.JSON(http.StatusUnauthorized,
32                    map[string]string{"error": "UNAUTHORIZED"})
33            }
34
35            c.Set(UserIDKey, claims.UserID)
36            c.Set(UserEmailKey, claims.Email)
37            return next(c)
38        }
39    }
40}
41
42func GetUserID(c echo.Context) (uuid.UUID, bool) {
43    uid, ok := c.Get(UserIDKey).(uuid.UUID)
44    return uid, ok
45}

By placing UserID as a uuid.UUID in the context, downstream handlers just call GetUserID(c) to know the caller’s identity without parsing the token again.


06.09 RefreshToken UseCase

The refresh token allows a session to be extended without re-login, with single-use rotation for security. The usecase below verifies the old token, issues a new pair, and deletes the old one.

go
 1// internal/usecase/auth/refresh_token.go
 2
 3package auth
 4
 5import (
 6    "context"
 7    "crypto/sha256"
 8    "encoding/hex"
 9    "fmt"
10    "time"
11
12    "github.com/google/uuid"
13    "github.com/santekno/santekno-shop/internal/domain/user"
14    "github.com/santekno/santekno-shop/pkg/token"
15)
16
17type RefreshTokenInput struct {
18    RefreshToken string
19}
20
21type RefreshTokenUseCase struct {
22    userRepo user.Repository
23    tokenSvc *token.JWTService
24}
25
26func NewRefreshTokenUseCase(userRepo user.Repository, tokenSvc *token.JWTService) *RefreshTokenUseCase {
27    return &RefreshTokenUseCase{userRepo: userRepo, tokenSvc: tokenSvc}
28}
29
30func (uc *RefreshTokenUseCase) Execute(ctx context.Context, input RefreshTokenInput) (*LoginOutput, error) {
31    // Hash incoming token for lookup
32    hash := sha256.Sum256([]byte(input.RefreshToken))
33    tokenHash := hex.EncodeToString(hash[:])
34
35    // Verify token exists and not expired
36    rt, err := uc.userRepo.GetRefreshToken(ctx, tokenHash)
37    if err != nil {
38        return nil, fmt.Errorf("refreshToken.Execute: %w", err)
39    }
40    if rt == nil || time.Now().After(rt.ExpiresAt) {
41        return nil, user.ErrInvalidCredentials
42    }
43
44    // Get user
45    u, err := uc.userRepo.GetByID(ctx, rt.UserID)
46    if err != nil {
47        return nil, fmt.Errorf("refreshToken.Execute: %w", err)
48    }
49    if u == nil || !u.IsActive {
50        return nil, user.ErrInvalidCredentials
51    }
52
53    // Rotate: delete old, create new
54    if err := uc.userRepo.DeleteRefreshToken(ctx, tokenHash); err != nil {
55        return nil, fmt.Errorf("refreshToken.Execute: deleting old token: %w", err)
56    }
57
58    newAccess, _, err := uc.tokenSvc.GenerateAccess(token.Claims{UserID: u.ID, Email: u.Email})
59    if err != nil {
60        return nil, fmt.Errorf("refreshToken.Execute: %w", err)
61    }
62
63    newRefresh, newExpiry, err := uc.tokenSvc.GenerateRefresh()
64    if err != nil {
65        return nil, fmt.Errorf("refreshToken.Execute: %w", err)
66    }
67
68    newHash := sha256.Sum256([]byte(newRefresh))
69    newTokenHash := hex.EncodeToString(newHash[:])
70
71    newRT := &user.RefreshToken{
72        ID:        uuid.New(),
73        UserID:    u.ID,
74        TokenHash: newTokenHash,
75        ExpiresAt: newExpiry,
76        CreatedAt: time.Now().UTC(),
77    }
78    if err := uc.userRepo.SaveRefreshToken(ctx, newRT); err != nil {
79        return nil, fmt.Errorf("refreshToken.Execute: saving new token: %w", err)
80    }
81
82    return &LoginOutput{
83        AccessToken:  newAccess,
84        RefreshToken: newRefresh,
85        ExpiresIn:    3600,
86        TokenType:    "Bearer",
87    }, nil
88}

Because the old token is deleted before the new one is issued, each refresh token is valid only once — if the same token is used twice, the second use fails automatically.


06.10 Login UseCase Test

Just like register, login is verified through tests that map every AC. The excerpt below shows tests for the happy path, email not found, and rate limit exceeded.

go
 1// internal/usecase/auth/login_test.go (key test cases)
 2
 3// AC1: Login succeeds
 4func (s *LoginUserSuite) TestLogin_ValidCredentials_ReturnsTokens() {
 5    hash, _ := bcrypt.GenerateFromPassword([]byte("Secret123"), 12)
 6    u := &user.User{Email: "user@example.com", PasswordHash: string(hash), IsActive: true}
 7
 8    s.userRepo.EXPECT().GetByEmail(gomock.Any(), "user@example.com").Return(u, nil)
 9    s.rateLimiter.EXPECT().Allow(gomock.Any(), gomock.Any(), 5, 900).Return(true, nil)
10    s.userRepo.EXPECT().SaveRefreshToken(gomock.Any(), gomock.Any()).Return(nil)
11
12    out, err := s.uc.Execute(context.Background(), auth.LoginInput{
13        Email: "user@example.com", Password: "Secret123", IP: "127.0.0.1",
14    })
15    s.NoError(err)
16    s.NotEmpty(out.AccessToken)
17    s.NotEmpty(out.RefreshToken)
18    s.Equal(3600, out.ExpiresIn)
19}
20
21// AC2: Email not found — same error as wrong password
22func (s *LoginUserSuite) TestLogin_EmailNotFound_ReturnsInvalidCredentials() {
23    s.userRepo.EXPECT().GetByEmail(gomock.Any(), "notexist@example.com").Return(nil, nil)
24    s.rateLimiter.EXPECT().Allow(gomock.Any(), gomock.Any(), 5, 900).Return(true, nil)
25
26    _, err := s.uc.Execute(context.Background(), auth.LoginInput{
27        Email: "notexist@example.com", Password: "Secret123", IP: "127.0.0.1",
28    })
29    s.ErrorIs(err, user.ErrInvalidCredentials)
30}
31
32// AC4: Rate limit
33func (s *LoginUserSuite) TestLogin_RateLimitExceeded_ReturnsTooManyAttempts() {
34    s.rateLimiter.EXPECT().Allow(gomock.Any(), gomock.Any(), 5, 900).Return(false, nil)
35
36    _, err := s.uc.Execute(context.Background(), auth.LoginInput{
37        Email: "user@example.com", Password: "Secret123", IP: "1.2.3.4",
38    })
39    s.ErrorIs(err, user.ErrTooManyAttempts)
40}

The TestLogin_EmailNotFound test asserts the security contract: a missing email returns the exact same ErrInvalidCredentials as a wrong password, closing the enumeration gap.


06.11 Update Container Wiring

Once all the auth components are ready, we assemble them in the container. The wiring below replaces the nil from the skeleton with real implementations: the postgres repository, the Redis rate limiter, and the token service.

go
 1// cmd/server/container.go — updated after Article 06
 2
 3func BuildContainer(cfg *config.Config, db *pgxpool.Pool, rdb *redis.Client) *Container {
 4    // Article 06: User infrastructure
 5    userRepo   := postgresRepo.NewUserRepository(db)
 6    rateLimiter := redisinfra.NewRateLimiter(rdb)
 7    tokenSvc   := token.NewJWTService(cfg.JWT.AccessSecret)
 8
 9    // Article 06: Auth usecases
10    registerUC     := auth.NewRegisterUserUseCase(userRepo)
11    loginUC        := auth.NewLoginUserUseCase(userRepo, tokenSvc, rateLimiter)
12    refreshTokenUC := auth.NewRefreshTokenUseCase(userRepo, tokenSvc)
13
14    // Handlers
15    authHandler := handler.NewAuthHandler(registerUC, loginUC, refreshTokenUC)
16
17    // Middleware
18    jwtMiddleware := middleware.JWT(tokenSvc)
19
20    // ... other usecases still nil until Articles 07-10
21}

The container is where dependency injection happens: concrete implementations are plugged into interfaces, while the usecase and handler never know the concrete type behind them.


06.12 Specify Audit After Article 06

To prove the spec is truly covered, we run specify audit and filter for the user features. The output below shows the per-feature score along with the test evidence that covers each AC.

bash
 1specify audit --output json | jq '.features[] | select(.feature | contains("user"))'
 2
 3# Expected output:
 4# {
 5#   "feature": "user-registration",
 6#   "score": 95,
 7#   "acs": [
 8#     {"id": "AC1", "status": "covered", "evidence": "TestRegister_ValidInput_ReturnsUser"},
 9#     {"id": "AC2", "status": "covered", "evidence": "TestRegister_EmailExists_Returns..."},
10#     {"id": "AC3", "status": "covered", "evidence": "TestRegister_WeakPassword_Returns..."},
11#     {"id": "AC4", "status": "covered", "evidence": "TestRegister_InvalidEmail_Returns..."}
12#   ]
13# }

The evidence field linking each AC to a concrete test name is auditable proof that the implementation meets the spec — not merely a claim.


06.13 Integration Test: User Repository

Unit tests with mocks never touch real SQL, so we need an integration test against a real database. The test below, tagged //go:build integration, verifies the create-and-get round trip, including the not-found case.

go
 1// internal/repository/postgres/user_repository_integration_test.go
 2//go:build integration
 3
 4package postgres_test
 5
 6import (
 7    "context"
 8    "os"
 9    "testing"
10    "time"
11
12    "github.com/jackc/pgx/v5/pgxpool"
13    "github.com/stretchr/testify/assert"
14    "github.com/stretchr/testify/require"
15
16    "github.com/santekno/santekno-shop/internal/domain/user"
17    postgres "github.com/santekno/santekno-shop/internal/repository/postgres"
18)
19
20func setupDB(t *testing.T) *pgxpool.Pool {
21    dbURL := os.Getenv("DATABASE_URL")
22    if dbURL == "" { t.Skip("DATABASE_URL not set") }
23
24    pool, err := pgxpool.New(context.Background(), dbURL)
25    require.NoError(t, err)
26    t.Cleanup(pool.Close)
27    return pool
28}
29
30func TestUserRepository_CreateAndGetByEmail(t *testing.T) {
31    pool := setupDB(t)
32    repo := postgres.NewUserRepository(pool)
33    ctx := context.Background()
34
35    u := &user.User{
36        Email:        "inttest@example.com",
37        PasswordHash: "$2a$12$...",
38        Name:         "Integration Test",
39        IsActive:     true,
40        CreatedAt:    time.Now().UTC(),
41        UpdatedAt:    time.Now().UTC(),
42    }
43
44    err := repo.Create(ctx, u)
45    require.NoError(t, err)
46
47    found, err := repo.GetByEmail(ctx, "inttest@example.com")
48    require.NoError(t, err)
49    require.NotNil(t, found)
50    assert.Equal(t, "inttest@example.com", found.Email)
51
52    // Cleanup
53    pool.Exec(ctx, "DELETE FROM users WHERE email = $1", "inttest@example.com")
54}
55
56func TestUserRepository_GetByEmail_NotFound(t *testing.T) {
57    pool := setupDB(t)
58    repo := postgres.NewUserRepository(pool)
59
60    result, err := repo.GetByEmail(context.Background(), "notexist@example.com")
61    assert.NoError(t, err)
62    assert.Nil(t, result) // nil, nil — not found is not an error
63}

The not-found test here verifies the nil, nil contract against a real database — ensuring the pattern is correct not only in the mock, but also in the real pgx implementation.


06.14 Validator Setup

Request validation in the handler relies on a validator mounted onto Echo. The adapter below wraps go-playground/validator so it can be used via c.Validate().

go
 1// delivery/http/validator.go
 2
 3package http
 4
 5import (
 6    "net/http"
 7    "github.com/go-playground/validator/v10"
 8    "github.com/labstack/echo/v4"
 9)
10
11type CustomValidator struct {
12    validator *validator.Validate
13}
14
15func NewValidator() *CustomValidator {
16    v := validator.New()
17    return &CustomValidator{validator: v}
18}
19
20func (cv *CustomValidator) Validate(i interface{}) error {
21    if err := cv.validator.Struct(i); err != nil {
22        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
23    }
24    return nil
25}

With the validator mounted on e.Validator, struct tags like validate:"required,email" are enforced automatically — input validation becomes declarative, not a chain of manual ifs.


06.15 Security: Password Hash Validation

Sometimes we need to make sure a stored hash uses a strong enough cost. The helper below validates that the bcrypt cost has not dropped below the required minimum.

go
 1// Ensure the bcrypt cost is not below the minimum
 2
 3const MinBcryptCost = 12
 4
 5func validateBcryptCost(hash string) error {
 6    cost, err := bcrypt.Cost([]byte(hash))
 7    if err != nil { return err }
 8    if cost < MinBcryptCost {
 9        return fmt.Errorf("bcrypt cost %d is below minimum %d", cost, MinBcryptCost)
10    }
11    return nil
12}
13
14// gosec: G401 flags MD5/SHA1 — we use bcrypt, OK.
15// gosec: G501 does not apply (we don't import crypto/md5).

This cost validation is useful during migration: if the minimum cost is ever raised, you can detect old hashes that need re-hashing on the next login.


06.16 Email Enumeration Prevention

One of the most important security rules in auth is closing email enumeration. The summary below explains how the spec is translated into implementation, plus the timing-attack consideration.

text
 1FROM THE SPEC:
 2  "DO NOT distinguish 'email not found' from 'wrong password'"
 3
 4IMPLEMENTATION:
 5  user.ErrInvalidCredentials is used for BOTH:
 6  - email not found → ErrInvalidCredentials
 7  - wrong password → ErrInvalidCredentials
 8  
 9  Handler mapping:
10  case errors.Is(err, user.ErrInvalidCredentials):
11      return c.JSON(401, Response{Error: "INVALID_CREDENTIALS"})
12  
13  Both → "INVALID_CREDENTIALS"
14  The attacker cannot tell which email is registered.
15
16TIMING ATTACK PREVENTION:
17  If the email does not exist, we do NOT call bcrypt.
18  This can cause a timing attack (no-bcrypt = faster response).
19  
20  Fix: always run bcrypt with a dummy hash if the user does not exist:
21  dummyHash := "$2a$12$dummy_hash_for_timing_attack_prevention_xxxxxxx"
22  bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password))
23  // this will fail, but the timing is the same

Identical errors alone are not enough — without a dummy bcrypt, the difference in response time still leaks which emails are registered; running a fake bcrypt closes that timing gap.


06.17 Password Update Patterns (Preview)

The password-update feature comes in a later part, but its pattern is important to understand now. The usecase below shows verifying the old password, validating the new one, and invalidating all refresh tokens for security.

go
 1// For the password update feature (Topic 5 Part 3):
 2// Needs: verify old password + generate new hash
 3
 4func (uc *UpdatePasswordUseCase) Execute(ctx context.Context, input UpdatePasswordInput) error {
 5    u, err := uc.userRepo.GetByID(ctx, input.UserID)
 6    if err != nil { return fmt.Errorf("updatePassword: %w", err) }
 7    if u == nil { return user.ErrUserNotFound }
 8
 9    // Verify current password
10    if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(input.CurrentPassword)); err != nil {
11        return user.ErrInvalidCredentials
12    }
13
14    // Validate new password
15    if err := user.ValidatePassword(input.NewPassword); err != nil { return err }
16
17    // Generate new hash
18    newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcryptCost)
19    if err != nil { return fmt.Errorf("updatePassword: hashing: %w", err) }
20
21    // Invalidate all refresh tokens (security: re-login required)
22    if err := uc.userRepo.DeleteAllRefreshTokens(ctx, input.UserID); err != nil {
23        return fmt.Errorf("updatePassword: clearing tokens: %w", err)
24    }
25
26    return uc.userRepo.UpdatePasswordHash(ctx, input.UserID, string(newHash))
27}

Deleting all refresh tokens after a password change forces re-login on every device — an important security step if the password is changed because it is suspected to be leaked.


06.18 Logout Pattern

Because the JWT access token is stateless, logout is implemented by deleting the refresh token. The usecase below shows the pattern along with its consequences.

go
 1// Logout: delete the refresh token from the database
 2// The access token cannot be invalidated (stateless JWT)
 3// Solution: keep the access token TTL short (1 hour)
 4
 5func (uc *LogoutUseCase) Execute(ctx context.Context, refreshToken string) error {
 6    hash := sha256.Sum256([]byte(refreshToken))
 7    tokenHash := hex.EncodeToString(hash[:])
 8    
 9    if err := uc.userRepo.DeleteRefreshToken(ctx, tokenHash); err != nil {
10        return fmt.Errorf("logout.Execute: %w", err)
11    }
12    return nil
13}
14
15// HTTP Handler:
16// POST /api/v1/auth/logout (requires JWT)
17// Body: {"refresh_token": "..."}
18// Response: 204 No Content

An access token already issued remains valid until its TTL expires — which is why the access token TTL is deliberately kept short (1 hour), limiting the exposure window after logout.


06.19 Spec Compliance Summary

Before closing, we recap how every user AC is mapped to a concrete test. The summary below shows the test → AC mapping and the audit score after this article.

text
 1Spec: user-registration → 4 ACs → all covered
 2Spec: user-login → 4 ACs → all covered
 3
 4Tests that cover the spec:
 5  register_test.go:
 6    TestRegister_ValidInput_ReturnsUser               → AC1
 7    TestRegister_EmailExists_Returns409               → AC2
 8    TestRegister_WeakPassword_Returns400              → AC3
 9    TestRegister_PasswordNoDigit_Returns400           → AC3
10    TestRegister_InvalidEmail_Returns400              → AC4
11    TestRegister_EmailNormalized_LowercaseStored      → Business Rule
12
13  login_test.go:
14    TestLogin_ValidCredentials_ReturnsTokens          → AC1
15    TestLogin_EmailNotFound_ReturnsInvalidCredentials → AC2
16    TestLogin_WrongPassword_ReturnsInvalidCredentials → AC3
17    TestLogin_RateLimitExceeded_ReturnsTooManyAttempts → AC4
18
19specify audit score after Article 06: ~35/100
20(from 5/100 in Article 05)

The jump from 5 to 35 is the concrete result of one completed domain — and the remaining 29 ACs will raise it further in the articles that follow.


06.20 Summary: User Service Complete

The Santekno Shop auth layer is now fully functional and spec-compliant:

RegisterUser: email normalization, bcrypt cost 12, email uniqueness check, and consistent error codes.

LoginUser: Redis rate limiting of 5 requests per 15 minutes, bcrypt verification, JWT HS256, and hashed refresh token storage.

RefreshToken: single-use rotation (the old token is invalidated, a new one issued), an opaque refresh token hashed before storage.

JWT Middleware: Bearer token parsing, claims extraction, and placing the user ID into the context.

Everything is derived from the spec, everything is tested, and the CI pipeline passes. The specify audit score rose from 5/100 to ~35/100; 29 other ACs still wait in the next domains.

In the next article, we flesh out the second domain: the Product Service — catalog, full-text search, and inventory management with optimistic locking.

Related Articles

💬 Comments