Account Recovery & Lockout — Auth

Dua mekanisme yang sering diabaikan padahal vektor serangan utama: lockout (anti-brute-force) dan recovery (user lupa password). Account Lockout — mitigasi…

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."}
  1. 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.
  2. Jawaban POST /forgot-password selalu sama, email terdaftar atau gak. Token yang gak ketemu, sudah dipakai, atau kedaluwarsa dijawab 422 dengan 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;
}

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;
}

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);

⚠️ Jebakan yang sering ketemu

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.

Yang akan kamu pelajari