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.
Password dan Auth Security
untuk Backend Skincare
Login yang aman bukan hanya cocokkan email dan password, tetapi rangkaian proteksi dari hash sampai recovery akun.
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.
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.
Proses membuktikan identitas user, misalnya email dan password benar, lalu server memberi token sesi.
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.
Modul ini merujuk ke dokumentasi bcrypt Go, catatan Go 1.26, middleware chi, dan cheat sheet OWASP untuk password storage serta forgot password.
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.
- Didisain cepat, sehingga brute force jadi murah.
- Tidak punya cost factor bawaan untuk memperlambat verifikasi.
- Sering terlihat sederhana, tetapi tidak layak untuk password production.
- 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.
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.gopackage 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 }
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.
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.
- 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.
- Tersedia di
golang.org/x/crypto/argon2lewatargon2.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.sqlCREATE 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() );
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.
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.
Handler menerima email dan password, service melakukan normalisasi email, validasi dasar, lalu membuat password_hash.
Server membuat token acak, menyimpan hash token di database, lalu mengirim link ke email user.
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.sqlCREATE 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);
Token verifikasi mirip kunci sementara. Kalau database bocor dan token tersimpan plaintext, penyerang bisa memverifikasi akun orang lain.
internal/auth/verification.gopackage 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 }
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.gopackage 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 }
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.
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.
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.
Token pendek yang dikirim client untuk membuktikan sesi aktif saat memanggil endpoint API.
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.gopackage 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 }
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.
Access token di memory dan refresh token di cookie HttpOnly, Secure, dan SameSite sering lebih aman daripada menyimpan semua token di localStorage.
- Mudah dibaca oleh kode React.
- Juga mudah dicuri bila ada XSS.
- Sering dipakai untuk demo, bukan selalu cocok untuk production.
- Cookie HttpOnly tidak bisa dibaca JavaScript.
- Token pendek membatasi masa pakai credential yang bocor.
- Logout dan rotasi refresh token tetap butuh storage server-side.
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.sqlCREATE 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.gopackage 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) }
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 --> ClientGambar 4. Refresh token asli tidak pernah disimpan sebagai plaintext di database.
remember_token ala framework sering terlalu longgar bila tidak punya expiry, revoke, dan hash storage yang jelas.
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.
- Membatasi request yang sedang berjalan pada satu titik middleware.
- Berguna untuk backpressure global.
- Tidak otomatis membatasi percobaan login per email.
- Menghitung gagal login per IP dan email.
- Bisa dipakai lintas instance API.
- Cocok untuk lockout pendek dan progressive delay.
internal/auth/login_limiter.gopackage 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 }
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.gopackage 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) }
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).
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.gopackage 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 }
middleware.Throttle(100) hanya membatasi request yang sedang aktif, bukan menghitung lima password salah untuk email yang sama.
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.
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.
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.
POST /v1/auth/forgot-password menerima email dan selalu membalas sukses generik.
Bila email terdaftar, server membuat token acak, menyimpan hash token, lalu mengirim link reset.
POST /v1/auth/reset-password menerima token dan password baru, lalu server validasi hash token.
Password di-hash ulang dengan bcrypt, token reset ditandai used, dan refresh token user dicabut.
migrations/202606060704_create_password_reset_tokens.sqlCREATE 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.gopackage 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 }
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.
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.
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.
Endpoint dan Struktur Modul Auth
Auth perlu batas yang jelas antara handler HTTP, service bisnis, repository database, dan integrasi email.
- 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
/v1/auth/register Buat user baru, hash password, dan kirim email verification /v1/auth/verify-email Verifikasi token email sebelum user boleh login /v1/auth/login Verifikasi password, rate limit, lalu terbitkan access token dan refresh token /v1/auth/refresh Rotasi refresh token dan buat access token baru /v1/auth/logout Cabut refresh token aktif /v1/auth/forgot-password Kirim link reset password dengan response generik /v1/auth/reset-password Ganti password memakai token reset sekali pakai Handler Go mirip controller tipis di Laravel. Bedanya, service dan repository tidak otomatis dibuat framework, sehingga boundary harus kamu disiplinkan sendiri.
internal/auth/service.gopackage 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) }
Untuk login, jangan bedakan email tidak ditemukan dan password salah di response client. Detail seperti itu membantu email enumeration.
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
endGambar 7. Alur keputusan login: cek lockout, samakan waktu saat user tidak ada, lalu terbitkan sesi.
Hands-on Mini Auth Service
Hands-on ini tidak membuat JWT penuh, tetapi membangun fondasi keamanan yang akan dipakai oleh endpoint auth production.
Gunakan modul resmi golang.org/x/crypto/bcrypt, lalu jalankan test.
PasswordHasherImplementasikan HashPassword dan VerifyPassword seperti kode di section bcrypt.
Tambahkan tabel users, refresh_tokens, password_reset_tokens, dan email_verification_tokens.
Test happy path, password salah, email belum verified, dan terlalu banyak percobaan login.
Terminalgo get golang.org/x/crypto/bcrypt go test ./...
internal/auth/password_test.gopackage 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.gopackage 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") } }
Auth test yang hanya menguji login sukses belum cukup. Tambahkan password salah, token expired, token dipakai ulang, dan email belum verified.
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.
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.
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.
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_hashmemakaigolang.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.Throttlehanya pagar concurrency global. - IP client harus dibaca dengan aman:
middleware.RealIPdideprekasi, pakaiClientIPFromXFFTrustedProxieslaluGetClientIPAddragar lockout tidak bisa dilewati lewatX-Forwarded-Forpalsu. - 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.