Penanganan kata sandi

Facade Hash, pilihan algoritma, dan ikhtisar alur reset kata sandi.

Daftar isi

Kata sandi tidak pernah disimpan sebagai teks asli, melainkan diubah menjadi hash satu arah, yaitu representasi yang tidak dapat dibalik menjadi teks asalnya, sebelum masuk ke database. Topik ini membahas facade Hash yang menyediakan pembuatan dan verifikasi hash, anatomi keluaran hash beserta peran salt, pilihan algoritma pada konfigurasi hashing, serta ikhtisar alur reset kata sandi. Seluruh contoh dijalankan pada aplikasi catatan. Setelah topik ini dibaca, cara Laravel menyimpan, memverifikasi, menghitung ulang, dan mengganti kata sandi dapat dijelaskan beserta alasan di balik setiap langkahnya.

Menyimpan dan memverifikasi kata sandi#

Facade Hash menyediakan dua metode utama untuk bekerja dengan kata sandi. Metode Hash::make() menerima kata sandi polos dan mengembalikan string hash, sedangkan metode Hash::check() menerima kata sandi polos beserta hash yang tersimpan dan mengembalikan true apabila keduanya cocok. Pada model User aplikasi catatan, kolom password membawa cast hashed, sehingga penugasan kata sandi polos pada model di-hash secara otomatis pada saat disimpan. Perilaku itu dapat diamati dengan menyiapkan satu pengguna contoh, kemudian memeriksa isi kolomnya melalui tinker.

bash
# menyiapkan pengguna contoh lalu memverifikasi kata sandinya
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\User;
use Illuminate\Support\Facades\Hash;
User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Ali", "password" => "kata-sandi-ali"]
);
$pengguna = User::where("email", "[email protected]")->first();
var_dump(Hash::isHashed($pengguna->password));
var_dump(Hash::check("kata-sandi-ali", $pengguna->password));
var_dump(Hash::check("sandi-salah", $pengguna->password));
'
text Keluaran
bool(true)
bool(true)
bool(false)

Keluaran tersebut menunjukkan tiga hal. Nilai isHashed() yang bernilai true membuktikan bahwa kata sandi polos yang dikirim melalui updateOrCreate() tersimpan di database dalam bentuk hash, bukan teks asli. Pemeriksaan check() mengembalikan true untuk kata sandi yang benar dan false untuk yang salah, sehingga verifikasi tidak memerlukan pencocokan manual. Ketiga perilaku itu terjadi karena cast hashed dan facade Hash sama-sama mengikuti konfigurasi hashing aplikasi.

Anatomi hash dan peran salt#

Setiap keluaran Hash::make() dengan driver bcrypt tersusun atas empat bagian yang dipisahkan tanda $, yaitu penanda algoritma, faktor biaya, salt, dan hasil hash. Salt adalah nilai acak yang digabungkan dengan kata sandi sebelum proses hashing, sehingga dua kali hashing atas kata sandi yang sama menghasilkan keluaran yang berbeda. Struktur itu dapat dibedah langsung dengan skrip PHP berikut, yang memecah satu hash bcrypt ke bagian-bagiannya.

php anatomi-hash.php
<?php

// keluaran Hash::make() berformat $2y$<cost>$<salt 22 karakter><hash 31 karakter>
$hash = '$2y$12$wRSCiOYHOnwPN52zIpqneOLCTXSu4BOPRni8mBVDy4gv7tOVfKPOu';

$bagian = explode('$', $hash);

echo 'algoritma: ' . $bagian[1] . PHP_EOL;
echo 'cost: ' . $bagian[2] . PHP_EOL;
echo 'salt: ' . substr($bagian[3], 0, 22) . PHP_EOL;
echo 'hash: ' . substr($bagian[3], 22) . PHP_EOL;
text Keluaran
algoritma: 2y
cost: 12
salt: wRSCiOYHOnwPN52zIpqneO
hash: LCTXSu4BOPRni8mBVDy4gv7tOVfKPOu

