OTP Email di Go: Kode Sekali Pakai, Kedaluwarsa, dan Perlindungan Replay

OTP email terlihat sederhana: buat enam digit, kirimkan, lalu bandingkan dengan input pengguna. Namun, batas keamanannya bukan berada pada API email. Bagian pentingnya adalah lifecycle challenge di sisi server.

Implementasi yang benar harus membuat kode sulit ditebak, membatasi masa berlakunya, membatasi tebakan, menonaktifkan challenge lama ketika diperlukan, dan memastikan kode yang sudah berhasil dipakai tidak dapat dikonsumsi untuk kedua kalinya. Properti ini berbeda dari TOTP, meskipun keduanya sering disebut OTP.

OTP email bukan TOTP

Library seperti github.com/pquerna/otp mengimplementasikan HOTP dan TOTP. TOTP menurunkan kode dari shared secret dan waktu saat ini. Server dan authenticator dapat menghitung nilai yang sama secara independen; tidak ada kode yang perlu dikirim untuk setiap percobaan autentikasi.

OTP email biasanya bekerja dengan cara berbeda. Server membuat challenge acak, menyimpan state di sisi server, lalu mengirim plaintext code melalui provider email.

TOTP
shared secret + time
        |
        v
code dihitung secara independen oleh client dan server

OTP email
server membuat random code
        |
        +--> menyimpan state verifikasi
        |
        +--> mengirim code melalui email

Karena itu, package TOTP tidak diperlukan untuk membuat kode verifikasi email. crypto/rand dari Go sudah menyediakan primitive randomness yang dibutuhkan.

Membuat enam digit dengan crypto/rand

Kode desimal enam digit memiliki 1.000.000 kemungkinan, termasuk nilai dengan nol di depan. Ambil angkanya dari sumber yang cryptographically secure lalu format menjadi tepat enam digit.

package otp

import (
    "crypto/rand"
    "fmt"
    "math/big"
)

func GenerateCode() (string, error) {
    n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
    if err != nil {
        return "", err
    }

    return fmt.Sprintf("%06d", n.Int64()), nil
}

Jangan gunakan math/rand untuk kode autentikasi. Package itu ditujukan untuk simulasi dan kebutuhan non-keamanan, bukan untuk menghasilkan nilai yang harus sulit diprediksi penyerang.

Enam digit memang menyediakan entropy terbatas. Pertahanan terhadap tebakan online karena itu sangat bergantung pada rate limit dan batas percobaan, bukan hanya panjang kode.

Simpan challenge, bukan sekadar email dan kode

Perlakukan setiap penerbitan OTP sebagai challenge autentikasi yang memiliki identitas dan lifecycle sendiri.

type Challenge struct {
    ID          string
    UserID      string
    Purpose     string
    CodeHash    []byte
    ExpiresAt   time.Time
    Attempts    int
    MaxAttempts int
    ConsumedAt  *time.Time
}

Purpose mencegah kode yang diterbitkan untuk satu operasi diam-diam berlaku untuk operasi lain. Nilainya dapat membedakan login, verifikasi email, pemulihan password, atau tindakan akun yang sensitif.

Challenge ID juga menghilangkan ambiguitas ketika beberapa kode pernah diminta. Browser dapat membawa challenge ID yang opaque, sedangkan email hanya membawa kode pendek yang harus diketik pengguna.

Simpan hash kode

Provider email membutuhkan plaintext code, tetapi verification store tidak membutuhkannya. Hash kode sebelum challenge disimpan.

Karena ruang pencarian kode enam digit sangat kecil, hash biasa tanpa key mudah dienumerasi jika database bocor. HMAC dengan secret key memungkinkan aplikasi menyimpan secret tambahan di luar database challenge.

package otp

import (
    "crypto/hmac"
    "crypto/sha256"
)

func CodeMAC(key []byte, challengeID, code string) []byte {
    mac := hmac.New(sha256.New, key)
    mac.Write([]byte(challengeID))
    mac.Write([]byte{0})
    mac.Write([]byte(code))
    return mac.Sum(nil)
}

Mengikat MAC ke challenge ID mencegah verifier yang tersimpan pada satu challenge dipindahkan begitu saja ke record challenge lain.

Plaintext code sebaiknya hanya hidup selama diperlukan untuk diberikan ke mail-sending layer. Jangan mencatatnya di log, analytics event, atau mengembalikannya dalam API response.

Kedaluwarsa harus ditegakkan server

Email dapat menulis bahwa kode berlaku lima menit, tetapi kalimat tersebut tidak memberi jaminan keamanan. Server harus memeriksa timestamp.

if !now.Before(challenge.ExpiresAt) {
    return ErrExpired
}

Gunakan masa berlaku yang cukup pendek untuk membatasi peluang replay, tetapi tetap memberi waktu bagi pengiriman email normal. Nilai persisnya merupakan keputusan produk; properti yang penting adalah deadline dimiliki dan ditegakkan secara konsisten oleh server.

Storage dengan dukungan TTL dapat membersihkan record lama secara otomatis, tetapi TTL tidak seharusnya menjadi satu-satunya pemeriksaan kedaluwarsa. Penghapusan dapat berlangsung asynchronous. Jalur verifikasi tetap perlu membandingkan waktu saat ini dengan ExpiresAt.

Batas tebakan melekat pada challenge

Kode enam digit tidak aman jika endpoint menerima percobaan tanpa batas. Naikkan attempt counter setiap kali verifikasi gagal dan tolak challenge setelah jatahnya habis.

if challenge.Attempts >= challenge.MaxAttempts {
    return ErrTooManyAttempts
}

