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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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 samaError 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.
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.
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 ContentAccess 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.
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.