Bagian 2y adalah penanda algoritma bcrypt yang digunakan PHP, angka 12 adalah faktor biaya saat hash itu dibuat, 22 karakter berikutnya adalah salt, dan sisanya adalah hasil hash. Pembagian ini berlaku di luar Laravel karena format keluaran bcrypt ditetapkan oleh implementasi bcrypt pada PHP, sedangkan facade Hash hanyalah pembungkusnya.

Salt menyebabkan hash yang dihasilkan make() tidak pernah sama untuk kata sandi yang sama, sehingga perbandingan langsung antara dua hash bukan cara memverifikasi kata sandi. Kedua hash berikut dibuat dari kata sandi kata-sandi-ali pada waktu yang berbeda, dengan faktor biaya yang berbeda pula.

bash
# membandingkan dua hash dari kata sandi yang sama
cd ~/aplikasi-catatan
php artisan tinker --execute='
use Illuminate\Support\Facades\Hash;
$hashA = "\$2y\$12\$wRSCiOYHOnwPN52zIpqneOLCTXSu4BOPRni8mBVDy4gv7tOVfKPOu";
$hashB = "\$2y\$10\$JgysP/xJAhBDFauxW/MPNu8VLTMsYAG/RIuPBZ.hG/kPK/LykO.rG";
var_dump($hashA === $hashB);
var_dump(Hash::check("kata-sandi-ali", $hashA));
var_dump(Hash::check("kata-sandi-ali", $hashB));
print_r(Hash::info($hashB));
'
text Keluaran
bool(false)
bool(true)
bool(true)
Array
(
    [algo] => 2y
    [algoName] => bcrypt
    [options] => Array
        (
            [cost] => 10
        )

)

Perbandingan $hashA === $hashB mengembalikan false karena kedua salt berbeda, tetapi check() mengembalikan true untuk keduanya. Metode check() membaca salt dari hash yang tersimpan, menghitung ulang hash kata sandi yang diuji dengan salt tersebut, lalu membandingkan hasilnya. Metode info() melengkapi pemeriksaan karena menguraikan algoritma beserta opsinya tanpa memerlukan kata sandi aslinya. Karena itulah verifikasi kata sandi selalu dilakukan melalui check(), baik oleh kode aplikasi maupun oleh Auth::attempt() pada proses login.

Memilih algoritma pada konfigurasi hashing#

Laravel mengatur pembuatan hash melalui konfigurasi hashing. Driver bawaannya adalah bcrypt, sedangkan driver alternatif yang didukung adalah argon dan argon2id. Faktor biaya menentukan lamanya proses hashing, sehingga nilai yang lebih tinggi memperlambat penyerangan tebakan kata sandi sekaligus memperlambat setiap proses login. Konfigurasi aktif dapat diperiksa tanpa membuka berkas apa pun melalui perintah config:show.

bash
# menampilkan konfigurasi hashing yang aktif
cd ~/aplikasi-catatan
php artisan config:show hashing
text Keluaran
  hashing ....................................................................
  driver .............................................................. bcrypt
  bcrypt ⇁ rounds ......................................................... 12
  bcrypt ⇁ verify ....................................................... true
  bcrypt ⇁ limit ........................................................ null
  argon ⇁ memory ....................................................... 65536
  argon ⇁ threads .......................................................... 1
  argon ⇁ time ............................................................. 4
  argon ⇁ verify ........................................................ true
  rehash_on_login ....................................................... true

Perbandingan kedua driver utama dirangkum pada tabel berikut.

Aspekbcryptargon2id
Cara memperlambat penyeranganPerulangan melalui faktor biaya (rounds)Konsumsi memori dan waktu (memory, time, threads)
Opsi konfigurasirounds, verify, limitmemory, threads, time, verify
Dukungan PHPTersedia pada penyiapan PHP bakuPHP yang dikompilasi dengan dukungan Argon2

Nilai rehash_on_login yang bernilai true menambahkan perilaku penting. Setiap login yang berhasil memeriksa hash kata sandi yang tersimpan, dan apabila hash itu dibuat dengan faktor biaya yang berbeda dari konfigurasi aktif, Laravel menghitung ulang hash dengan pengaturan terbaru lalu menyimpannya kembali. Peningkatan faktor biaya dari 10 menjadi 12, misalnya, tidak memerlukan migrasi data karena pembaruan terjadi secara bertahap setiap kali pengguna login. Metode Hash::needsRehash() melakukan pemeriksaan yang sama tanpa menunggu login.

