Web Artisan
Beranda

Progress belajar

Modul 54 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 7 · Security

Password dan Auth Security
untuk Backend Skincare

Login yang aman bukan hanya cocokkan email dan password, tetapi rangkaian proteksi dari hash sampai recovery akun.

Bahasa: Go 1.26~70 menit baca
01

Kenapa auth security tidak boleh seadanya?

Di online shop skincare, auth melindungi data pelanggan, alamat pengiriman, riwayat order, dan akses ke proses pembayaran.

Di React, login sering terasa seperti urusan form dan localStorage. Di Laravel, kamu mungkin terbiasa dengan Hash::make(), bcrypt(), middleware auth, dan password reset bawaan. Di Go, semua itu biasanya lebih eksplisit: kamu memilih fungsi hash, menyimpan token, mengatur TTL, dan memetakan error sendiri.

🌉Jembatan: dari Laravel Auth ke Go

bcrypt_password() atau Hash::make() di PHP mirip secara konsep dengan bcrypt.GenerateFromPassword di Go, tetapi Go tidak menyembunyikan keputusan cost, error, dan storage token di balik framework besar.

authentication

Proses membuktikan identitas user, misalnya email dan password benar, lalu server memberi token sesi.

authorization

Proses memutuskan user yang sudah login boleh melakukan aksi tertentu, misalnya customer boleh checkout dan admin boleh mengubah status order.

Password hash

Password tidak pernah disimpan sebagai teks asli, karena database bisa bocor.

Token pendek

Access token pendek mengurangi dampak bila token tercuri.

Recovery aman

Reset password dan verifikasi email harus memakai token sekali pakai yang disimpan sebagai hash.

sequenceDiagram
  participant FE as Frontend React
  participant API as Go API
  participant DB as PostgreSQL
  participant Mail as Email Provider
  FE->>API: POST /v1/auth/register
  API->>DB: insert user with password_hash
  API->>DB: insert email_verification token_hash
  API->>Mail: send verification link
  FE->>API: POST /v1/auth/verify-email
  API->>DB: mark email_verified_at
  FE->>API: POST /v1/auth/login
  API->>DB: find user and refresh token
  API-->>FE: access token 15m + refresh token 7d

Gambar 1. Auth security adalah rangkaian proses, bukan satu fungsi login.

📌Referensi resmi yang dipakai

Modul ini merujuk ke dokumentasi bcrypt Go, catatan Go 1.26, middleware chi, dan cheat sheet OWASP untuk password storage serta forgot password.

02

Password Hashing dengan bcrypt

Hash password harus lambat secara sengaja, supaya database bocor tidak langsung berubah menjadi daftar password user.

MD5 dan SHA adalah hash umum yang cepat. Cepat bagus untuk checksum file, tetapi buruk untuk password, karena penyerang bisa mencoba miliaran tebakan dengan GPU atau mesin khusus. bcrypt berbeda karena punya adaptive cost factor, artinya biaya komputasi bisa dinaikkan seiring hardware makin cepat.

MD5 / SHA untuk password
  • Didisain cepat, sehingga brute force jadi murah.
  • Tidak punya cost factor bawaan untuk memperlambat verifikasi.
  • Sering terlihat sederhana, tetapi tidak layak untuk password production.
bcrypt untuk password
  • Didisain untuk password hashing, bukan checksum umum.
  • Punya cost factor yang bisa dinaikkan ketika server makin kuat.
  • Hash sudah memuat salt dan parameter cost di string hasilnya.
⚠️Jangan hash password dengan SHA-256 biasa

SHA-256 boleh dipakai untuk hash token acak berentropi tinggi, tetapi bukan untuk password manusia yang sering pendek dan mudah ditebak.

Kode berikut sengaja memisahkan hashing dan verifikasi. Ini membuat service lebih mudah dites, dan nanti bisa diganti ke algoritma lain tanpa membongkar handler.

internal/auth/password.go
package auth import ( "errors" "fmt" "golang.org/x/crypto/bcrypt" ) // bcrypt.DefaultCost = 10, MinCost = 4, MaxCost = 31. // 12 dipilih sengaja di atas default: lebih lambat (lebih tahan brute force), // masih nyaman untuk waktu login. Ini keputusan, bukan magic number. const BcryptCost = 12 var ErrPasswordTooLong = errors.New("password terlalu panjang untuk bcrypt") type PasswordHasher struct{} func (PasswordHasher) HashPassword(plain string) (string, error) { passwordBytes := []byte(plain) // Cek manual ini opsional: GenerateFromPassword sendiri sudah menolak // input > 72 byte dengan bcrypt.ErrPasswordTooLong. Kita cek lebih dulu // hanya supaya pesan errornya berbahasa domain kita sendiri. if len(passwordBytes) > 72 { return "", ErrPasswordTooLong } hash, err := bcrypt.GenerateFromPassword(passwordBytes, BcryptCost) if err != nil { return "", fmt.Errorf("hash password: %w", err) } return string(hash), nil } func (PasswordHasher) VerifyPassword(hash string, plain string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) return err == nil }
💡Angka cost adalah keputusan, bukan magic number

bcrypt.DefaultCost bernilai 10, dengan MinCost 4 dan MaxCost 31. Kita pilih 12 di atas default agar lebih tahan brute force. Cost terlalu kecil melemahkan hash, sedangkan terlalu besar membuat login lambat dan membuka peluang denial of service lewat banyak request login.

📌bcrypt sudah punya error panjang bawaan

Sejak versi terbaru, bcrypt.GenerateFromPassword mengembalikan bcrypt.ErrPasswordTooLong sendiri untuk input lebih dari 72 byte, jadi cek manual len > 72 bersifat opsional. Kita tetap memakainya hanya untuk memberi pesan error berbahasa domain.

