Skip to content
Santekno.com | Level Up Your Engineering Skills
ID
📖 0%
11 Nov 2026 · 23 mnt baca ·Artikel 86 / 208
Go

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

Implementasi lengkap User Service Santekno Shop. RegisterUser, LoginUser, RefreshToken dengan bcrypt, JWT HS256, Redis rate limiting, dan refresh token rotation — semua dari spec.

IH
Ihsan Arif
Penulis di Santekno · Backend Engineer

User service golang dengan JWT, bcrypt, dan Redis rate limiting adalah implementasi konkret pertama Santekno Shop yang menerjemahkan spec menjadi kode berjalan. Dari spec di awal seri: RegisterUser (4 AC) dan LoginUser (4 AC); dari skeleton sebelumnya: stub sudah tersedia. Sekarang kita isi implementasi lengkapnya sesuai clean architecture.

Di artikel ini kita membangun auth layer end-to-end — hashing password bcrypt cost 12, JWT HS256, rate limiting berbasis Redis, dan rotasi refresh token — lalu memverifikasi setiap AC lewat unit test dengan mock. Setiap keputusan keamanan (email enumeration, timing attack, token rotation) dibahas eksplisit agar bukan sekadar menyalin kode, tapi paham alasannya.


06.01 RegisterUser UseCase: Full Implementation

Usecase register adalah orkestrasi aturan bisnis registrasi: normalisasi email, validasi, cek keunikan, hash password, lalu simpan. Implementasi berikut memetakan setiap langkah ke AC spesifik dari 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 — lebih strict bisa pakai 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}

Perhatikan komentar AC1–AC4 di kode: setiap cabang logika bisa ditelusuri langsung ke acceptance criteria di spec, inilah traceability yang menjadi jantung SDD.


06.02 RegisterUser Tests

Implementasi tanpa test tidak lengkap dalam SDD. Test suite berikut memverifikasi setiap AC register — dari happy path hingga email duplikat, password lemah, dan normalisasi email — menggunakan 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: Registrasi berhasil
 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 sudah terdaftar
 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: Password lemah
 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 tanpa angka
 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: Email tidak valid
 85func (s *RegisterUserSuite) TestRegister_InvalidEmail_ReturnsInvalidEmail() {
 86    _, err := s.uc.Execute(context.Background(), auth.RegisterInput{
 87        Email: "bukan-email", Password: "Secret123", Name: "John",
 88    })
 89    s.ErrorIs(err, user.ErrInvalidEmail)
 90}
 91
 92// Email di-lowercase
 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}

Dengan mock, test ini berjalan cepat tanpa database dan setiap s.ErrorIs(...) menjadi bukti hidup bahwa AC terkait benar-benar ter-cover.


06.03 JWT Token Service

Auth butuh dua jenis token: access token JWT yang self-contained dan refresh token opaque yang disimpan hash-nya. Service berikut menangani generate dan validate keduanya dengan 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 sebagai 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}

Pemeriksaan *jwt.SigningMethodHMAC di ValidateAccess mencegah serangan algorithm confusion — token yang mengaku memakai algoritma lain langsung ditolak, bukan diverifikasi dengan cara yang salah.


06.04 LoginUser UseCase

Login menggabungkan rate limiting, verifikasi kredensial, dan penerbitan token. Implementasi berikut sengaja mengembalikan error yang sama untuk email tidak ada maupun password salah — pencegahan email enumeration.

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}

Refresh token di-hash dengan SHA-256 sebelum disimpan, sehingga kebocoran database sekalipun tidak memberi attacker token yang bisa langsung dipakai.


06.05 Redis Rate Limiter

Interface RateLimiter di usecase login dipenuhi oleh implementasi Redis. Rate limiter berikut memakai kombinasi INCR + EXPIRE dalam satu pipeline untuk efisiensi.

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 jika masih dalam limit, false jika sudah melebihi
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}

Karena Allow memenuhi interface yang di-inject ke usecase, login bisa diuji dengan mock rate limiter tanpa Redis nyata, sementara produksi memakai implementasi ini.


06.06 PostgreSQL User Repository

Repository adalah satu-satunya layer yang menyentuh database. Implementasi postgres berikut menegakkan pola not-found nil, nil dan parameterized query di setiap 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}

Setiap query memakai placeholder $1, $2, dan seterusnya — tidak ada string concatenation — sehingga seluruh repository kebal terhadap SQL injection secara desain.


06.07 Auth Handler: Full Implementation

