Alur reset password sering menjadi titik masuk termudah untuk skema account takeover (ATO). Kelemahan struktural yang sering ditemukan meliputi token yang dapat ditebak, kebocoran token akibat penyimpanan plaintext di database, race condition saat token dikonsumsi, serta kebocoran status registrasi user melalui respons API.

Panduan ini merinci hardening alur reset password tingkat backend: dari pembangkitan token kriptografis, penyimpanan aman, eksekusi transaksi atomik, penanganan anti-abuse, hingga invalidasi sesi.

Arsitektur Token: Entropi, Hash, dan Siklus Hidup

Token reset berfungsi sebagai kredensial autentikasi sementara dengan hak akses setara password penuh. Perlakukan token ini dengan tingkat keamanan yang sama dengan password.

1. Pembangkitan Token Kriptografis

Gunakan generator bilangan acak semu yang aman secara kriptografis (CSPRNG). Jangan menggunakan fungsi acak standar runtime (seperti Math.random() atau rand()) karena state internalnya dapat diprediksi.

  • Panjang token minimum: 32 byte (256 bit entropi).

  • Encoding: Hexadecimal atau Base64URL-safe.

2. Simpan Hash, Kirim Raw Token

Penyimpanan raw token di database menimbulkan risiko fatal jika snapshot database bocor atau terjadi SQL Injection. Simpan representasi hash dari token menggunakan algoritma cepat satu arah seperti SHA-256. Argon2 atau bcrypt tidak diwajibkan di sini karena token 256-bit acak sudah memiliki entropi yang mustahil diserang dengan brute force / rainbow table.

Pola Kerja:
1. Generate raw_token = CSPRNG(32)
2. Hitung token_hash = SHA256(raw_token)
3. Simpan token_hash, user_id, dan expires_at ke database.
4. Kirim raw_token melalui email (sebagai query parameter link reset).

3. Siklus Hidup dan Invalidasi Proaktif

  • Masa Berlaku Singkat (TTL): Batasi waktu kedaluwarsa antara 10 hingga 15 menit.

  • Invalidasi Token Lama: Setiap kali user meminta reset password baru, semua token reset aktif milik user tersebut harus ditandai invalid (atau dihapus) sebelum token baru diterbitkan.

Pencegahan Enumerasi Akun dan Anti-Abuse

Respons Seragam (Uniform Response)

Penyerang dapat menggunakan endpoint reset password untuk memetakan alamat email valid di sistem (user enumeration). Cegah ini dengan mengembalikan HTTP status dan body yang identik, baik email terdaftar maupun tidak.

Mitigasi juga perbedaan latensi respon (timing attack). Jika email tidak ditemukan, lakukan simulasi hash beban kerja dummy untuk menyamakan waktu proses kalkulasi server.

Rate Limiting Berlapis Tanpa Membuka Celah DoS

Membatasi reset password berdasarkan akun dapat dieksploitasi penyerang untuk mengunci akun target (Denial of Service). Terapkan strategi pembatasan bertingkat:

  • Rate limit per IP: Maksimal 5 permintaan reset per IP setiap 15 menit. Ini memitigasi serangan spraying.

  • Rate limit per Email/Akun: Batasi pengiriman email keluar (misal maksimal 3 permintaan per jam per alamat). Namun, respons API tetap mengembalikan sukses palsu ke pemanggil untuk mencegah enumerasi sekaligus menghindari lock out yang disengaja oleh pihak ketiga.

  • CAPTCHA Adaptif: Munculkan tantangan Cloudflare Turnstile atau reCAPTCHA v3 jika lalu lintas dari IP atau subnet tertentu menunjukkan anomali.

Implementasi Alur Kerja (Pseudocode)

Skema Database