bash
# memeriksa kebutuhan rehash dan dukungan Argon2 pada PHP yang terpasang
cd ~/aplikasi-catatan
php artisan tinker --execute='
use Illuminate\Support\Facades\Hash;
$hashLama = "\$2y\$10\$JgysP/xJAhBDFauxW/MPNu8VLTMsYAG/RIuPBZ.hG/kPK/LykO.rG";
var_dump(Hash::needsRehash($hashLama));
var_dump(defined("PASSWORD_ARGON2ID"));
'
text Keluaran
bool(true)
bool(false)

Keluaran pertama bernilai true karena hash contoh dibuat dengan faktor biaya 10, sedangkan konfigurasi aktif menuntut 12. Keluaran kedua bernilai false karena PHP pada penyiapan aplikasi catatan dikompilasi tanpa dukungan Argon2, dan kondisi ini membatasi pilihan driver yang dapat digunakan.

Alur reset kata sandi#

Kata sandi yang dilupakan tidak dikirim ulang ke pengguna, melainkan diganti melalui alur reset yang dijalankan fasilitas password broker, yaitu komponen Laravel yang menangani pembuatan token dan penggantian kata sandi. Alur tersebut terdiri atas empat langkah berikut.

  1. Pengguna mengisi formulir lupa kata sandi, dan aplikasi memanggil Password::sendResetLink() dengan alamat email yang dimasukkan.
  2. Broker membuat token acak melalui Password::createToken(), menyimpan hash token itu pada tabel password_reset_tokens, lalu mengirim tautan yang membawa token ke email pengguna.
  3. Pengguna membuka tautan, menetapkan kata sandi baru, dan aplikasi memanggil Password::reset() dengan email, token, serta kata sandi barunya.
  4. Broker memverifikasi token dengan Hash::check() terhadap kolom token sekaligus memeriksa masa berlakunya, lalu mengganti kata sandi melalui callback, yaitu fungsi yang ditentukan aplikasi, dan menghapus baris tokennya.

Nilai expire menentukan masa berlaku token dalam menit, sedangkan nilai throttle menentukan jeda minimum antara dua permintaan tautan berturut-turut dalam detik. Pada konfigurasi bawaan, keduanya bernilai 60. Token itu tersimpan pada tabel yang strukturnya dapat dilihat dengan perintah berikut.

bash
# melihat struktur tabel penyimpan token reset
cd ~/aplikasi-catatan
sqlite3 database/database.sqlite '.schema password_reset_tokens'
text Keluaran
CREATE TABLE IF NOT EXISTS "password_reset_tokens" ("email" varchar not null, "token" varchar not null, "created_at" datetime, primary key ("email"));

Kolom email menjadi kunci primer sehingga satu alamat hanya menyimpan satu token, kolom token menyimpan hash, dan kolom created_at menjadi dasar perhitungan masa berlaku. Seluruh alur di atas dapat dijalankan tanpa HTTP dan tanpa pengiriman email karena pembuatan token dan penggantiannya dapat dipanggil langsung.

bash
# menyiapkan pengguna lalu menjalankan pembuatan dan pemakaian token reset
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
$pengguna = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Ali", "password" => "kata-sandi-ali"]
);
$token = Password::createToken($pengguna);
var_dump(strlen($token));
var_dump(substr(DB::table("password_reset_tokens")->where("email", "[email protected]")->value("token"), 0, 7));
$status = Password::reset(
    ["email" => "[email protected]", "password" => "sandi-baru-ali", "password_confirmation" => "sandi-baru-ali", "token" => $token],
    function (User $pemilik, string $sandiBaru) {
        $pemilik->forceFill(["password" => $sandiBaru])->save();
    }
);
var_dump($status === Password::PASSWORD_RESET);
var_dump(Hash::check("sandi-baru-ali", $pengguna->fresh()->password));
var_dump(DB::table("password_reset_tokens")->where("email", "[email protected]")->exists());
'
text Keluaran
int(64)
string(7) "$2y$12$"
bool(true)
bool(true)
bool(false)

