Authentication #
Authentication adalah proses memverifikasi identitas — memastikan bahwa entitas yang mengklaim sebagai “Ali” memang benar-benar Ali, bukan orang lain yang berpura-pura. Ini adalah gerbang pertama dari semua sistem keamanan: jika authentication lemah, semua lapisan perlindungan lain bisa dibypass.
Yang membuat authentication menarik sekaligus berbahaya: ia bersinggungan langsung dengan pengalaman pengguna. Sistem yang terlalu ketat membuat user frustrasi — terlalu banyak langkah, terlalu sering diminta login ulang, password requirement yang tidak masuk akal. Sistem yang terlalu longgar membuka celah yang dieksploitasi attacker. Menemukan keseimbangan yang tepat antara keamanan dan usability adalah salah satu tantangan terbesar dalam desain sistem.
Artikel ini membahas authentication dari sudut pandang implementasi: bagaimana membangunnya dengan benar dari awal, jebakan-jebakan yang sering menyebabkan insiden, dan praktik terbaik yang sudah teruji.
Authentication vs Authorization: Perbedaan yang Sering Dibalik #
Sebelum membahas implementasi, penting untuk memahami perbedaan dua konsep yang sering digunakan secara bergantian padahal berbeda secara fundamental.
Authentication (AuthN) — "Siapa kamu?"
Proses memverifikasi identitas pengguna.
Input: credential (password, token, biometrik)
Output: identitas yang terverifikasi (atau gagal)
Contoh: login dengan username dan password
Authorization (AuthZ) — "Apa yang boleh kamu lakukan?"
Proses menentukan hak akses berdasarkan identitas.
Input: identitas yang sudah terverifikasi
Output: daftar operasi yang diizinkan
Contoh: user biasa tidak bisa akses halaman admin
Urutan yang benar:
Request masuk → Authentication → Authorization → Process request
Jika authentication gagal → tolak, tidak perlu cek authorization
Jika authorization gagal → tolak dengan 403 Forbidden
Jika keduanya lulus → proses request
Kesalahan yang sering terjadi: melakukan authorization check tanpa authentication check yang benar, atau mengasumsikan bahwa sudah login berarti sudah punya semua akses.
Password: Fondasi yang Sering Salah Diimplementasikan #
Password masih menjadi mekanisme autentikasi yang paling umum — dan masih sering diimplementasikan dengan cara yang salah.
Hashing Password yang Benar #
# ANTI-PATTERN 1: menyimpan password plaintext
user.password = "userpassword123" # JANGAN PERNAH
# ANTI-PATTERN 2: enkripsi (bukan hashing)
user.password = encrypt(password, SECRET_KEY)
# Enkripsi bisa di-decrypt. Jika SECRET_KEY bocor, semua password bocor.
# ANTI-PATTERN 3: hash tanpa salt — rentan rainbow table attack
import hashlib
user.password = hashlib.sha256(password.encode()).hexdigest()
# Dua user dengan password sama menghasilkan hash sama
# → attacker bisa precompute rainbow table dan langsung match
# ANTI-PATTERN 4: hash cepat — rentan brute force
user.password = hashlib.sha256(password.encode()).hexdigest()
# SHA256 bisa menghitung miliaran hash per detik di GPU modern
# Database 1 juta password bisa di-crack dalam jam jika hash bocor
# BENAR: gunakan algoritma yang dirancang untuk password hashing
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# Konfigurasi Argon2id (rekomendasi OWASP 2023)
ph = PasswordHasher(
time_cost=3, # jumlah iterasi — lebih tinggi = lebih lambat = lebih aman
memory_cost=65536, # 64MB RAM — mencegah GPU/ASIC attack
parallelism=2, # jumlah thread
hash_len=32, # panjang hash output
salt_len=16 # panjang salt (otomatis di-generate)
)
def hash_password(password: str) -> str:
"""Hash password untuk disimpan di database."""
return ph.hash(password)
# Output: $argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash>
# Salt dan parameter disimpan bersama hash — tidak perlu kolom terpisah
def verify_password(stored_hash: str, provided_password: str) -> bool:
"""Verifikasi password saat login."""
try:
ph.verify(stored_hash, provided_password)
return True
except VerifyMismatchError:
return False
def needs_rehash(stored_hash: str) -> bool:
"""Cek apakah hash perlu di-upgrade (parameter berubah)."""
return ph.check_needs_rehash(stored_hash)
Mengapa Argon2id:
Memory-hard: membutuhkan RAM dalam jumlah besar
→ GPU modern punya ribuan core tapi RAM yang terbatas per core
→ Memory requirement membuat GPU attack jauh lebih mahal
Time-cost: setiap hash butuh ~100-300ms (dikonfigurasi)
→ Server masih bisa handle ratusan login per detik
→ Attacker yang mencoba brute force: hanya bisa mencoba
ratusan/ribuan kombinasi per detik (vs miliaran dengan MD5)
Perbandingan kecepatan hashing (GPU RTX 4090):
MD5: 100+ miliar hash/detik → password 8 char habis dalam detik
SHA256: 10+ miliar hash/detik
bcrypt: ~10 juta hash/detik
Argon2: ~100 ribu hash/detik → miliaran kali lebih lambat dari MD5
Password Policy yang Masuk Akal #
Password policy yang terlalu ketat tidak membuat sistem lebih aman — user akan menulis password di sticky note atau menggunakan pola yang mudah ditebak.
# Password policy berdasarkan NIST SP 800-63B (lebih baru dan lebih baik)
MINIMUM_LENGTH = 12 # panjang minimum, bukan maksimum
MAXIMUM_LENGTH = 128 # ada maksimum untuk mencegah DoS via hash computation
PWNED_API_URL = "https://api.pwnedpasswords.com/range/{}"
def validate_password(password: str) -> tuple[bool, list[str]]:
errors = []
# 1. Panjang minimum
if len(password) < MINIMUM_LENGTH:
errors.append(f"Password minimal {MINIMUM_LENGTH} karakter")
# 2. Panjang maksimum
if len(password) > MAXIMUM_LENGTH:
errors.append(f"Password maksimal {MAXIMUM_LENGTH} karakter")
# 3. Cek di database password yang diketahui bocor (HaveIBeenPwned API)
if is_password_pwned(password):
errors.append(
"Password ini ada dalam daftar password yang diketahui bocor. "
"Silakan pilih password yang berbeda."
)
# 4. Cek konteks (jangan gunakan nama aplikasi, username, dll)
# Ini lebih berguna dari requirement "harus ada angka dan huruf besar"
return len(errors) == 0, errors
def is_password_pwned(password: str) -> bool:
"""Cek HaveIBeenPwned menggunakan k-Anonymity — password tidak dikirim."""
import hashlib
import requests
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefix = sha1[:5]
suffix = sha1[5:]
response = requests.get(PWNED_API_URL.format(prefix), timeout=5)
if response.status_code != 200:
return False # Jika API gagal, jangan blokir user
for line in response.text.splitlines():
hash_suffix, count = line.split(':')
if hash_suffix == suffix:
return int(count) > 0
return False
Apa yang TIDAK perlu (berdasarkan NIST):
✗ Requirement "harus ada huruf besar, angka, dan simbol"
→ Membuat user menggunakan pola yang mudah ditebak: Password1!
✗ Rotasi password wajib secara periodik (misal setiap 90 hari)
→ User menggunakan password lemah yang mudah diingat jika sering diubah
→ Ganti password HANYA jika ada indikasi kompromi
✗ Hints dan security questions
→ Security questions sering bisa ditebak dari info publik
Yang perlu:
✓ Panjang yang cukup (12+ karakter)
✓ Cek terhadap database password yang bocor
✓ Tidak boleh sama dengan username atau email
✓ Tampilkan strength indicator untuk membantu user memilih password kuat
Multi-Factor Authentication (MFA) #
MFA menambahkan lapisan verifikasi kedua setelah password. Bahkan jika password dikompromikan, attacker masih perlu faktor kedua yang tidak mereka miliki.
flowchart TD
A[User masukkan\nusername + password] --> B{Password benar?}
B --> |Tidak| C[Tolak + log\nfailed attempt]
B --> |Ya| D{MFA aktif?}
D --> |Tidak| E[Login berhasil\nTapi kurang aman]
D --> |Ya| F{Jenis MFA?}
F --> G[TOTP\nGoogle Auth/Authy]
F --> H[SMS OTP]
F --> I[Push notification\nke app]
F --> J[Hardware key\nYubiKey/FIDO2]
G --> K{Kode valid?}
H --> K
I --> K
J --> K
K --> |Tidak| L[Tolak + log]
K --> |Ya| M[Login berhasil ✓]TOTP (Time-based One-Time Password) #
# Implementasi TOTP dengan pyotp
import pyotp
import qrcode
import secrets
def setup_totp_for_user(user_id: int, username: str) -> dict:
"""Generate TOTP secret dan QR code untuk setup MFA."""
# Generate secret key yang aman
secret = pyotp.random_base32()
# Simpan secret ke database (terenkripsi!)
store_mfa_secret(user_id, encrypt_secret(secret))
# Generate provisioning URI untuk QR code
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(
name=username,
issuer_name="MyApp"
)
# Generate QR code
qr = qrcode.make(provisioning_uri)
# Tampilkan QR code ke user untuk di-scan dengan authenticator app
# Juga tampilkan secret key sebagai teks (untuk manual entry)
return {
'secret': secret, # tampilkan SEKALI, jangan simpan di plain
'provisioning_uri': provisioning_uri,
# backup codes untuk recovery
'backup_codes': generate_backup_codes(user_id)
}
def verify_totp(user_id: int, provided_code: str) -> bool:
"""Verifikasi TOTP code dari user."""
encrypted_secret = get_mfa_secret(user_id)
secret = decrypt_secret(encrypted_secret)
totp = pyotp.TOTP(secret)
# valid_window=1 mengizinkan 1 interval maju/mundur (30 detik)
# untuk mengakomodasi sedikit perbedaan waktu antara server dan device
return totp.verify(provided_code, valid_window=1)
def generate_backup_codes(user_id: int) -> list[str]:
"""Generate backup codes untuk recovery jika device hilang."""
codes = [secrets.token_hex(4).upper() for _ in range(10)]
# Simpan hash dari backup codes (bukan plaintext)
# User hanya bisa lihat sekali saat setup
store_backup_codes_hashed(user_id, codes)
return codes # tampilkan ke user sekali saja
Pilihan MFA dari yang paling ke kurang aman:
1. FIDO2/WebAuthn (hardware key atau passkey)
→ Tidak bisa di-phish karena terikat ke domain
→ Paling aman, tapi butuh hardware atau device yang support
2. TOTP (Google Authenticator, Authy)
→ Time-based, kode berbeda setiap 30 detik
→ Rentan phishing jika user ditipu memasukkan kode ke situs palsu
→ Tapi masih jauh lebih baik dari SMS
3. Push notification (Duo, Okta Verify)
→ User approve di app → lebih user-friendly dari TOTP
→ Rentan MFA fatigue attack (spam notifikasi sampai user approve)
4. SMS OTP
→ Rentan SIM swapping attack
→ Lebih baik dari tidak ada MFA sama sekali
→ Hindari untuk aplikasi dengan risiko tinggi (banking, crypto)
Session vs Token-Based Authentication #
Ada dua pendekatan utama untuk mempertahankan state autentikasi setelah login berhasil.
Session-based (stateful):
Server Database/Redis Client
┌──────┐ ┌──────────────┐ ┌──────────┐
│Login │──store──→ │session_id: │ │Cookie: │
│ │ │ user_id: 1 │ ←read─ │session=X │
│ │ ←set─────│ expires: .. │ │ │
└──────┘ └──────────────┘ └──────────┘
Keuntungan:
→ Session bisa di-revoke kapan saja (hapus dari database)
→ Server selalu punya info terkini tentang session
→ Ideal untuk web app tradisional
Kekurangan:
→ Butuh shared storage jika ada multiple server instance
→ Tambahan latency untuk lookup ke database/Redis
Token-based (stateless) — JWT:
Server Client
┌──────┐ ┌──────────────────────────────────┐
│Login │──sign───→ │Header: eyJhbGciOiJSUzI1NiJ9. │
│ │ │Payload: eyJ1c2VyX2lkIjoxfQ. │
│ │ ←send────│Signature: ... │
└──────┘ └──────────────────────────────────┘
Keuntungan:
→ Stateless — tidak perlu database lookup untuk verifikasi
→ Horizontal scaling mudah (tidak perlu shared session store)
→ Bisa diverifikasi oleh multiple services tanpa komunikasi ke auth server
Kekurangan:
→ Tidak bisa di-revoke sebelum expire (tanpa additional mechanism)
→ Payload terbaca oleh siapapun (hanya signature yang tidak bisa dipalsukan)
JWT — Pitfall yang Sering Diabaikan #
import jwt
import secrets
from datetime import datetime, timedelta
# ANTI-PATTERN 1: algoritma "none" — mematikan signature verification
token = jwt.encode({'user_id': 1}, None, algorithm='none')
decoded = jwt.decode(token, options={"verify_signature": False})
# Siapapun bisa membuat token tanpa signature
# ANTI-PATTERN 2: kunci HS256 yang lemah
token = jwt.encode({'user_id': 1}, "secret", algorithm='HS256')
# "secret" bisa di-crack dengan wordlist attack dalam detik
# ANTI-PATTERN 3: expiry yang terlalu panjang
payload = {
'user_id': 1,
'exp': datetime.utcnow() + timedelta(days=365) # 1 tahun!
}
# Token yang dikompromikan valid selama 1 tahun tanpa cara revoke
# BENAR: konfigurasi JWT yang aman
import os
# HS256: secret key minimal 256-bit (32 byte)
JWT_SECRET = os.environ['JWT_SECRET'] # 32+ byte random key
assert len(JWT_SECRET) >= 32, "JWT secret too short"
# Atau RS256 untuk arsitektur multi-service
# RS256 memungkinkan service lain verifikasi token tanpa tahu private key
def create_access_token(user_id: int, additional_claims: dict = None) -> str:
now = datetime.utcnow()
payload = {
'sub': str(user_id), # subject
'iat': now, # issued at
'exp': now + timedelta(minutes=15), # expiry pendek!
'jti': secrets.token_urlsafe(16), # unique token ID
'type': 'access', # bedakan access vs refresh token
**(additional_claims or {})
}
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
def verify_access_token(token: str) -> dict:
try:
payload = jwt.decode(
token,
JWT_SECRET,
algorithms=['HS256'], # eksplisit — jangan ['HS256', 'none']
options={
'require': ['exp', 'iat', 'sub', 'jti', 'type']
}
)
if payload.get('type') != 'access':
raise ValueError("Wrong token type")
# Opsional: cek token blacklist jika butuh revocation
if is_token_revoked(payload['jti']):
raise ValueError("Token has been revoked")
return payload
except jwt.ExpiredSignatureError:
raise AuthError("Token expired, please login again")
except jwt.InvalidTokenError as e:
raise AuthError(f"Invalid token: {e}")
Brute Force Protection #
import time
from collections import defaultdict
class RateLimiter:
def __init__(self, max_attempts: int, window_seconds: int,
lockout_seconds: int):
self.max_attempts = max_attempts
self.window_seconds = window_seconds
self.lockout_seconds = lockout_seconds
def check_rate_limit(self, identifier: str) -> tuple[bool, int]:
"""
Returns: (is_allowed, retry_after_seconds)
"""
key = f"login_attempts:{identifier}"
lockout_key = f"login_lockout:{identifier}"
# Cek apakah sedang dalam lockout
lockout_remaining = redis_client.ttl(lockout_key)
if lockout_remaining > 0:
return False, lockout_remaining
# Cek jumlah percobaan dalam window
attempts = redis_client.get(key)
if attempts and int(attempts) >= self.max_attempts:
# Set lockout
redis_client.setex(lockout_key, self.lockout_seconds, 1)
redis_client.delete(key)
return False, self.lockout_seconds
return True, 0
def record_attempt(self, identifier: str):
"""Record satu attempt."""
key = f"login_attempts:{identifier}"
pipe = redis_client.pipeline()
pipe.incr(key)
pipe.expire(key, self.window_seconds)
pipe.execute()
def reset_attempts(self, identifier: str):
"""Reset setelah login berhasil."""
redis_client.delete(f"login_attempts:{identifier}")
redis_client.delete(f"login_lockout:{identifier}")
# Limiter per IP: 10 percobaan per 5 menit
ip_limiter = RateLimiter(max_attempts=10, window_seconds=300, lockout_seconds=300)
# Limiter per akun: 5 percobaan per 15 menit (lebih ketat)
account_limiter = RateLimiter(max_attempts=5, window_seconds=900, lockout_seconds=900)
@app.route('/login', methods=['POST'])
def login():
email = request.json.get('email', '')
password = request.json.get('password', '')
ip = request.remote_addr
# Cek rate limit per IP
allowed, retry_after = ip_limiter.check_rate_limit(ip)
if not allowed:
return jsonify({
'error': 'Terlalu banyak percobaan. Coba lagi dalam beberapa menit.',
}), 429, {'Retry-After': str(retry_after)}
# Cek rate limit per akun
allowed, retry_after = account_limiter.check_rate_limit(email)
if not allowed:
return jsonify({
'error': 'Akun sementara terkunci karena terlalu banyak percobaan.',
}), 429, {'Retry-After': str(retry_after)}
# Record attempt SEBELUM verifikasi (untuk mencegah timing attack)
ip_limiter.record_attempt(ip)
account_limiter.record_attempt(email)
user = User.query.filter_by(email=email).first()
# PENTING: selalu verifikasi password (timing-safe)
# Jika user tidak ada, tetap jalankan verify untuk waktu yang konsisten
if user is None:
ph.verify("dummy_hash", password) # timing dummy
return jsonify({'error': 'Email atau password tidak valid'}), 401
if not verify_password(user.password_hash, password):
return jsonify({'error': 'Email atau password tidak valid'}), 401
# Login berhasil — reset rate limit
ip_limiter.reset_attempts(ip)
account_limiter.reset_attempts(email)
# Cek apakah hash perlu di-upgrade
if needs_rehash(user.password_hash):
user.password_hash = hash_password(password)
db.session.commit()
session_token = create_session(user.id, request)
response = make_response(jsonify({'status': 'ok'}))
set_auth_cookie(response, session_token)
return response
Secure Password Reset #
Flow reset password adalah salah satu yang paling sering diimplementasikan dengan lemah — tapi berpotensi menjadi celah account takeover yang serius.
import secrets
import hashlib
from datetime import datetime, timedelta
RESET_TOKEN_EXPIRY = timedelta(minutes=15)
def request_password_reset(email: str):
"""Kirim email reset password ke user."""
user = User.query.filter_by(email=email).first()
# PENTING: response SELALU sama terlepas apakah email ada atau tidak
# Mencegah user enumeration (mengetahui apakah email terdaftar)
if user:
# Generate token yang kuat
raw_token = secrets.token_urlsafe(32)
# Simpan HASH dari token (bukan token itu sendiri)
# Jika database bocor, token tidak langsung bisa dipakai
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
# Invalidasi semua token reset sebelumnya
PasswordResetToken.query.filter_by(user_id=user.id).delete()
PasswordResetToken.create(
user_id=user.id,
token_hash=token_hash,
expires_at=datetime.utcnow() + RESET_TOKEN_EXPIRY,
used=False
)
send_reset_email(
to=user.email,
reset_url=f"https://app.com/reset-password?token={raw_token}"
# raw_token dikirim ke email, bukan token_hash
)
# Response selalu sama — jangan bedakan "email ada" vs "email tidak ada"
return jsonify({
'message': 'Jika email terdaftar, instruksi reset akan dikirimkan.'
})
def reset_password(raw_token: str, new_password: str):
"""Proses reset password dengan token dari email."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
record = PasswordResetToken.query.filter_by(
token_hash=token_hash,
used=False
).first()
if not record:
raise InvalidTokenError("Token tidak valid atau sudah digunakan")
if record.expires_at < datetime.utcnow():
raise InvalidTokenError("Token sudah kadaluarsa")
# Validasi password baru
is_valid, errors = validate_password(new_password)
if not is_valid:
raise ValidationError(errors)
# Update password
user = User.query.get(record.user_id)
user.password_hash = hash_password(new_password)
# Tandai token sebagai sudah digunakan (single-use)
record.used = True
record.used_at = datetime.utcnow()
# PENTING: invalidasi semua session aktif setelah reset
# Jika account dikompromikan dan attacker yang set password,
# session attacker juga harus diinvalidasi
invalidate_all_sessions(user.id)
db.session.commit()
# Kirim notifikasi email bahwa password berhasil diubah
send_password_changed_notification(user.email)
Checklist Authentication #
PASSWORD:
□ Password di-hash dengan Argon2id, bcrypt, atau scrypt — bukan MD5/SHA256
□ Salt di-generate otomatis untuk setiap password (Argon2 melakukan ini)
□ Panjang minimum 12 karakter
□ Cek terhadap HaveIBeenPwned API untuk password yang diketahui bocor
□ Tidak memaksa requirement kompleksitas yang tidak perlu (simbol wajib, dll)
□ Tidak ada rotasi password periodik paksa
MFA:
□ MFA tersedia untuk semua user
□ MFA wajib untuk akun admin dan akun dengan privilege tinggi
□ TOTP atau FIDO2 lebih diprioritaskan dari SMS OTP
□ Backup codes tersedia jika device hilang
□ Recovery process yang aman jika user kehilangan akses ke MFA
BRUTE FORCE PROTECTION:
□ Rate limiting per IP dan per akun
□ Account lockout setelah N percobaan gagal
□ Pesan error tidak membedakan "email tidak ada" vs "password salah"
□ Constant-time comparison untuk verifikasi credential
□ Log semua percobaan gagal dengan metadata (IP, timestamp, user agent)
SESSION & TOKEN:
□ Session ID dibuat dengan CSPRNG (secrets.token_urlsafe)
□ JWT menggunakan algoritma eksplisit, expiry pendek, dan jti unique
□ Session di-invalidasi saat logout
□ Session di-invalidasi saat password diubah
□ Idle timeout dan absolute timeout dikonfigurasi
PASSWORD RESET:
□ Token reset dibuat dengan CSPRNG (32+ byte)
□ Token reset di-hash sebelum disimpan di database
□ Token hanya berlaku 15-60 menit
□ Token single-use — langsung invalid setelah dipakai
□ Response reset tidak membocorkan apakah email terdaftar
□ Semua session di-invalidasi setelah reset password
□ Notifikasi email dikirim setelah password berhasil diubah
MONITORING:
□ Failed login attempts di-log dan di-alert jika anomali
□ Login dari lokasi baru/tidak biasa memicu notifikasi
□ Concurrent login dari lokasi yang tidak mungkin dideteksi
Ringkasan #
- Authentication dan authorization adalah dua hal yang berbeda — authn memverifikasi identitas, authz memverifikasi hak akses. Keduanya harus dilakukan secara terpisah dan berurutan.
- Argon2id adalah standar untuk password hashing — memory-hard, time-configurable, dengan salt otomatis. MD5, SHA256, dan enkripsi bukan pilihan yang valid untuk password.
- Policy password yang terlalu ketat justru melemahkan keamanan — fokus pada panjang minimum dan cek terhadap database password yang bocor (HaveIBeenPwned), bukan requirement karakter khusus yang kompleks.
- MFA adalah lapisan perlindungan yang paling efektif terhadap credential compromise — bahkan jika password bocor, attacker masih perlu faktor kedua. FIDO2/WebAuthn adalah yang paling aman karena tidak bisa di-phish.
- Session-based auth memungkinkan revocation, JWT tidak — session bisa di-invalidasi kapan saja. JWT yang dikompromikan valid sampai expire, kecuali ada mekanisme revocation tambahan.
- JWT harus menggunakan algoritma eksplisit dan expiry pendek — jangan izinkan ’none’ algorithm, gunakan HS256/RS256, set access token expire dalam 15 menit.
- Brute force protection harus ada di dua level — per IP dan per akun. Keduanya diperlukan karena attacker bisa datang dari banyak IP atau targetkan satu akun dari satu IP.
- Constant-time comparison untuk verifikasi credential mencegah timing attack —
hmac.compare_digestatausecrets.compare_digest, bukan==operator biasa.- Token reset password harus di-hash sebelum disimpan — sama seperti password, simpan hash-nya bukan token aslinya. Jika database bocor, token tidak langsung bisa dipakai.
- Reset password harus invalidasi semua session aktif — jika akun dikompromikan dan attacker yang memicu reset, session mereka juga harus ikut dihapus.