Handler menerjemahkan HTTP request menjadi input usecase dan error menjadi respons. Handler berikut menambahkan binding, validasi request, serta ekstraksi IP untuk 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    // Ambil IP untuk 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}

Handler tetap tipis: ia hanya mengurus HTTP dan mendelegasikan semua aturan bisnis ke usecase, lalu menyerahkan pemetaan error ke apierror.Handle.


06.08 JWT Middleware: Full Implementation

Stub middleware dari skeleton kini kita lengkapi dengan validasi token sesungguhnya. Middleware berikut memvalidasi Bearer token, mengekstrak claims, dan menaruh user ID ke 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}

Dengan menaruh UserID sebagai uuid.UUID di context, handler downstream cukup memanggil GetUserID(c) untuk mengetahui identitas pemanggil tanpa mengurai token ulang.


06.09 RefreshToken UseCase

Refresh token memungkinkan perpanjangan sesi tanpa login ulang, dengan rotasi single-use demi keamanan. Usecase berikut memverifikasi token lama, menerbitkan pasangan baru, dan menghapus yang lama.

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}

Karena token lama dihapus sebelum token baru diterbitkan, setiap refresh token hanya berlaku sekali — jika token yang sama dipakai dua kali, pemakaian kedua otomatis gagal.


06.10 Login UseCase Test

Sama seperti register, login diverifikasi lewat test yang memetakan setiap AC. Cuplikan berikut menunjukkan test untuk happy path, email tidak ditemukan, dan rate limit terlampaui.

go
 1// internal/usecase/auth/login_test.go (key test cases)
 2
 3// AC1: Login berhasil
 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 tidak ditemukan — same error sebagai password salah
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}

Test TestLogin_EmailNotFound menegaskan kontrak keamanan: email tidak ada mengembalikan ErrInvalidCredentials yang sama persis dengan password salah, menutup celah enumeration.


06.11 Update Container Wiring

Setelah semua komponen auth siap, kita rakit di container. Wiring berikut menggantikan nil di skeleton dengan implementasi nyata: repository postgres, rate limiter Redis, dan token service.

go
 1// cmd/server/container.go — update setelah Artikel 06
 2
 3func BuildContainer(cfg *config.Config, db *pgxpool.Pool, rdb *redis.Client) *Container {
 4    // Artikel 06: User infrastructure
 5    userRepo   := postgresRepo.NewUserRepository(db)
 6    rateLimiter := redisinfra.NewRateLimiter(rdb)
 7    tokenSvc   := token.NewJWTService(cfg.JWT.AccessSecret)
 8
 9    // Artikel 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}

Container inilah tempat dependency injection terjadi: implementasi konkret dipasang ke interface, sementara usecase dan handler tidak pernah tahu tipe konkret di baliknya.


06.12 Specify Audit Setelah Artikel 06

Untuk membuktikan spec benar-benar ter-cover, kita jalankan specify audit dan filter fitur user. Output berikut menunjukkan skor per fitur beserta bukti test yang meng-cover tiap 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# }

Field evidence yang menautkan AC ke nama test konkret adalah bukti audit-able bahwa implementasi memenuhi spec — bukan sekadar klaim.


06.13 Integration Test: User Repository

Unit test dengan mock tidak menyentuh SQL sungguhan, jadi kita butuh integration test terhadap database nyata. Test berikut, ditandai //go:build integration, memverifikasi round-trip create dan get, termasuk kasus not-found.

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}

Test not-found di sini memverifikasi kontrak nil, nil terhadap database sungguhan — memastikan pola tidak hanya benar di mock, tapi juga di implementasi pgx nyata.


06.14 Validator Setup

Validasi request di handler bergantung pada validator yang dipasang ke Echo. Adapter berikut membungkus go-playground/validator agar bisa dipakai lewat 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}

Dengan validator terpasang di e.Validator, struct tag seperti validate:"required,email" otomatis ditegakkan — validasi input jadi deklaratif, bukan rangkaian if manual.


06.15 Security: Password Hash Validation

Kadang kita perlu memastikan hash yang tersimpan memakai cost yang cukup kuat. Helper berikut memvalidasi bahwa cost bcrypt tidak turun di bawah minimum yang disyaratkan.

go
 1// Pastikan bcrypt cost tidak di-bawah 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 akan flag MD5/SHA1 — kita pakai bcrypt, OK.
15// gosec: G501 tidak berlaku (tidak import crypto/md5).

Validasi cost ini berguna saat migrasi: jika suatu saat cost minimum dinaikkan, kamu bisa mendeteksi hash lama yang perlu di-rehash pada login berikutnya.