Keluaran tersebut memperlihatkan satu putaran alur lengkap. Token yang dikembalikan createToken() panjangnya 64 karakter, sedangkan nilai pada kolom token berawalan $2y$12$, yang berarti token itu tersimpan dalam bentuk hash bcrypt dan bukan teks aslinya. Status PASSWORD_RESET menegaskan verifikasi token berhasil, check() mengonfirmasi kata sandi baru terverifikasi, dan baris token hilang karena reset yang berhasil selalu membersihkan tokennya. Pada aplikasi nyata, alur tersebut dijalankan melalui dua aksi controller berikut.

php Pola aksi controller untuk tautan reset dan kata sandi baru
// tidak-dijalankan
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;

// aksi formulir lupa kata sandi: mengirim tautan berisi token
public function kirimTautan(Request $request)
{
    $status = Password::sendResetLink(
        $request->only('email')
    );

    return $status === Password::RESET_LINK_SENT
        ? back()->with(['status' => __($status)])
        : back()->withErrors(['email' => __($status)]);
}

// aksi formulir kata sandi baru: memverifikasi token lalu mengganti kata sandi
public function simpanKataSandiBaru(Request $request)
{
    $status = Password::reset(
        $request->only('email', 'password', 'password_confirmation', 'token'),
        function (User $pengguna, string $kataSandi) {
            $pengguna->forceFill(['password' => $kataSandi])->save();
        }
    );

    return $status === Password::PASSWORD_RESET
        ? redirect()->route('login')->with('status', __($status))
        : back()->withErrors(['email' => [__($status)]]);
}

Kesalahan umum#

Kesalahan pertama adalah memverifikasi kata sandi dengan membandingkan dua keluaran Hash::make() secara langsung. Salt acak membuat keluaran itu hampir pasti berbeda untuk kata sandi yang sama, sehingga perbandingan seperti Hash::make($sandi) === $hashTersimpan selalu bernilai false. Perbaikannya adalah memanggil Hash::check($sandi, $hashTersimpan) sebagaimana ditunjukkan pada bagian Anatomi hash dan peran salt.

Kesalahan kedua adalah meng-hash kata sandi dengan md5() atau sha1(). Kedua fungsi itu dirancang untuk memeriksa integritas data dengan cepat, sedangkan kecepatan justru melemahkan kata sandi karena jutaan tebakan dapat diuji dalam satu detik. Algoritma hashing kata sandi seperti bcrypt dan argon2id dirancang lambat dan menyertakan salt secara otomatis, sehingga facade Hash tetap menjadi satu-satunya alat yang diperlukan.

Kesalahan ketiga adalah mencari baris token reset di database dengan token yang muncul pada tautan email, misalnya where('token', $token) pada tabel password_reset_tokens. Kolom tersebut menyimpan hash dari token, sehingga pencarian dengan teks token aslinya tidak pernah menemukan baris apa pun. Verifikasi token tetap diserahkan kepada Password::reset(), yang membandingkan token melalui Hash::check() sama seperti kata sandi.

Ringkasan#

  • Hash::make() menghasilkan hash satu arah dengan salt acak, dan Hash::check() adalah cara verifikasi yang benar karena membaca salt dari hash yang tersimpan.
  • Cast hashed pada model User meng-hash kata sandi secara otomatis saat disimpan dan tidak meng-hash ulang nilai yang sudah berupa hash.
  • Driver bawaan bcrypt menggunakan faktor biaya 12, argon2id tersedia sebagai alternatif yang mengandalkan memori, dan rehash_on_login menghitung ulang hash lama setiap kali pengguna login.
  • Alur reset menyimpan hash token pada tabel password_reset_tokens dengan masa berlaku 60 menit, memverifikasinya melalui Password::reset(), lalu menghapus baris token setelah kata sandi berhasil diganti.

Referensi#