Lesson ini mengurus dua kejadian di sekitar form login. Account recovery: Siti lupa password, membuktikan dia pemilik akun lewat email, lalu memasang password baru. Lockout: setelah terlalu banyak password salah, percobaan berikutnya ditahan sebentar. Bayangin loker sekolah dengan kunci angka. Salah kombinasi lima kali, loker menahan percobaan berikutnya 15 menit, bukan terkunci selamanya, karena kalau begitu anak iseng bisa membuat loker temannya gak bisa dibuka. Lupa kombinasinya? Penjaga memberi kunci sekali pakai ke pemiliknya, kombinasi diganti, dan semua kunci lama langsung gak berlaku.
🧪 Tebakan cepat: dari komputer warnet, seseorang mengetik email Siti dengan password salah lima kali. Semenit kemudian Siti login dari rumah dengan password yang benar. Kalau hitungan kegagalannya per
email|IP, Siti bisa masuk atau gak? Tebak dulu, jawabannya ada di bagian "Lockout: jeda sementara per email dan IP".
Lupa password: alurnya
Ini percakapan HTTP di Portal Sekolah, persis yang ditampilkan Lab (token di Lab acak):
POST /forgot-password HTTP/1.1
Host: portal.sekolah.test
{"email": "[email protected]"}
HTTP/1.1 200 OK
{"message": "Kalau email itu terdaftar, link reset password sudah dikirim."}
POST /reset-password HTTP/1.1
Host: portal.sekolah.test
{"token": "BIrrdlGOeVSVLLnsip3iOl4JlDLm9xGZVTNnygSkRCY", "email": "[email protected]", "password": "Kelas11-IPA2-aman"}
HTTP/1.1 200 OK
{"message": "Password sudah diganti. Silakan login lagi."}
- Token reset dibuat dengan pola yang sama persis dengan lesson Magic Link Authentication: 32 byte acak dari
crypto.getRandomValues, base64url di link email, dan yang disimpan cuma SHA-256-nya. Bedanya umurnya: 60 menit, karena orang butuh waktu memikirkan password baru. - Jawaban
POST /forgot-passwordselalu sama, email terdaftar atau gak. Token yang gak ketemu, sudah dipakai, atau kedaluwarsa dijawab422dengan satu pesan: "Link reset sudah gak berlaku. Minta link baru."
Setelah reset: cabut semua sesi, kabari pemiliknya
async function resetPassword(token, newPassword, now) {
const row = resetTokens.get(await sha256Hex(token));
if (!row || row.usedAt !== null || now >= row.expiresAt) return 422;
row.usedAt = now; // sekali pakai
const user = users.get(row.userId);
user.passwordHash = await hashPassword(newPassword); // lesson Password Hashing
for (const [id, session] of sessions) {
if (session.userId === user.id) sessions.delete(id); // semua perangkat keluar
}
sendEmail(user.email, "Password Portal Sekolah-mu baru saja diganti. Bukan kamu? Hubungi admin sekolah.");
return 200;
}
sha256Hexdan tiga pengecekannya sama denganverifyLinkdi lesson Magic Link Authentication.hashPasswordadalah hash lambat dengan salt dari lesson Password Hashing.- Loop
sessionsmenghapus semua sesi Siti. Siti punya tiga, di laptop, HP, dan komputer lab (sama seperti di lesson Auth Best Practices). Setelah reset: nol. Kalau ada orang yang sudah login dengan password lama, mengganti password saja gak mengusirnya, karena sesinya masih berlaku. Kalau Portal Sekolah memakai refresh token, cabut semuanya juga (lesson Refresh Token Rotation). sendEmailmengabari pemiliknya. Kalau yang mereset ternyata bukan Siti, dia tahu saat itu juga dan bisa melapor.
Lockout: jeda sementara per email dan IP
Cara paling gampang, "5 kali salah, akun dikunci sampai admin membukanya", punya masalah besar: siapa pun yang tahu email Siti bisa membuat Siti gak bisa login, cukup dengan lima password salah. Dua perbaikannya:
| Pilihan | Yang ditahan | Kapan terbuka |
|---|---|---|
| Kunci permanen per akun | akun Siti, dari mana pun | kalau admin membukanya |
Jeda per email|IP |
pasangan email Siti dan IP yang gagal saja | sendiri, 15 menit setelah kegagalannya |
Dengan jeda per email|IP, lima kegagalan dari warnet cuma menahan pasangan [email protected]|198.51.100.23. Siti login dari rumah (203.0.113.7) dengan kunci yang lain, hitungannya kosong, jadi dia bisa masuk. Itu jawaban tebakannya. Dengan kunci permanen per akun, Siti ikut terkunci walau password-nya benar.
Jeda ini throttling: memperlambat, bukan mengunci. Penyerang yang ganti-ganti IP memang dapat jatah baru di tiap IP. Lapisan berikutnya, batas per IP dan captcha setelah beberapa kegagalan, ada di lesson Rate Limiting untuk Autentikasi.
Sliding window: menghitung kegagalan 15 menit terakhir
const WINDOW = 15 * 60 * 1000;
const THRESHOLD = 5;
const failures = new Map(); // "email|ip" -> [waktu gagal, ...]
function recordFailure(key, now) {
if (!failures.has(key)) failures.set(key, []);
failures.get(key).push(now);
}
function isLockedOut(key, now) {
const recent = (failures.get(key) || []).filter((t) => t > now - WINDOW); // 15 menit terakhir
failures.set(key, recent); // yang lama dibuang
return recent.length >= THRESHOLD;
}
function clearFailures(key) {
failures.delete(key); // dipanggil saat login berhasil
}
function login(email, password, ip, now) {
const key = `${email.toLowerCase()}|${ip}`;
if (isLockedOut(key, now)) return 429; // password-nya gak diperiksa sama sekali
if (!checkPassword(email, password)) {
recordFailure(key, now); // dicatat untuk email apa pun, terdaftar atau gak
return 401;
}
clearFailures(key);
return 200;
}
- Sliding window: yang dihitung cuma kegagalan 15 menit terakhir, mundur dari sekarang. Kegagalan lama keluar satu per satu, jadi kuncinya terbuka sendiri, dan Siti yang salah 4 kali lalu istirahat sejam mulai dari nol lagi. Fixed counter (penghitung yang terus naik) gak pernah lupa: kegagalan bulan lalu ikut dihitung, dan akunnya terkunci sampai ada yang mereset.
>= THRESHOLD: kegagalan kelima sudah mengunci. Percobaan keenam dijawab429tanpa memeriksa password.
Jejak dari Lab:
| Jam | Dari | Kejadian | Gagal di 15 menit terakhir | Jawaban |
|---|---|---|---|---|
| 07.00 | warnet | password salah ke-1 sampai ke-5 | 1 sampai 5 | 401 |
| 07.01 | warnet | coba lagi | 5 | 429, Retry-After: 840 |
| 07.02 | rumah Siti | password benar | 0 (kunci lain) | 200 |
| 07.15 | warnet | coba lagi | 0, semuanya keluar jendela | password diperiksa lagi |
Retry-After: 840 artinya 840 detik, alias 14 menit: kegagalan pukul 07.00 keluar dari jendela pukul 07.15.
Di latihan, nama method-nya recordFailure(userId, now), isLockedOut(userId, now), dan clearFailures(userId), dengan kunci seperti 'u1', dan waktunya angka milidetik. Kuncinya boleh string apa saja: di aplikasi sungguhan isinya email|ip. Ambangnya 5, jendelanya 15 * 60 * 1000.
Pesan yang gak membocorkan
| Situasi | Jawaban yang sama untuk semua |
|---|---|
| Lupa password, email terdaftar atau gak | 200 "Kalau email itu terdaftar, link reset password sudah dikirim." |
| Login gagal, email salah atau password salah | 401 "Email atau password salah" |
| Terlalu banyak gagal | 429 "Terlalu banyak percobaan login. Coba lagi dalam 14 menit." |
Jawaban "Email tidak terdaftar" memberi tahu siapa saja email mana yang punya akun di Portal Sekolah. Lockout juga dihitung untuk email yang gak terdaftar. Kalau 429 cuma muncul untuk akun yang ada, jawaban itu sendiri yang membocorkan.
Di Laravel
// POST /forgot-password: satu jawaban, apa pun status-nya
Password::sendResetLink($request->only('email'));
return response()->json(['message' => 'Kalau email itu terdaftar, link reset password sudah dikirim.']);
// POST /reset-password
Password::reset($request->only('email', 'password', 'password_confirmation', 'token'), function (User $user, string $password) {
$user->forceFill(['password' => Hash::make($password)])->setRememberToken(Str::random(60));
$user->save();
DB::table('sessions')->where('user_id', $user->id)->delete(); // semua perangkat keluar
$user->notify(new PasswordChanged); // Notification buatanmu
});
// Login: jeda per email|IP
$key = Str::lower($request->input('email')).'|'.$request->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
$seconds = RateLimiter::availableIn($key);
throw ValidationException::withMessages(['email' => "Terlalu banyak percobaan login. Coba lagi dalam {$seconds} detik."])->status(429);
}
if (! Auth::attempt($request->only('email', 'password'))) {
RateLimiter::hit($key, 15 * 60);
throw ValidationException::withMessages(['email' => 'Email atau password salah']);
}
RateLimiter::clear($key);
- Contoh di dokumentasi Laravel menampilkan pesan status-nya, dan untuk email tak terdaftar bunyinya "We can't find a user with that email address." Jangan ditampilkan: jawab dengan satu pesan.
- Token reset Laravel ada di tabel
password_reset_tokens, satu baris per email, kolomtoken-nya hash bcrypt. Umurnya 60 menit (expirediconfig/auth.php), dan barisnya dihapus setelah reset berhasil. setRememberToken(Str::random(60))mematikan cookie "remember me" yang lama. Menghapus barissessionsmilik user (session driverdatabase) mengeluarkan semua perangkat.RateLimiter::hit($key, 15 * 60)memulai jendela 15 menit di kegagalan pertama, lalu hitungannya hilang sekaligus saat jendela itu habis. Mirip sliding window, tapi kegagalannya gak keluar satu per satu.
⚠️ Jebakan yang sering ketemu
- Kunci permanen per akun. Lima password salah dari siapa pun membuat pemilik akun gak bisa masuk. Pakai jeda sementara per
email|IP. - Reset tanpa mencabut sesi. Orang yang sudah login dengan password lama tetap login. Hapus semua sesi (dan refresh token) user itu.
- Pesan atau status yang beda untuk email tak terdaftar, di lupa password maupun di lockout.
- Jalan pintas recovery yang lemah, seperti pertanyaan "nama hewan peliharaan pertama". Jawabannya sering bisa ditebak dari media sosial. Pakai email, dan untuk akun yang memakai MFA, recovery code dari lesson Multi-factor Authentication.
TL;DR: Lupa password: token acak 32 byte, simpan SHA-256-nya, 60 menit, sekali pakai, dan jawaban /forgot-password yang sama untuk semua email. Setelah reset: hash password baru, cabut semua sesi user di semua perangkat, dan kirim email pemberitahuan. Lockout: jangan kunci permanen per akun, karena siapa pun bisa mengunci pemiliknya. Tahan sementara per email|IP dengan sliding window: 5 kegagalan dalam 15 menit terakhir berarti 429 dengan Retry-After, kegagalan lama keluar sendiri, dan login berhasil menghapus hitungannya. Pesan selalu sama: "Email atau password salah". Di Laravel: Password::sendResetLink, Password::reset, dan RateLimiter::tooManyAttempts, hit, clear.