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. Generateraw_token = CSPRNG(32)
2. Hitungtoken_hash = SHA256(raw_token)
3. Simpantoken_hash,user_id, danexpires_atke database.
4. Kirimraw_tokenmelalui 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_versionpada 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 nilaitoken_versionsecara 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)
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.
Replay Attack Test: Gunakan kembali token yang telah sukses dikonsumsi pada panggilan berikutnya. Verifikasi sistem menolak token yang memiliki
used_at IS NOT NULL.Expired Token Test: Manipulasi kolom
expires_atdi database menjadi 1 detik di masa lalu. Pastikan sistem mengembalikan error kedaluwarsa.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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!