Increment harus concurrency-safe. Dua request bersamaan tidak boleh membaca attempt count yang sama lalu saling menimpa update.

Rate limiting juga diperlukan di atas level challenge. Boundary yang berguna antara lain akun, alamat email tujuan, source IP atau network, dan endpoint itu sendiri. Pembatasan ini menekan brute-force sekaligus mencegah provider email disalahgunakan sebagai relay pengiriman pesan.

Verifikasi berhasil harus mengonsumsi kode secara atomik

Memeriksa kode dan menandainya sudah dipakai dalam dua operasi database terpisah membuka replay race.

Urutan berikut tidak aman:

request A: code valid
request B: code valid
request A: tandai consumed
request B: tandai consumed

Kedua request dapat lolos sebelum salah satunya menulis consumed state.

Verifikasi harus membuat state transition penentu secara atomik. Pada relational database, ini dapat dilakukan dengan conditional update di dalam transaction atau satu update dengan predicate yang mewajibkan challenge belum dikonsumsi dan belum kedaluwarsa.

Secara konseptual:

UPDATE otp_challenges
SET consumed_at = CURRENT_TIMESTAMP
WHERE id = ?
  AND consumed_at IS NULL
  AND expires_at > CURRENT_TIMESTAMP;

Aplikasi tetap harus memverifikasi kode yang dikirim, tetapi final consume operation harus berhasil agar autentikasi dianggap berhasil. Jika request lain sudah mengonsumsi challenge lebih dulu, request kedua ditolak.

Redis dapat mempertahankan properti yang sama melalui operasi atomik atau Lua script. Teknologi storage bukan bagian terpenting; invariant yang harus dijaga adalah konsumsi tunggal.

Resend membutuhkan aturan yang eksplisit

Tombol resend menambahkan state transition lain. Ada dua desain yang masuk akal: kirim ulang active code yang sama, atau terbitkan challenge baru dan invalidasi challenge sebelumnya.

Menerbitkan kode baru lebih mudah dianalisis jika hanya satu active challenge yang diizinkan untuk setiap user dan purpose:

challenge 1 diterbitkan
        |
user meminta resend
        |
challenge 1 diinvalidasi
challenge 2 diterbitkan
        |
hanya challenge 2 yang dapat berhasil

Tanpa aturan eksplisit, beberapa kode dapat tetap valid bersamaan. Kondisi itu memperbesar jumlah tebakan yang dapat diajukan penyerang dan membingungkan pengguna ketika email yang terlambat datang tidak sesuai urutan.

Provider email adalah dependency pengiriman

SMTP, Amazon SES, Alibaba Cloud DirectMail, Mailgun, dan layanan sejenis tidak mengubah model verifikasi OTP. Provider mengangkut pesan; aplikasi tetap bertanggung jawab atas pembuatan dan validasi challenge.

Untuk trafik OTP, bandingkan provider berdasarkan lebih dari harga nominal per seribu email. Delivery latency, bounce handling, reliabilitas API, ketersediaan regional, domain authentication, rate limit, dan observability langsung memengaruhi jalur autentikasi.

Kegagalan provider juga memengaruhi desain API. Aplikasi sebaiknya tidak meninggalkan challenge yang dapat dipakai tanpa batas setelah permanent send failure. Di sisi lain, retry pengiriman tidak boleh tanpa sengaja membuat kode valid baru pada setiap transport retry.

Memisahkan penerbitan challenge dari delivery membuat boundary ini lebih jelas:

type Mailer interface {
    SendOTP(ctx context.Context, email, code string) error
}

Package autentikasi dapat mengelola state challenge, sedangkan mail adapter menangani SES, DirectMail, SMTP, atau provider lain.

Hindari account enumeration

Endpoint seperti POST /auth/email-otp dapat membocorkan keberadaan akun jika responsnya terlihat berbeda.

Untuk flow login atau recovery, public response dapat tetap generik:

{
  "message": "Jika alamat dapat menerima kode, email telah dikirim."
}

Secara internal, service tetap dapat membedakan akun yang tidak ada, akun yang diblokir, kegagalan provider, dan rate-limit event untuk kebutuhan operasional. Perbedaan timing dan response publik sebaiknya tidak berubah menjadi API untuk menemukan akun yang terdaftar.

Service boundary yang ringkas

API Go tetap dapat dibuat kecil meskipun implementasinya menegakkan beberapa invariant.

type Service interface {
    Issue(ctx context.Context, userID, email, purpose string) (challengeID string, err error)
    Verify(ctx context.Context, challengeID, code string) error
}

Issue membuat kode, menyimpan verifier dan deadline, lalu meminta mailer mengirimkannya. Verify mengambil challenge, memeriksa purpose dan state, mencatat attempt, membandingkan verifier yang dikirim menggunakan hmac.Equal, lalu mengonsumsi challenge yang berhasil secara atomik.

Pemisahan ini lebih berguna daripada mengikat logika OTP langsung ke SMTP client. Provider email dapat diganti tanpa mengubah kontrak autentikasi.

Properti keamanan berada pada state transition

Membuat enam digit acak adalah bagian terkecil dari OTP email. Jaminan yang berarti justru berasal dari transisi di sekitar kode tersebut:

diterbitkan
  |
  +--> kedaluwarsa
  |
  +--> terkunci setelah percobaan gagal
  |
  +--> digantikan challenge yang lebih baru
  |
  +--> dikonsumsi tepat satu kali

Implementasi Go menjadi kuat ketika setiap jalur berakhir pada state yang terdefinisi dan concurrent request tidak dapat melewati aturan tersebut. Provider email kemudian tetap berada pada perannya: mekanisme delivery yang dapat diganti, bukan komponen yang menentukan correctness autentikasi.