CREATE TABLE password_reset_tokens (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash VARCHAR(64) NOT NULL UNIQUE,
    expires_at TIMESTAMPTZ NOT NULL,
    used_at TIMESTAMPTZ NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tokens_lookup ON password_reset_tokens (token_hash, used_at, expires_at);

1. Endpoint Request: Permintaan Reset

import crypto from 'crypto';

async function handleForgotPassword(req, res) {
    const { email } = req.body;
    const clientIp = req.ip;

    // 1. Cek rate limit IP
    await rateLimiter.consume(`reset_ip:${clientIp}`);

    const user = await db.findUserByEmail(email);

    if (user) {
        // Cek batasan throttling pengiriman per akun
        const canSendEmail = await rateLimiter.checkAccountAllowance(user.id);

        if (canSendEmail) {
            // Invalidasi token lama yang belum digunakan
            await db.query(
                `UPDATE password_reset_tokens 
                 SET used_at = NOW() 
                 WHERE user_id = $1 AND used_at IS NULL`,
                [user.id]
            );

            // Generate raw token dan hash
            const rawToken = crypto.randomBytes(32).toString('hex');
            const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
            const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 menit

            await db.query(
                `INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
                 VALUES ($1, $2, $3)`,
                [user.id, tokenHash, expiresAt]
            );

            await queue.dispatch('sendResetEmail', {
                email: user.email,
                resetLink: `https://app.example.com/reset-password?token=${rawToken}`
            });
        }
    } else {
        // Dummy delay untuk menetralkan timing attack
        await crypto.subtle.digest('SHA-256', new Uint8Array(32));
    }

    // 2. Selalu kembalikan respons generik
    return res.status(200).json({
        message: 'Jika alamat email terdaftar di sistem kami, instruksi pemulihan telah dikirim.'
    });
}

2. Endpoint Confirm: Konsumsi Token Secara Atomik

Risiko race condition (pembacaan ganda sebelum status diubah) dihindari melalui satu operasi UPDATE kondisional yang mengembalikan data hasil secara atomik.

async function handleConfirmReset(req, res) {
    const { token, newPassword } = req.body;

    if (!token || typeof token !== 'string' || newPassword.length < 12) {
        return res.status(400).json({ error: 'Input tidak valid.' });
    }

    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

    // Transaksi database
    const client = await db.connect();
    try {
        await client.query('BEGIN');

        // Konsumsi token secara atomik. Jika token expired/sudah terpakai, rowCount = 0.
        const tokenResult = await client.query(
            `UPDATE password_reset_tokens
             SET used_at = NOW()
             WHERE token_hash = $1 
               AND used_at IS NULL 
               AND expires_at > NOW()
             RETURNING user_id`,
            [tokenHash]
        );

        if (tokenResult.rowCount === 0) {
            await client.query('ROLLBACK');
            return res.status(400).json({ error: 'Token tidak valid atau telah kedaluwarsa.' });
        }

        const userId = tokenResult.rows[0].user_id;

        // Hash password baru (Argon2id/bcrypt)
        const passwordHash = await hashPassword(newPassword);

        // Update password & increment token_version (invalidasi JWT)
        await client.query(
            `UPDATE users 
             SET password_hash = $1, 
                 token_version = token_version + 1,
                 updated_at = NOW() 
             WHERE id = $2`,
            [passwordHash, userId]
        );

        // Hapus session server-side jika menggunakan Redis
        await redisSessionStore.destroyAllUserSessions(userId);

        await client.query('COMMIT');

        // Kirim notifikasi keamanan & tulis audit log
        await queue.dispatch('sendPasswordChangedAlert', { userId });
        await auditLog.logSecurityEvent({
            userId,
            action: 'PASSWORD_RESET_SUCCESS',
            ip: req.ip,
            userAgent: req.headers['user-agent']
        });

        return res.status(200).json({ message: 'Password berhasil diperbarui. Silakan login kembali.' });
    } catch (error) {
        await client.query('ROLLBACK');
        return res.status(500).json({ error: 'Terjadi kesalahan sistem.' });
    } finally {
        client.release();
    }
}

Pencabutan Sesi dan Notifikasi Pengguna

Menyetel ulang password tanpa membatalkan sesi yang berjalan membiarkan penyerang yang telah memiliki akses tetap bertahan di akun target.

Kebijakan Pencabutan Sesi (Session Revocation)

  • Stateful Session: Hapus seluruh session ID yang diasosiasikan dengan user ID dari store Redis/database.

  • Stateless JWT: Gunakan kolom token_version pada tabel user. Sematkan versi ini di dalam payload JWT. Ketika token diverifikasi di middleware, bandingkan nilai di payload dengan versi yang ada di database/cache. Penambahan nilai token_version secara otomatis mendiskualifikasi seluruh JWT lama.

Notifikasi Keamanan

Kirimkan notifikasi instan melalui email segera setelah password berhasil diganti. Jangan pernah mengirimkan password baru dalam email. Email harus berisi:

  • Waktu perubahan dalam zona waktu yang tepat.

  • Detail perangkat dan perkiraan lokasi (IP/User Agent).

  • Tautan langsung untuk membekukan akun jika user tidak mengenali aktivitas tersebut.

Sanitasi Audit Log

Data audit berguna untuk post-incident response, namun pencatatan data rahasia dapat menyebabkan pelanggaran sekunder.

  • DILARANG dicatat: Raw reset token, password baru (plaintext maupun hash), token auth/bearer.

  • WAJIB dicatat: ID aksi (misal: RESET_REQ, RESET_CONFIRM), User ID target, status (sukses/gagal), IP address pemohon, dan User Agent.

Pengujian Keamanan (Security Test Cases)

  1. Race Condition / Concurrency Test: Kirim 10 request konkuren menggunakan HTTP runner ke endpoint konfirmasi dengan raw token yang sama persis. Pastikan hanya 1 request yang menghasilkan HTTP 200, sedangkan 9 lainnya gagal dengan HTTP 400.

  2. Replay Attack Test: Gunakan kembali token yang telah sukses dikonsumsi pada panggilan berikutnya. Verifikasi sistem menolak token yang memiliki used_at IS NOT NULL.

  3. Expired Token Test: Manipulasi kolom expires_at di database menjadi 1 detik di masa lalu. Pastikan sistem mengembalikan error kedaluwarsa.

  4. Timing Enumeration Analysis: Eksekusi benchmark 500 request untuk email yang valid dan 500 request untuk email acak yang tidak terdaftar. Lakukan uji statistik untuk memastikan deviasi standar respon time antar keduanya tidak signifikan.