bcrypt (default proyek ini)
  • Sudah teruji lama, mudah dipakai lewat golang.org/x/crypto/bcrypt.
  • Cost factor tunggal, hash sudah memuat salt dan parameter.
  • Batas input 72 byte yang perlu diperhatikan.
argon2id (opsi tahan-GPU)
  • Tersedia di golang.org/x/crypto/argon2 lewat argon2.IDKey.
  • Tahan serangan GPU dan ASIC karena memory-hard, butuh banyak memori per hash.
  • Butuh menyimpan parameter (memory, time, threads) dan salt sendiri.

bcrypt.GenerateFromPassword mengembalikan hash dalam bentuk []byte, lalu kita simpan sebagai string di kolom password_hash. Untuk verifikasi, jangan hash ulang manual dan membandingkan string sendiri, pakai CompareHashAndPassword supaya format bcrypt ditangani oleh package.

migrations/202606060701_create_users.sql
CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, email_verified_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );
🌉Jembatan: PHP bcrypt ke Go bcrypt

Di PHP kamu biasanya memanggil password_hash($password, PASSWORD_BCRYPT) dan password_verify; di Go padanannya adalah GenerateFromPassword dan CompareHashAndPassword, dengan error yang harus diperiksa eksplisit.

03

Register dan Verifikasi Email

User baru tidak seharusnya langsung bisa login sebelum emailnya terbukti bisa menerima link verifikasi.

Untuk aplikasi e-commerce, email bukan hanya identitas login. Email juga dipakai untuk invoice, notifikasi pembayaran, pengiriman, dan recovery akun. Kalau email belum diverifikasi, user bisa salah ketik email lalu kehilangan akses ke order.

Register

Handler menerima email dan password, service melakukan normalisasi email, validasi dasar, lalu membuat password_hash.

Buat token verifikasi

Server membuat token acak, menyimpan hash token di database, lalu mengirim link ke email user.

Verifikasi

Ketika link dibuka, server hash token dari request, cari record yang cocok, pastikan belum expired dan belum dipakai, lalu isi email_verified_at.

migrations/202606060702_create_email_verifications.sql
CREATE TABLE email_verification_tokens ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_email_verification_tokens_user_id ON email_verification_tokens(user_id); CREATE INDEX idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at);
📌Kenapa token verifikasi juga di-hash?

Token verifikasi mirip kunci sementara. Kalau database bocor dan token tersimpan plaintext, penyerang bisa memverifikasi akun orang lain.