06.16 Email Enumeration Prevention

Salah satu aturan keamanan paling penting di auth adalah menutup email enumeration. Ringkasan berikut menjelaskan bagaimana spec diterjemahkan ke implementasi, plus pertimbangan timing attack.

text
 1DARI SPEC:
 2  "JANGAN bedakan error 'email tidak ada' dan 'password salah'"
 3
 4IMPLEMENTASI:
 5  user.ErrInvalidCredentials digunakan untuk KEDUANYA:
 6  - email tidak ada → ErrInvalidCredentials
 7  - password salah → ErrInvalidCredentials
 8  
 9  Handler mapping:
10  case errors.Is(err, user.ErrInvalidCredentials):
11      return c.JSON(401, Response{Error: "INVALID_CREDENTIALS"})
12  
13  Keduanya → "INVALID_CREDENTIALS"
14  Attacker tidak bisa membedakan mana email yang terdaftar.
15
16TIMING ATTACK PREVENTION:
17  Jika email tidak ada, kita TIDAK panggil bcrypt.
18  Ini bisa menyebabkan timing attack (no-bcrypt = faster response).
19  
20  Fix: selalu jalankan bcrypt dengan dummy hash jika user tidak ada:
21  dummyHash := "$2a$12$dummy_hash_for_timing_attack_prevention_xxxxxxx"
22  bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password))
23  // ini akan fail, tapi timing-nya sama

Error yang identik saja belum cukup — tanpa dummy bcrypt, perbedaan waktu respons masih membocorkan email mana yang terdaftar; menjalankan bcrypt palsu menutup celah timing itu.


06.17 Password Update Patterns (Preview)

Fitur update password menyusul di bagian selanjutnya, tapi polanya penting dipahami sekarang. Usecase berikut memperlihatkan verifikasi password lama, validasi yang baru, dan invalidasi semua refresh token demi keamanan.

go
 1// Untuk fitur update password (Topik 5 Part 3):
 2// Butuh: 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}

Menghapus semua refresh token setelah ganti password memaksa re-login di semua perangkat — langkah keamanan penting jika password diganti karena diduga bocor.


06.18 Logout Pattern

Karena access token JWT bersifat stateless, logout diimplementasikan dengan menghapus refresh token. Usecase berikut menunjukkan pola tersebut beserta konsekuensinya.

go
 1// Logout: delete refresh token dari database
 2// Access token tidak bisa di-invalidate (stateless JWT)
 3// Solusi: access token TTL pendek (1 jam)
 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

Access token yang sudah terlanjur diterbitkan tetap valid hingga TTL habis — itulah kenapa TTL access token sengaja dibuat pendek (1 jam), membatasi jendela paparan setelah logout.


06.19 Spec Compliance Summary

Sebelum menutup, kita rekap bagaimana setiap AC user terpetakan ke test konkret. Ringkasan berikut menunjukkan pemetaan test → AC dan skor audit setelah artikel ini.

text
 1Spec: user-registration → 4 ACs → semua covered ✅
 2Spec: user-login → 4 ACs → semua covered ✅
 3
 4Tests yang mengcover 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 setelah Artikel 06: ~35/100
20(dari 5/100 di Artikel 05)

Lonjakan skor dari 5 ke 35 adalah hasil konkret dari satu domain yang selesai — dan ke-29 AC sisanya akan menaikkannya lebih jauh di artikel-artikel berikutnya.


06.20 Ringkasan: User Service Selesai

Auth layer Santekno Shop kini fully functional dan sesuai spec:

RegisterUser: normalisasi email, bcrypt cost 12, cek keunikan email, dan error code yang konsisten.

LoginUser: rate limiting Redis 5 request per 15 menit, verifikasi bcrypt, JWT HS256, serta penyimpanan refresh token yang di-hash.

RefreshToken: rotasi single-use (token lama diinvalidasi, yang baru diterbitkan), refresh token opaque yang di-hash sebelum disimpan.

JWT Middleware: parsing Bearer token, ekstraksi claims, dan penempatan user ID ke context.

Semua diturunkan dari spec, semua di-test, dan CI pipeline pass. Skor specify audit naik dari 5/100 ke ~35/100; 29 AC lain masih menunggu di domain berikutnya.

Di artikel berikutnya, kita flesh out domain kedua: Product Service — katalog, full-text search, dan manajemen inventory dengan optimistic locking.

Artikel Terkait

💬 Komentar