Tampilan
Kenapa penting
Di bab sebelumnya user disimpan in-memory dengan {noop} — cukup untuk belajar, fatal untuk production. Aplikasi nyata menyimpan ribuan sampai jutaan akun di database, dan database bisa bocor: backup yang salah tempat, SQL injection, atau akses orang dalam. Saat itu terjadi, satu-satunya pertahanan adalah cara kamu menyimpan password. Kebocoran password plain text atau MD5 sering berujung pada pengambilalihan akun di layanan lain, karena orang memakai ulang password. Bab ini membahas apa yang diharapkan dari backend developer: memuat user dari database, hashing yang benar, registrasi yang tidak membocorkan informasi, dan reset password yang aman.
Konsep inti
UserDetailsService: jembatan ke database
Spring Security tidak tahu bentuk tabel user-mu. Ia hanya butuh satu kontrak: UserDetailsService dengan method loadUserByUsername(String) yang mengembalikan UserDetails (username, hash password, authority, status aktif). Saat login HTTP Basic, DaoAuthenticationProvider memanggil service ini, lalu memverifikasi password dengan PasswordEncoder.
sql
-- V3__create_users.sql (Flyway)
CREATE TABLE app_users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR(254) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'CUSTOMER',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Email unik tanpa peduli huruf besar/kecil
CREATE UNIQUE INDEX ux_app_users_email ON app_users (lower(email));java
// Potongan — memuat user dari database
@Service
class AppUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
AppUserDetailsService(AppUserRepository users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AppUser user = users.findByEmailIgnoreCase(username)
.orElseThrow(() -> new UsernameNotFoundException("User tidak ditemukan"));
return User.withUsername(user.getEmail())
.password(user.getPasswordHash())
.roles(user.getRole().name())
.disabled(!user.isEnabled())
.build();
}
}Bila ada satu bean UserDetailsService dan satu bean PasswordEncoder, Spring Boot memakainya otomatis — bean InMemoryUserDetailsManager dari bab sebelumnya tinggal dihapus. Pesan UsernameNotFoundException tidak dikirim ke klien: secara default Spring Security mengubahnya menjadi kegagalan login umum (BadCredentialsException) supaya penyerang tidak bisa membedakan "user tidak ada" dari "password salah".
Hashing, bukan enkripsi
Password tidak boleh bisa dikembalikan ke bentuk aslinya, bahkan oleh admin. Karena itu yang dipakai adalah hashing satu arah, bukan enkripsi. Tapi tidak sembarang hash:
| Cara simpan | Masalah |
|---|---|
| Plain text | Bocor = semua password langsung terbaca |
| Enkripsi (AES) | Kunci ikut bocor = semua password terbaca |
| MD5 / SHA-1 / SHA-256 polos | Terlalu cepat (miliaran tebakan/detik di GPU); tanpa salt, hash sama untuk password sama |
| BCrypt / Argon2 / scrypt / PBKDF2 | Sengaja lambat, salt acak per user, biaya bisa dinaikkan seiring hardware |
Salt adalah nilai acak unik per hash yang membuat dua user dengan password sama tetap punya hash berbeda, sehingga tabel hash siap pakai tidak berguna. Algoritma password menyimpan salt di dalam string hash-nya, jadi kamu tidak perlu kolom terpisah.
PasswordEncoder dan DelegatingPasswordEncoder
Spring Security menyediakan interface PasswordEncoder dengan dua method utama: encode(raw) untuk membuat hash, dan matches(raw, encoded) untuk memverifikasi. Pilihan yang direkomendasikan adalah DelegatingPasswordEncoder:
java
// Potongan
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}Encoder ini menyimpan hash dengan prefix id algoritma, misalnya:
text
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BGSaat verifikasi, prefix menentukan encoder mana yang dipakai. Keuntungannya: kamu bisa berganti algoritma (mis. dari BCrypt ke Argon2) tanpa memaksa semua user reset password. Hash lama tetap terverifikasi dengan algoritma lama, dan hash baru memakai default terbaru. Default createDelegatingPasswordEncoder() saat ini adalah bcrypt.
BCrypt vs Argon2:
- BCrypt — matang, tersedia tanpa dependency tambahan, parameter strength (default 10, tiap +1 menggandakan waktu). Batasnya: hanya 72 byte pertama password yang diproses.
- Argon2 (
Argon2PasswordEncoder) — pemenang Password Hashing Competition, memory-hard sehingga mahal diserang dengan GPU. Di Spring Security butuh library BouncyCastle. OWASP menempatkan Argon2id sebagai pilihan pertama.
Untuk kebanyakan aplikasi, BCrypt dengan strength yang membuat satu hash butuh sekitar ratusan milidetik di server production sudah layak. Yang penting adalah memakai salah satunya, bukan SHA polos.
Registrasi user
Registrasi menggabungkan konsep M07 (DTO + validasi) dengan hashing:
java
// Potongan
public record RegisterRequest(
@NotBlank @Email @Size(max = 254) String email,
@NotBlank @Size(min = 15, max = 128) String password) {
}Aturan yang perlu dipegang:
- Validasi input dengan Bean Validation; batasi panjang maksimum password agar request raksasa tidak membebani hashing.
- Normalisasi email (trim, huruf kecil) dan andalkan unique index di database, bukan hanya cek
existsByEmail— dua request bersamaan bisa lolos cek yang sama. - Hash sebelum menyimpan; password mentah tidak pernah ditulis ke log, entity, atau respons.
- Respons generik. Jangan membalas "email sudah terdaftar". Balas hal yang sama untuk semua kasus, lalu kirim email berbeda ke pemilik alamat (verifikasi untuk akun baru, pemberitahuan "kamu sudah punya akun" untuk yang lama).
Kebijakan password ala NIST
NIST SP 800-63B (revisi 4) membalik banyak kebiasaan lama:
| Dianjurkan | Tidak dianjurkan |
|---|---|
| Panjang minimum 15 karakter bila password satu-satunya faktor (8 bila ada MFA) | Aturan komposisi wajib (huruf besar + angka + simbol) |
| Izinkan panjang maksimum minimal 64 karakter, spasi, dan Unicode | Rotasi paksa berkala tanpa indikasi bocor |
| Tolak password yang ada di daftar umum/bocor | Pertanyaan keamanan ("nama hewan peliharaan?") |
| Izinkan password manager (paste diperbolehkan) | Petunjuk password yang terlihat orang lain |
Spring Security menyediakan CompromisedPasswordChecker dengan implementasi HaveIBeenPwnedRestApiPasswordChecker untuk mengecek password terhadap basis data kebocoran tanpa mengirim password utuh (hanya awalan hash-nya). Checker ini tidak dibuat otomatis — daftarkan sendiri sebagai bean.
Brute force dan rate limit sekilas
Hashing lambat melindungi database yang bocor, tetapi endpoint login tetap bisa ditebak berulang kali dari luar. Pertahanan umum: batasi jumlah percobaan per akun dan per IP dalam jendela waktu, tambahkan jeda progresif, dan catat kegagalan untuk dipantau. Hati-hati dengan penguncian akun permanen — penyerang bisa sengaja mengunci akun orang lain. Rate limiting biasanya dipasang di API gateway atau dengan library khusus; detailnya di bab keamanan API.
Reset password dengan token sekali pakai
Alur yang aman:
- User mengirim email ke
POST /api/auth/password-reset. Server selalu membalas202dengan pesan generik. - Bila email terdaftar, server membuat token acak dari
SecureRandom, menyimpan hash-nya beserta waktu kedaluwarsa singkat (mis. 30 menit), lalu mengirim link berisi token mentah ke email. - User mengirim token + password baru. Server mencocokkan hash, memastikan belum kedaluwarsa dan belum dipakai, menyimpan hash password baru, lalu menandai token terpakai.
Contoh Java murni berikut menunjukkan pembuatan dan pencocokan token:
java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HexFormat;
public class ResetTokenDemo {
private static final SecureRandom RANDOM = new SecureRandom();
// Token mentah: dikirim ke email user, TIDAK disimpan di database
static String newToken() {
byte[] bytes = new byte[32]; // 256 bit acak
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
// Yang disimpan di database hanya hash-nya
static String sha256Hex(String value) throws NoSuchAlgorithmException {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
}
static boolean matches(String tokenFromLink, String storedHash) throws NoSuchAlgorithmException {
byte[] a = sha256Hex(tokenFromLink).getBytes(StandardCharsets.UTF_8);
byte[] b = storedHash.getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(a, b); // perbandingan waktu-konstan
}
public static void main(String[] args) throws NoSuchAlgorithmException {
String token = newToken();
String stored = sha256Hex(token);
System.out.println("panjang token : " + token.length());
System.out.println("panjang hash : " + stored.length());
System.out.println("token benar : " + matches(token, stored));
System.out.println("token tebakan : " + matches("abc123", stored));
System.out.println("token beda tiap panggilan: " + !newToken().equals(newToken()));
}
}text
panjang token : 43
panjang hash : 64
token benar : true
token tebakan : false
token beda tiap panggilan: trueKenapa token boleh di-hash dengan SHA-256 padahal password tidak? Karena token berisi 256 bit acak — tidak bisa ditebak dengan kamus seperti password buatan manusia. Kecepatan SHA-256 tidak memberi keuntungan berarti bagi penyerang.
Contoh di dunia backend
Service registrasi toko online yang menggabungkan semua aturan di atas:
java
// Potongan — Spring Boot 4.1, Spring Data JPA
@Service
class RegistrationService {
private final AppUserRepository users;
private final PasswordEncoder passwordEncoder;
private final CompromisedPasswordChecker compromisedPasswordChecker;
private final AccountMailer mailer;
RegistrationService(AppUserRepository users, PasswordEncoder passwordEncoder,
CompromisedPasswordChecker compromisedPasswordChecker, AccountMailer mailer) {
this.users = users;
this.passwordEncoder = passwordEncoder;
this.compromisedPasswordChecker = compromisedPasswordChecker;
this.mailer = mailer;
}
@Transactional
public void register(RegisterRequest request) {
String email = request.email().trim().toLowerCase(Locale.ROOT);
if (compromisedPasswordChecker.check(request.password()).isCompromised()) {
throw new WeakPasswordException("Password ini pernah bocor di layanan lain, pilih yang lain");
}
if (users.existsByEmailIgnoreCase(email)) {
mailer.sendAlreadyRegistered(email); // beri tahu pemilik asli, bukan pemanggil
return;
}
try {
users.saveAndFlush(new AppUser(email, passwordEncoder.encode(request.password()), Role.CUSTOMER));
mailer.sendVerification(email);
} catch (DataIntegrityViolationException e) {
// Balapan dua request: unique index menolak yang kedua — tetap respons generik
mailer.sendAlreadyRegistered(email);
}
}
}
@RestController
@RequestMapping("/api/auth")
class RegistrationController {
private final RegistrationService registrationService;
RegistrationController(RegistrationService registrationService) {
this.registrationService = registrationService;
}
@PostMapping("/register")
ResponseEntity<Map<String, String>> register(@Valid @RequestBody RegisterRequest request) {
registrationService.register(request);
return ResponseEntity.accepted()
.body(Map.of("message", "Bila data valid, cek email kamu untuk langkah berikutnya."));
}
}Jangan lupa membuka endpoint registrasi di konfigurasi security: .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/password-reset/**").permitAll(). Penolakan password bocor boleh spesifik, karena tidak membocorkan informasi tentang akun orang lain; WeakPasswordException dipetakan ke 400/422 lewat @RestControllerAdvice seperti di M07.
Pertanyaan interview
1. Kenapa password harus di-hash dengan BCrypt/Argon2, bukan SHA-256?
SHA-256 dirancang untuk cepat, sehingga penyerang yang mendapat database bisa mencoba miliaran tebakan per detik. BCrypt dan Argon2 sengaja lambat (dan Argon2 juga boros memori), memakai salt acak per hash, serta punya parameter biaya yang bisa dinaikkan seiring hardware makin cepat. Hasilnya, menebak satu password menjadi sangat mahal.
2. Apa fungsi DelegatingPasswordEncoder?
Ia menyimpan hash dengan prefix {id} yang menunjukkan algoritmanya, dan memilih encoder yang sesuai saat verifikasi. Ini memungkinkan migrasi algoritma bertahap: hash lama tetap bisa diverifikasi, hash baru memakai algoritma default terbaru, dan aplikasi bisa melakukan re-hash saat user berhasil login.
3. Bagaimana mendesain reset password yang aman?
Selalu balas respons generik agar tidak membocorkan email terdaftar. Buat token acak kuat dari SecureRandom, simpan hanya hash-nya dengan kedaluwarsa singkat, kirim token mentah lewat email, dan tandai token terpakai setelah berhasil. Batasi jumlah permintaan per email/IP, dan pertimbangkan mencabut sesi/token aktif setelah password diganti.
Jebakan umum
- Membandingkan hash dengan
equals. Hash BCrypt berbeda tiapencodekarena salt; selalu pakaimatches. - Menyimpan password plain, MD5, atau SHA polos — termasuk "sementara" atau "di environment staging".
- Mencatat request login/registrasi ke log sehingga password mentah ikut tersimpan di sistem log.
- Hanya mengandalkan
existsByEmailtanpa unique index; dua request bersamaan bisa membuat akun ganda. - Pesan error yang membedakan "email tidak ada" dan "password salah" — membuka jalan user enumeration.
- Token reset yang bisa ditebak (
UUIDberbasis waktu, angka berurutan,Random) atau tanpa kedaluwarsa.
Ringkasan
UserDetailsServicememuat user dari database; Boot memakainya otomatis bersama beanPasswordEncoder.- Password di-hash satu arah dengan algoritma lambat bersalt (BCrypt, Argon2), bukan plain text, enkripsi, atau SHA polos.
DelegatingPasswordEncodermenyimpan prefix{id}sehingga algoritma bisa diganti tanpa reset massal.- Registrasi: validasi, normalisasi email, unique index di database, hash sebelum simpan, respons generik.
- Kebijakan NIST: utamakan panjang, tolak password bocor, tanpa aturan komposisi dan rotasi paksa.
- Reset password memakai token acak sekali pakai berumur pendek yang disimpan dalam bentuk hash.