internal/auth/verification.go
package auth import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "time" ) const EmailVerificationTTL = 24 * time.Hour func GenerateOpaqueToken() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("generate token: %w", err) } return base64.RawURLEncoding.EncodeToString(buf), nil } func HashOpaqueToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } type VerificationRepository interface { StoreEmailVerification(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error ConsumeEmailVerification(ctx context.Context, tokenHash string, now time.Time) (int64, error) MarkEmailVerified(ctx context.Context, userID int64, verifiedAt time.Time) error } type Mailer interface { SendVerification(ctx context.Context, email string, token string) error }
⚠️Hash token beda dari hash password

Token acak 32 byte boleh di-hash dengan SHA-256 karena entropinya tinggi, sedangkan password manusia tetap harus memakai password hashing seperti bcrypt.

Interface saja belum cukup untuk memahami alurnya. Berikut service konkret yang merangkai semuanya: RegisterUser membuat user, menerbitkan token, lalu mengirim email; VerifyEmail meng-consume token sekali pakai dan menandai email terverifikasi.

internal/auth/registration.go
package auth import ( "context" "errors" "fmt" "strings" "time" ) var ErrInvalidVerificationToken = errors.New("token verifikasi tidak valid") type RegistrationService struct { users UserRepository tokens VerificationRepository hasher PasswordHasher mailer Mailer now func() time.Time } func NewRegistrationService(users UserRepository, tokens VerificationRepository, mailer Mailer) *RegistrationService { return &RegistrationService{users: users, tokens: tokens, mailer: mailer, now: time.Now} } func (s *RegistrationService) RegisterUser(ctx context.Context, email string, password string) (User, error) { email = strings.ToLower(strings.TrimSpace(email)) passwordHash, err := s.hasher.HashPassword(password) if err != nil { return User{}, err } user, err := s.users.CreateUser(ctx, email, passwordHash) if err != nil { return User{}, fmt.Errorf("create user: %w", err) } token, err := GenerateOpaqueToken() if err != nil { return User{}, err } expiresAt := s.now().UTC().Add(EmailVerificationTTL) if err := s.tokens.StoreEmailVerification(ctx, user.ID, HashOpaqueToken(token), expiresAt); err != nil { return User{}, fmt.Errorf("store verification token: %w", err) } // Token asli (bukan hash) yang dikirim ke user lewat email. if err := s.mailer.SendVerification(ctx, user.Email, token); err != nil { return User{}, fmt.Errorf("send verification email: %w", err) } return user, nil } func (s *RegistrationService) VerifyEmail(ctx context.Context, token string) error { now := s.now().UTC() // ConsumeEmailVerification mencari record by token_hash (lookup index), // memastikan belum expired dan belum dipakai, lalu mengisi used_at. userID, err := s.tokens.ConsumeEmailVerification(ctx, HashOpaqueToken(token), now) if err != nil { return ErrInvalidVerificationToken } if err := s.tokens.MarkEmailVerified(ctx, userID, now); err != nil { return fmt.Errorf("mark email verified: %w", err) } return nil }
💡Kenapa lookup by token_hash aman secara timing?

Kita tidak membandingkan token string satu per satu. Server hash token dari request, lalu cari record by token_hash yang ber-index unik. Pencarian index tidak bocor lewat timing per karakter, jadi tidak perlu crypto/subtle.ConstantTimeCompare di sini. Constant-time comparison baru relevan saat membandingkan secret yang TIDAK disimpan sebagai hash, seperti signature HMAC webhook nanti di chapter 4.

stateDiagram-v2
  [*] --> Issued: RegisterUser buat token
  Issued --> Sent: email terkirim
  Sent --> Consumed: VerifyEmail set used_at
  Sent --> Expired: lewat TTL 24 jam
  Sent --> Superseded: user minta token baru
  Consumed --> [*]
  Expired --> [*]
  Superseded --> [*]

Gambar 2. Satu token verifikasi hanya boleh dipakai sekali, dan akan kedaluwarsa atau digantikan.

🌉Jembatan: MustVerifyEmail Laravel ke alur manual Go

Di Laravel, contract MustVerifyEmail dan event bawaan menangani verifikasi otomatis. Di Go kamu merakit sendiri: tabel email_verification_tokens, RegisterUser, dan VerifyEmail. Scaffolding Laravel sebenarnya adalah building block yang sama, hanya saja di Go semuanya eksplisit.

erDiagram
  users ||--o{ email_verification_tokens : memiliki
  users ||--o{ refresh_tokens : memiliki
  users ||--o{ password_reset_tokens : memiliki
  users {
    bigint id PK
    text email UK
    text password_hash
    timestamptz email_verified_at
  }
  email_verification_tokens {
    bigint id PK
    bigint user_id FK
    text token_hash UK
    timestamptz expires_at
    timestamptz used_at
  }
  refresh_tokens {
    bigint id PK
    bigint user_id FK
    text token_hash UK
    timestamptz expires_at
    timestamptz revoked_at
  }
  password_reset_tokens {
    bigint id PK
    bigint user_id FK
    text token_hash UK
    timestamptz expires_at
    timestamptz used_at
  }

Gambar 3. Satu user punya banyak token; semua tabel token menyimpan token_hash unik dan FK ke users dengan ON DELETE CASCADE.

04

Access Token Pendek, Refresh Token Panjang

Access token sebaiknya pendek umurnya, refresh token lebih panjang tetapi harus bisa dicabut dan dilacak.

Dalam backend ini, kita pakai strategi umum: access token 15 menit dan refresh token 7 hari. Access token dipakai untuk request API biasa, seperti GET /v1/me, POST /v1/cart/items, dan POST /v1/orders. Refresh token dipakai hanya untuk meminta access token baru.

access token

Token pendek yang dikirim client untuk membuktikan sesi aktif saat memanggil endpoint API.

refresh token

Token lebih panjang yang dipakai untuk menerbitkan access token baru, idealnya disimpan server sebagai hash dan bisa dicabut.

Access token 15 menit

Bila tercuri dari browser, masa pakainya pendek sehingga dampak serangan lebih kecil.

Refresh token 7 hari

User tidak perlu login ulang terus, tetapi server tetap bisa mencabut token ketika logout atau rotasi token.

internal/auth/session.go
package auth import ( "context" "fmt" "time" ) const ( AccessTokenTTL = 15 * time.Minute RefreshTokenTTL = 7 * 24 * time.Hour ) // SignAccessToken menanam expiresAt sebagai klaim exp di dalam token. // Cara memvalidasi exp saat request masuk (dengan toleransi clock skew kecil) // dibahas tuntas di chapter 2 ketika kita pasang JWT dan middleware auth. type TokenSigner interface { SignAccessToken(userID int64, email string, expiresAt time.Time) (string, error) } type RefreshTokenRepository interface { StoreRefreshToken(ctx context.Context, token RefreshTokenRecord) error RevokeRefreshToken(ctx context.Context, tokenHash string, revokedAt time.Time) error FindRefreshToken(ctx context.Context, tokenHash string, now time.Time) (RefreshTokenRecord, error) } type RefreshTokenRecord struct { UserID int64 TokenHash string ExpiresAt time.Time RevokedAt *time.Time CreatedAt time.Time } type SessionService struct { signer TokenSigner repo RefreshTokenRepository now func() time.Time } func NewSessionService(signer TokenSigner, repo RefreshTokenRepository) *SessionService { return &SessionService{signer: signer, repo: repo, now: time.Now} } func (s *SessionService) IssueSession(ctx context.Context, userID int64, email string) (string, string, error) { now := s.now().UTC() accessToken, err := s.signer.SignAccessToken(userID, email, now.Add(AccessTokenTTL)) if err != nil { return "", "", fmt.Errorf("sign access token: %w", err) } refreshToken, err := GenerateOpaqueToken() if err != nil { return "", "", err } record := RefreshTokenRecord{ UserID: userID, TokenHash: HashOpaqueToken(refreshToken), ExpiresAt: now.Add(RefreshTokenTTL), CreatedAt: now, } if err := s.repo.StoreRefreshToken(ctx, record); err != nil { return "", "", fmt.Errorf("store refresh token: %w", err) } return accessToken, refreshToken, nil }
⏱️exp ditegakkan saat verifikasi, bukan saat dibuat

TTL di sini hanya menentukan kapan token kedaluwarsa. Yang benar-benar menolak token expired adalah verifikasi exp saat request masuk. Itu dibahas di chapter 2 bersama JWT, termasuk toleransi clock skew kecil supaya server dengan jam sedikit beda tidak salah menolak token yang masih valid.

💡Untuk browser, pertimbangkan cookie HttpOnly

Access token di memory dan refresh token di cookie HttpOnly, Secure, dan SameSite sering lebih aman daripada menyimpan semua token di localStorage.

Frontend JS: localStorage mudah
  • Mudah dibaca oleh kode React.
  • Juga mudah dicuri bila ada XSS.
  • Sering dipakai untuk demo, bukan selalu cocok untuk production.
Production auth: minimalkan dampak XSS
  • Cookie HttpOnly tidak bisa dibaca JavaScript.
  • Token pendek membatasi masa pakai credential yang bocor.
  • Logout dan rotasi refresh token tetap butuh storage server-side.
05

Refresh Token Disimpan sebagai Hash

Refresh token adalah credential jangka panjang, maka database hanya boleh menyimpan bentuk hash-nya.

Saat login berhasil, server membuat refresh token acak dan mengirim token asli ke client. Database hanya menyimpan token_hash. Saat client memanggil refresh endpoint, server hash token dari request, lalu mencocokkannya ke database.

migrations/202606060703_create_refresh_tokens.sql
CREATE TABLE refresh_tokens ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
internal/auth/refresh.go
package auth import ( "context" "errors" "fmt" "time" ) var ErrInvalidRefreshToken = errors.New("refresh token tidak valid") func (s *SessionService) Refresh(ctx context.Context, refreshToken string, email string) (string, string, error) { now := s.now().UTC() tokenHash := HashOpaqueToken(refreshToken) record, err := s.repo.FindRefreshToken(ctx, tokenHash, now) if err != nil { return "", "", ErrInvalidRefreshToken } if record.RevokedAt != nil || !record.ExpiresAt.After(now) { return "", "", ErrInvalidRefreshToken } if err := s.repo.RevokeRefreshToken(ctx, tokenHash, now); err != nil { return "", "", fmt.Errorf("revoke old refresh token: %w", err) } return s.IssueSession(ctx, record.UserID, email) }
🔁Rotasi refresh token

Setiap refresh berhasil mencabut refresh token lama dan menerbitkan token baru. Ini membantu mendeteksi reuse token yang dicuri.

flowchart LR
  Client["Client"] -->|POST /v1/auth/refresh| API["Go API"]
  API --> Hash["hash refresh token"]
  Hash --> DB[("refresh_tokens")]
  DB -->|valid, not expired, not revoked| Revoke["revoke old token"]
  Revoke --> Issue["issue new access + refresh"]
  Issue --> Client

Gambar 4. Refresh token asli tidak pernah disimpan sebagai plaintext di database.

⚠️Jangan samakan refresh token dengan remember me biasa

remember_token ala framework sering terlalu longgar bila tidak punya expiry, revoke, dan hash storage yang jelas.

06

Login Rate Limiting

Endpoint login harus tahan brute force tanpa membuat seluruh API ikut tersendat.

chi/middleware.Throttle membatasi jumlah request yang sedang diproses bersamaan. Itu berguna sebagai pagar global terhadap lonjakan concurrency, tetapi bukan rate limiter per user atau per IP. Untuk proteksi login, gunakan counter berbasis IP dan email, biasanya disimpan di Redis agar konsisten di banyak instance API.

chi middleware.Throttle
  • Membatasi request yang sedang berjalan pada satu titik middleware.
  • Berguna untuk backpressure global.
  • Tidak otomatis membatasi percobaan login per email.
Redis counter untuk login
  • Menghitung gagal login per IP dan email.
  • Bisa dipakai lintas instance API.
  • Cocok untuk lockout pendek dan progressive delay.
internal/auth/login_limiter.go
package auth import ( "context" "errors" "fmt" "net/netip" "strings" "time" ) var ErrTooManyLoginAttempts = errors.New("terlalu banyak percobaan login") const ( LoginWindow = 10 * time.Minute MaxLoginFailures = 5 LoginLockDuration = 15 * time.Minute ) type LoginAttemptStore interface { IncrementFailure(ctx context.Context, key string, window time.Duration) (int, error) Reset(ctx context.Context, key string) error IsLocked(ctx context.Context, key string) (bool, error) Lock(ctx context.Context, key string, duration time.Duration) error } type LoginLimiter struct { store LoginAttemptStore } func NewLoginLimiter(store LoginAttemptStore) *LoginLimiter { return &LoginLimiter{store: store} } func (l *LoginLimiter) Check(ctx context.Context, email string, ip netip.Addr) error { key := loginAttemptKey(email, ip) locked, err := l.store.IsLocked(ctx, key) if err != nil { return fmt.Errorf("check login lock: %w", err) } if locked { return ErrTooManyLoginAttempts } return nil } func (l *LoginLimiter) RecordFailure(ctx context.Context, email string, ip netip.Addr) error { key := loginAttemptKey(email, ip) count, err := l.store.IncrementFailure(ctx, key, LoginWindow) if err != nil { return fmt.Errorf("record login failure: %w", err) } if count >= MaxLoginFailures { return l.store.Lock(ctx, key, LoginLockDuration) } return nil } func (l *LoginLimiter) RecordSuccess(ctx context.Context, email string, ip netip.Addr) error { return l.store.Reset(ctx, loginAttemptKey(email, ip)) } func loginAttemptKey(email string, ip netip.Addr) string { normalizedEmail := strings.ToLower(strings.TrimSpace(email)) return "login:" + ip.String() + ":" + normalizedEmail }
💡Kunci rate limit jangan hanya email

Batasi dengan kombinasi IP dan email. Kalau hanya email, penyerang bisa mengunci akun korban dengan sengaja. Kalau hanya IP, NAT kantor atau kampus bisa terkena dampak berlebihan.

flowchart TD
  A["Pilih kunci rate limit login"] --> B["Hanya IP"]
  A --> C["Hanya email"]
  A --> D["IP + email"]
  B --> B1["NAT kantor atau kampus ikut terkunci"]
  C --> C1["Penyerang sengaja mengunci akun korban"]
  D --> D1["Seimbang: lindungi akun tanpa hukum massal di balik NAT"]

Gambar 5. Kombinasi IP dan email menyeimbangkan proteksi akun dengan dampak ke pengguna di balik NAT.

Mendapatkan IP client dengan aman

Counter di atas mengunci berdasarkan IP, jadi cara membaca IP itu sendiri menentukan apakah lockout bisa dilewati. chi/middleware.RealIP kini dideprekasi karena rentan IP spoofing: ia memutasi r.RemoteAddr ke nilai X-Forwarded-For paling kiri tanpa memeriksa apakah proxy kamu benar-benar menyetelnya. Pakai salah satu pembaca baru yang tidak memutasi r.RemoteAddr, lalu ambil IP via middleware.GetClientIPAddr yang mengembalikan netip.Addr (pas dengan loginAttemptKey).

cmd/api/main.go
package main import ( "net/http" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httprate" ) func main() { r := chi.NewRouter() r.Use(middleware.RequestID) // Di belakang 1 load balancer (mis. AWS ALB), percayai 1 proxy terdepan. // JANGAN pakai middleware.RealIP: dideprekasi, rentan IP spoofing. r.Use(middleware.ClientIPFromXFFTrustedProxies(1)) r.Use(middleware.Recoverer) r.Use(middleware.Throttle(100)) r.Route("/v1/auth", func(r chi.Router) { // Pagar tipis berbasis IP + email di depan handler login. r.With(loginRateLimit()).Post("/login", handleLogin) }) http.ListenAndServe(":8080", r) } func loginRateLimit() func(http.Handler) http.Handler { return httprate.Limit( 10, time.Minute, httprate.WithKeyFuncs( httprate.KeyByIP, func(r *http.Request) (string, error) { return r.FormValue("email"), nil }, ), ) } func handleLogin(w http.ResponseWriter, r *http.Request) { ip := middleware.GetClientIPAddr(r.Context()) // netip.Addr, tidak memutasi RemoteAddr _ = ip w.WriteHeader(http.StatusNotImplemented) }
⚠️middleware.RealIP dideprekasi (IP spoofing)

Karena rate limit login bergantung pada IP, memakai RealIP yang mudah dipalsukan berarti penyerang bisa memalsukan header X-Forwarded-For untuk lolos lockout. Ganti dengan ClientIPFromXFFTrustedProxies (atau ClientIPFromXFF dengan daftar CIDR proxy tepercaya), lalu baca via GetClientIPAddr(ctx).

🌉Jembatan: req.ip Express vs trusted proxy di Go

Di Express, req.ip atau membaca X-Forwarded-For mentah sering naif dan mudah dipalsukan tanpa trust proxy. Di Go, konsep yang sama disebut trusted proxy: kamu menyatakan berapa proxy terdepan yang boleh dipercaya (relevan di balik ALB AWS di Roadmap 8).

httprate adalah pembatas berbasis sliding window counter yang cocok sebagai pagar tipis di depan handler. Untuk lockout per akun yang lebih kaya (progressive delay, reset saat sukses), kita tetap pakai LoginLimiter di atas. Berikut implementasi LoginAttemptStore sederhana di memori untuk satu instance.

internal/auth/inmemory_store.go
package auth import ( "context" "sync" "time" ) // InMemoryLoginStore cocok untuk satu instance / pengembangan. // Di production multi-container, ganti dengan implementasi berbasis Redis // agar semua instance berbagi hitungan yang sama. type InMemoryLoginStore struct { mu sync.Mutex failures map[string]int lockedTo map[string]time.Time now func() time.Time } func NewInMemoryLoginStore() *InMemoryLoginStore { return &InMemoryLoginStore{ failures: make(map[string]int), lockedTo: make(map[string]time.Time), now: time.Now, } } func (s *InMemoryLoginStore) IncrementFailure(_ context.Context, key string, _ time.Duration) (int, error) { s.mu.Lock() defer s.mu.Unlock() s.failures[key]++ return s.failures[key], nil } func (s *InMemoryLoginStore) Reset(_ context.Context, key string) error { s.mu.Lock() defer s.mu.Unlock() delete(s.failures, key) delete(s.lockedTo, key) return nil } func (s *InMemoryLoginStore) IsLocked(_ context.Context, key string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() until, ok := s.lockedTo[key] return ok && until.After(s.now()), nil } func (s *InMemoryLoginStore) Lock(_ context.Context, key string, duration time.Duration) error { s.mu.Lock() defer s.mu.Unlock() s.lockedTo[key] = s.now().Add(duration) return nil }
⚠️Throttle bukan pengganti brute force protection

middleware.Throttle(100) hanya membatasi request yang sedang aktif, bukan menghitung lima password salah untuk email yang sama.

🗄️Hard 429 vs progressive lockout vs Redis

Pendekatan paling sederhana adalah hard 429 begitu kuota habis. Progressive lockout (kunci 15 menit setelah 5 gagal) lebih ramah pengguna sah tetapi tetap menghukum penyerang. Begitu API berjalan di banyak container, pindahkan counter ke Redis lewat github.com/go-chi/httprate-redis (mengimplementasikan httprate.LimitCounter) atau store Redis untuk LoginLimiter, supaya hitungan konsisten lintas instance.

🌉Jembatan: express-rate-limit ke httprate

Di Node, express-rate-limit default menyimpan hitungan di memori per-proses, sama seperti InMemoryLoginStore di atas. Begitu ada banyak instance, per-proses tidak cukup karena tiap container punya hitungan sendiri. Solusinya sama di dua dunia: pindah ke store bersama seperti Redis.

07

Forgot Password Flow

Forgot password harus membantu user tanpa memberi petunjuk ke penyerang bahwa email tertentu terdaftar.

Flow yang aman punya empat prinsip: response selalu generik, token acak disimpan sebagai hash, token punya expiry pendek, dan token hanya bisa dipakai sekali. Setelah password berhasil direset, cabut refresh token aktif agar sesi lama tidak tetap hidup.

User meminta reset

POST /v1/auth/forgot-password menerima email dan selalu membalas sukses generik.

Server membuat token

Bila email terdaftar, server membuat token acak, menyimpan hash token, lalu mengirim link reset.

User membuka link

POST /v1/auth/reset-password menerima token dan password baru, lalu server validasi hash token.

Server mengamankan sesi

Password di-hash ulang dengan bcrypt, token reset ditandai used, dan refresh token user dicabut.

migrations/202606060704_create_password_reset_tokens.sql
CREATE TABLE password_reset_tokens ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); CREATE INDEX idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at);
internal/auth/password_reset.go
package auth import ( "context" "fmt" "strings" "time" ) const PasswordResetTTL = 30 * time.Minute type PasswordResetRepository interface { FindUserIDByEmail(ctx context.Context, email string) (int64, error) StorePasswordReset(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error ConsumePasswordReset(ctx context.Context, tokenHash string, now time.Time) (int64, error) UpdatePasswordHash(ctx context.Context, userID int64, passwordHash string, updatedAt time.Time) error RevokeAllRefreshTokens(ctx context.Context, userID int64, revokedAt time.Time) error } type ResetMailer interface { SendPasswordReset(ctx context.Context, email string, token string) error } type PasswordResetService struct { repo PasswordResetRepository hasher PasswordHasher mailer ResetMailer now func() time.Time } func NewPasswordResetService(repo PasswordResetRepository, hasher PasswordHasher, mailer ResetMailer) *PasswordResetService { return &PasswordResetService{repo: repo, hasher: hasher, mailer: mailer, now: time.Now} } // RequestReset selalu mengembalikan nil ke pemanggil, baik email terdaftar // maupun tidak. Handler memetakan ini ke response generik yang sama, sehingga // penyerang tidak bisa membedakan email terdaftar lewat status atau timing. func (s *PasswordResetService) RequestReset(ctx context.Context, email string) error { email = strings.ToLower(strings.TrimSpace(email)) userID, err := s.repo.FindUserIDByEmail(ctx, email) if err != nil { // Email tidak ada: diam-diam selesai tanpa membocorkan apa pun. return nil } token, err := GenerateOpaqueToken() if err != nil { return fmt.Errorf("generate reset token: %w", err) } expiresAt := s.now().UTC().Add(PasswordResetTTL) if err := s.repo.StorePasswordReset(ctx, userID, HashOpaqueToken(token), expiresAt); err != nil { return fmt.Errorf("store password reset: %w", err) } if err := s.mailer.SendPasswordReset(ctx, email, token); err != nil { return fmt.Errorf("send reset email: %w", err) } return nil } func (s *PasswordResetService) CompleteReset(ctx context.Context, token string, newPassword string) error { now := s.now().UTC() userID, err := s.repo.ConsumePasswordReset(ctx, HashOpaqueToken(token), now) if err != nil { return fmt.Errorf("consume password reset: %w", err) } passwordHash, err := s.hasher.HashPassword(newPassword) if err != nil { return err } if err := s.repo.UpdatePasswordHash(ctx, userID, passwordHash, now); err != nil { return fmt.Errorf("update password hash: %w", err) } if err := s.repo.RevokeAllRefreshTokens(ctx, userID, now); err != nil { return fmt.Errorf("revoke sessions: %w", err) } return nil }
✉️Response forgot password harus generik

Balasan seperti jika email terdaftar, kami akan mengirim link mencegah email enumeration lewat endpoint recovery. RequestReset selalu mengembalikan nil untuk email ada maupun tidak, sehingga handler memetakannya ke response yang sama.

⏱️Samakan juga waktunya, bukan hanya pesannya

Kalau cabang email-ada menjalankan generate token, hash, simpan, dan kirim email, sedangkan cabang email-tidak-ada langsung selesai, perbedaan waktu respons bisa membocorkan email terdaftar. Untuk hardening penuh, jalankan kerja yang setara waktunya di kedua cabang, misalnya tetap generate dan hash token meski tidak disimpan, atau pindahkan pengiriman email ke worker async agar latensi tidak bergantung pada keberadaan email.

🌉Jembatan: Password::sendResetLink Laravel ke Go

Laravel menyediakan Password::sendResetLink() dan Password::reset() plus tabel password_reset_tokens bawaan. Di Go kamu merakit padanannya: RequestReset, CompleteReset, dan tabel yang kamu buat sendiri. Scaffolding Laravel adalah building block yang sama, hanya saja eksplisit di Go.

sequenceDiagram
  participant User as User
  participant API as Go API
  participant DB as PostgreSQL
  participant Mail as Email Provider
  User->>API: POST /v1/auth/forgot-password
  API->>DB: find user by email
  API->>DB: store reset token_hash when user exists
  API->>Mail: send reset link when user exists
  API-->>User: generic success response
  User->>API: POST /v1/auth/reset-password
  API->>DB: consume token_hash once
  API->>DB: update password_hash and revoke refresh tokens
  API-->>User: password changed

Gambar 6. Forgot password yang aman tidak membocorkan apakah email terdaftar.

08

Endpoint dan Struktur Modul Auth

Auth perlu batas yang jelas antara handler HTTP, service bisnis, repository database, dan integrasi email.

Struktur internal/auth
  • cmd/
  • api/
  • main.go mount router dan middleware
  • internal/
  • auth/
  • handler.go parse JSON, tulis response HTTP
  • service.go login, refresh, logout
  • registration.go register user dan verifikasi email
  • password.go bcrypt hash dan verify
  • verification.go email verification token
  • password_reset.go forgot dan reset password
  • login_limiter.go counter brute force login
  • inmemory_store.go LoginAttemptStore in-memory
  • repository.go kontrak repository auth
  • pg_repository.go implementasi PostgreSQL dengan pgx
  • mailer.go kontrak pengiriman email
  • shared/
  • httperror.go mapping error ke HTTP status
  • migrations/
  • 202606060701_create_users.sql
  • 202606060702_create_email_verifications.sql
  • 202606060703_create_refresh_tokens.sql
  • 202606060704_create_password_reset_tokens.sql
  • go.mod
POST /v1/auth/register Buat user baru, hash password, dan kirim email verification
POST /v1/auth/verify-email Verifikasi token email sebelum user boleh login
POST /v1/auth/login Verifikasi password, rate limit, lalu terbitkan access token dan refresh token
POST /v1/auth/refresh Rotasi refresh token dan buat access token baru
POST /v1/auth/logout Cabut refresh token aktif
POST /v1/auth/forgot-password Kirim link reset password dengan response generik
POST /v1/auth/reset-password Ganti password memakai token reset sekali pakai
🌉Jembatan: controller Laravel vs handler Go

Handler Go mirip controller tipis di Laravel. Bedanya, service dan repository tidak otomatis dibuat framework, sehingga boundary harus kamu disiplinkan sendiri.

internal/auth/service.go
package auth import ( "context" "errors" "fmt" "net/netip" "strings" "time" ) var ( ErrInvalidCredentials = errors.New("email atau password tidak valid") ErrEmailNotVerified = errors.New("email belum diverifikasi") ) type User struct { ID int64 Email string PasswordHash string EmailVerifiedAt *time.Time } type UserRepository interface { FindUserByEmail(ctx context.Context, email string) (User, error) CreateUser(ctx context.Context, email string, passwordHash string) (User, error) } type AuthService struct { users UserRepository sessions *SessionService limiter *LoginLimiter hasher PasswordHasher } // dummyBcryptHash adalah hash bcrypt valid untuk password acak yang tidak // pernah dipakai. Saat email tidak ditemukan, kita tetap membandingkan password // ke hash ini supaya waktu respons setara dengan kasus password salah. Tanpa ini, // "email tidak ada" selesai lebih cepat dan membocorkan email terdaftar via timing. const dummyBcryptHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEeO5e6X3Q1Q3w0sJ0mN8s8mWpZ9mJ8nF2" func (s *AuthService) Login(ctx context.Context, email string, password string, ip netip.Addr) (string, string, error) { email = strings.ToLower(strings.TrimSpace(email)) if err := s.limiter.Check(ctx, email, ip); err != nil { return "", "", err } user, err := s.users.FindUserByEmail(ctx, email) if err != nil { // User tidak ada: jalankan bcrypt dummy agar waktunya setara, // lalu kembalikan error yang sama persis dengan password salah. s.hasher.VerifyPassword(dummyBcryptHash, password) _ = s.limiter.RecordFailure(ctx, email, ip) return "", "", ErrInvalidCredentials } if !s.hasher.VerifyPassword(user.PasswordHash, password) { _ = s.limiter.RecordFailure(ctx, email, ip) return "", "", ErrInvalidCredentials } if user.EmailVerifiedAt == nil { return "", "", ErrEmailNotVerified } if err := s.limiter.RecordSuccess(ctx, email, ip); err != nil { return "", "", fmt.Errorf("reset login limiter: %w", err) } return s.sessions.IssueSession(ctx, user.ID, user.Email) }
⚠️Jangan beri pesan error terlalu detail

Untuk login, jangan bedakan email tidak ditemukan dan password salah di response client. Detail seperti itu membantu email enumeration.

🌉Jembatan: Auth::attempt + throttle Laravel ke Go

Di Laravel, Auth::attempt() plus middleware throttle (lewat Fortify atau RouteServiceProvider) menggabungkan verifikasi password dan rate limiting secara implisit. Di Go kamu merakitnya eksplisit: LoginLimiter.Check dulu, lalu bcrypt.CompareHashAndPassword lewat VerifyPassword, lalu RecordFailure atau RecordSuccess.

sequenceDiagram
  participant C as Client
  participant API as Go API
  participant L as LoginLimiter
  participant DB as PostgreSQL
  C->>API: POST /v1/auth/login
  API->>L: Check(email, ip)
  alt terkunci
    L-->>API: ErrTooManyLoginAttempts
    API-->>C: 429 Too Many Requests
  else lolos
    API->>DB: FindUserByEmail
    alt user tidak ada
      API->>API: VerifyPassword(dummyHash) untuk samakan waktu
      API->>L: RecordFailure
      API-->>C: 401 invalid credentials
    else user ada
      API->>API: VerifyPassword(user.PasswordHash)
      alt password salah
        API->>L: RecordFailure
        API-->>C: 401 invalid credentials
      else password benar dan email terverifikasi
        API->>L: RecordSuccess
        API->>DB: store refresh token
        API-->>C: 200 access + refresh token
      end
    end
  end

Gambar 7. Alur keputusan login: cek lockout, samakan waktu saat user tidak ada, lalu terbitkan sesi.

09

Hands-on Mini Auth Service

Hands-on ini tidak membuat JWT penuh, tetapi membangun fondasi keamanan yang akan dipakai oleh endpoint auth production.

Tambahkan dependency bcrypt

Gunakan modul resmi golang.org/x/crypto/bcrypt, lalu jalankan test.

Buat PasswordHasher

Implementasikan HashPassword dan VerifyPassword seperti kode di section bcrypt.

Buat migration auth

Tambahkan tabel users, refresh_tokens, password_reset_tokens, dan email_verification_tokens.

Tulis test login

Test happy path, password salah, email belum verified, dan terlalu banyak percobaan login.

Terminal
go get golang.org/x/crypto/bcrypt go test ./...
internal/auth/password_test.go
package auth import "testing" func TestPasswordHasher(t *testing.T) { hasher := PasswordHasher{} hash, err := hasher.HashPassword("rahasia-yang-cukup-panjang") if err != nil { t.Fatalf("HashPassword() error = %v", err) } if hash == "rahasia-yang-cukup-panjang" { t.Fatal("hash tidak boleh sama dengan password asli") } if !hasher.VerifyPassword(hash, "rahasia-yang-cukup-panjang") { t.Fatal("VerifyPassword() harus true untuk password benar") } if hasher.VerifyPassword(hash, "password-salah") { t.Fatal("VerifyPassword() harus false untuk password salah") } } func TestPasswordHasherRejectsTooLongPassword(t *testing.T) { hasher := PasswordHasher{} password := "1234567890123456789012345678901234567890123456789012345678901234567890123" _, err := hasher.HashPassword(password) if err == nil { t.Fatal("expected error for password longer than 72 bytes") } }
internal/auth/token_test.go
package auth import "testing" func TestGenerateOpaqueTokenAndHash(t *testing.T) { tokenA, err := GenerateOpaqueToken() if err != nil { t.Fatalf("GenerateOpaqueToken() error = %v", err) } tokenB, err := GenerateOpaqueToken() if err != nil { t.Fatalf("GenerateOpaqueToken() error = %v", err) } if tokenA == tokenB { t.Fatal("token acak tidak boleh sama") } if HashOpaqueToken(tokenA) == tokenA { t.Fatal("hash token tidak boleh sama dengan token asli") } if HashOpaqueToken(tokenA) != HashOpaqueToken(tokenA) { t.Fatal("hash token harus deterministik") } }
💡Test keamanan harus menguji failure path

Auth test yang hanya menguji login sukses belum cukup. Tambahkan password salah, token expired, token dipakai ulang, dan email belum verified.

10

Jebakan Umum dari JS/PHP ke Go

Sebagian bug auth muncul bukan karena Go sulit, tetapi karena kebiasaan dari stack lama dibawa tanpa adaptasi.

Menyimpan password plaintext

Ini fatal. Kolom yang benar adalah password_hash, bukan password.

Memakai SHA untuk password

SHA cepat dan tidak cocok untuk password manusia. Pakai bcrypt atau password hashing modern lain.

Refresh token plaintext

Refresh token harus diperlakukan seperti password sementara, simpan hash-nya saja.

Access token terlalu lama

Access token berumur beberapa hari membuat pencurian token jauh lebih berbahaya.

Error login terlalu detail

Pesan email tidak terdaftar membantu penyerang memetakan akun valid.

Forgot password bocor

Response reset password harus generik dan token reset harus sekali pakai.

IP dari header dipercaya mentah

middleware.RealIP dideprekasi; X-Forwarded-For bisa dipalsukan untuk lolos lockout. Pakai ClientIPFromXFFTrustedProxies lalu GetClientIPAddr.

Membandingkan secret tanpa hash atau constant-time

Token yang disimpan sebagai hash aman dibandingkan via lookup index. Untuk secret yang tidak di-hash, seperti signature HMAC, pakai crypto/subtle.ConstantTimeCompare.

⚠️bcrypt punya batas panjang input

Paket bcrypt Go tidak menerima password lebih dari 72 byte. Validasi panjang berbasis byte, bukan hanya jumlah karakter, karena emoji dan karakter non-ASCII bisa memakai beberapa byte.

🌉Jembatan: auth scaffolding vs keputusan eksplisit

Laravel memberi scaffolding dan default yang nyaman. Go cenderung memberi building block. Keuntungannya, kamu tahu persis kapan token dibuat, di-hash, expired, dicabut, dan dicatat.

flowchart TD
  A["Password masuk dari form"] --> B{"Register atau reset?"}
  B -->|ya| C["Hash dengan bcrypt"]
  C --> D[("users.password_hash")]
  E["Refresh token dibuat acak"] --> F["Hash dengan SHA-256"]
  F --> G[("refresh_tokens.token_hash")]
  H["Reset token dibuat acak"] --> I["Hash dengan SHA-256"]
  I --> J[("password_reset_tokens.token_hash")]

Gambar 8. Password manusia dan token acak sama-sama rahasia, tetapi strategi hash-nya berbeda karena sumber entropinya berbeda.

11

Ringkasan & Poin Penting

Di Roadmap 7, auth mulai diperlakukan sebagai lapisan keamanan production, bukan hanya fitur login.

Yang wajib menempel

  • Password user disimpan sebagai password_hash memakai golang.org/x/crypto/bcrypt, bukan MD5, SHA, atau plaintext.
  • Access token pendek, misalnya 15 menit, mengurangi dampak token bocor.
  • Refresh token lebih panjang, misalnya 7 hari, tetapi harus disimpan sebagai hash, punya expiry, bisa dicabut, dan idealnya dirotasi.
  • Email verification mencegah akun memakai email yang tidak terbukti milik user.
  • Forgot password harus memakai response generik, token acak, hash token di database, expiry pendek, dan pemakaian sekali saja.
  • Rate limiting login perlu counter per IP dan email, sedangkan chi/middleware.Throttle hanya pagar concurrency global.
  • IP client harus dibaca dengan aman: middleware.RealIP dideprekasi, pakai ClientIPFromXFFTrustedProxies lalu GetClientIPAddr agar lockout tidak bisa dilewati lewat X-Forwarded-For palsu.
  • Samakan waktu respons saat user tidak ada (bcrypt dummy) dan saat forgot password, supaya tidak ada email enumeration lewat timing.
  • Dalam proyek online shop skincare, auth melindungi alamat customer, cart, order, pembayaran, dan akses admin.

Pada langkah berikutnya di Roadmap 7, fondasi ini akan dipakai untuk authorization: middleware yang membaca access token, memasukkan user ke context.Context, lalu membedakan customer biasa dan admin backoffice.

Progress disimpan lokal di browser ini.