Alur masuk, daftar & lupa sandi
Alur autentikasi terlihat sederhana sampai kamu memperhatikan apa yang bocor dari pesan errornya. Ini bentuk yang benar untuk keempatnya.
Intisari
- Pesan error login harus seragam: "email atau sandi salah". Jangan pernah membedakan keduanya.
- Alur "lupa sandi" harus menjawab sama untuk email terdaftar dan tidak terdaftar.
- Token reset: acak kriptografis, di-hash sebelum disimpan, sekali pakai, kedaluwarsa 1 jam.
- Regenerasi ID sesi setelah login โ mencegah session fixation.
- Setelah ganti sandi, batalkan semua sesi lain dan beri tahu lewat email.
Masuk
// src/actions/index.ts
export const server = {
masuk: defineAction({
accept: "form",
input: z.object({
email: z.string().email(),
sandi: z.string().min(1).max(200),
tujuan: z.string().startsWith("/").max(200).optional(),
}),
handler: async ({ email, sandi, tujuan }, ctx) => {
const ip = ctx.locals.ipPembaca;
if (!(await bolehCoba(ip, email))) {
throw new ActionError({
code: "TOO_MANY_REQUESTS",
message: "Terlalu banyak percobaan. Coba lagi dalam 15 menit.",
});
}
const member = await masukDenganSandi(email, sandi, ip);
if (!member) {
await catatGagal(ip, email);
// SATU pesan untuk semua kegagalan.
throw new ActionError({
code: "UNAUTHORIZED",
message: "Email atau sandi salah.",
});
}
await buatSesi(ctx.cookies, member.id, ip, ctx.request.headers.get("user-agent") ?? "");
await hapusCatatanGagal(ip, email);
return { tujuan: tujuan ?? "/akun" };
},
}),
};
tujuan: z.string().startsWith("/") menutup open redirect. Tanpa
pemeriksaan itu, tautan /masuk?tujuan=https://situs-jahat.id akan melempar member ke situs
penipuan setelah mereka berhasil login โ dan karena mereka datang dari domainmu, mereka
cenderung percaya. Ini kerentanan favorit untuk phishing, dan satu baris Zod menutupnya.
Perhatikan juga: startsWith("/") saja tidak cukup kalau kamu tidak menolak
//situs-jahat.id โ itu URL protokol-relatif yang sah. Tambahkan pemeriksaan bahwa karakter
kedua bukan /.
Enumerasi akun: kebocoran yang paling umum
| Pesan | Yang bocor |
|---|---|
| "Email tidak terdaftar" | Email ini bukan member |
| "Sandi salah" | Email ini member |
| "Email atau sandi salah" | Tidak ada |
Dua pesan pertama membuat siapa pun bisa menyusun daftar email member portalmu dengan skrip sederhana. Untuk portal berita berbayar, daftar itu punya nilai jual โ dan kebocorannya adalah masalah privasi pelanggan, bukan sekadar masalah teknis.
Kebocoran yang sama bisa terjadi lewat waktu respons (dibahas di materi password) dan lewat halaman pendaftaran yang bilang "email sudah dipakai". Untuk yang terakhir, jawabannya: terima pendaftarannya, lalu kirim email โ kalau sudah terdaftar, isinya "seseorang mencoba mendaftar dengan email Anda; silakan masuk atau reset sandi".
Regenerasi ID sesi setelah login
export async function buatSesi(cookies: AstroCookies, memberId: number, โฆ) {
// Buang sesi anonim yang mungkin sudah ada sebelum login.
const lama = cookies.get("__Host-sesi")?.value;
if (lama) {
await dbTulis.deleteFrom("sesi").where("id", "=", lama).execute();
}
// ID baru, selalu.
const id = randomBytes(32).toString("base64url");
// โฆ
}
Tanpa ini, penyerang bisa menanam ID sesi ke browser korban sebelum korban login, lalu memakai ID yang sama setelahnya. Itu session fixation. Membuat ID baru saat login menutupnya sepenuhnya.
Lupa sandi
export const mintaResetSandi = defineAction({
accept: "form",
input: z.object({ email: z.string().email() }),
handler: async ({ email }, ctx) => {
const member = await cariMemberAktif(email);
if (member) {
const token = randomBytes(32).toString("base64url");
// Simpan HASH-nya, bukan tokennya.
await dbTulis
.insertInto("reset_sandi")
.values({
member_id: member.id,
token_hash: sha256(token),
kedaluwarsa: new Date(Date.now() + 60 * 60 * 1000),
dibuat_pada: new Date(),
})
.execute();
await kirimEmailReset(member.email, token);
}
// Jawaban SAMA, ada atau tidak ada.
return { pesan: "Kalau email itu terdaftar, kami sudah mengirim tautan reset." };
},
});
Token di-hash sebelum disimpan, sama seperti sandi. Kalau tabelmu bocor lewat SQL injection atau backup yang salah tempat, token mentah berarti penyerang bisa mengambil alih akun mana pun yang sedang dalam proses reset. Token yang di-hash tidak berguna bagi mereka. Ini pertahanan berlapis yang biayanya satu baris.
Memakai token reset
export const gantiSandi = defineAction({
accept: "form",
input: z.object({
token: z.string().min(20).max(100),
sandiBaru: z.string().min(12).max(200),
}),
handler: async ({ token, sandiBaru }, ctx) => {
const hasil = await dbTulis.transaction().execute(async (trx) => {
const baris = await trx
.selectFrom("reset_sandi")
.select(["id", "member_id"])
.where("token_hash", "=", sha256(token))
.where("kedaluwarsa", ">", new Date())
.where("dipakai_pada", "is", null)
.executeTakeFirst();
if (!baris) return null;
// Tandai terpakai DI DALAM transaksi โ sekali pakai, sungguhan.
await trx.updateTable("reset_sandi")
.set({ dipakai_pada: new Date() })
.where("id", "=", baris.id)
.execute();
await trx.updateTable("member")
.set({ password: await buatHash(sandiBaru), algoritma: "bcrypt12" })
.where("id", "=", baris.member_id)
.execute();
// Batalkan SEMUA sesi โ termasuk milik penyerang, kalau ada.
await trx.deleteFrom("sesi").where("member_id", "=", baris.member_id).execute();
return baris.member_id;
});
if (!hasil) {
throw new ActionError({ code: "BAD_REQUEST", message: "Tautan tidak valid atau sudah kedaluwarsa." });
}
await kirimEmailPemberitahuan(hasil, "Sandi akun Anda baru saja diubah");
return { pesan: "Sandi berhasil diubah. Silakan masuk." };
},
});
Menghapus semua sesi setelah ganti sandi adalah alasan utama fitur ini ada. Skenario yang sesungguhnya: akun member dibajak, penyerang sedang login. Member menyadarinya dan mengganti sandi. Kalau sesi penyerang tidak ikut dibatalkan, ia masih di dalam โ dan member mengira masalahnya sudah selesai. Email pemberitahuannya juga penting: kalau member tidak melakukannya, itu satu-satunya peringatan yang ia dapat.
Pendaftaran
| Langkah | Catatan |
|---|---|
| Normalisasi email | trim().toLowerCase(). Simpan satu bentuk saja |
| Verifikasi email | Wajib sebelum bisa berlangganan โ mencegah pendaftaran sampah |
| Rate limit per IP | Tanpa ini, satu skrip membuat sepuluh ribu akun semalam |
| Jangan bilang "email sudah dipakai" | Enumerasi. Kirim email alih-alih menampilkan pesan |
| Cek daftar sandi bocor | Lebih efektif daripada aturan komposisi karakter |
| Simpan waktu & IP pendaftaran | Berguna saat menyelidiki penyalahgunaan |
Keluar
export const keluar = defineAction({
accept: "form",
handler: async (_input, ctx) => {
const id = ctx.cookies.get("__Host-sesi")?.value;
if (id) {
await dbTulis.deleteFrom("sesi").where("id", "=", id).execute();
}
ctx.cookies.delete("__Host-sesi", { path: "/" });
return { ok: true };
},
});
Hapus barisnya di database, bukan hanya cookie-nya. Menghapus cookie saja berarti sesinya masih sah โ
siapa pun yang sempat menyalin nilainya masih bisa masuk. Dan keluar harus POST, bukan
GET: tautan logout berbasis GET bisa dipicu dari gambar di situs lain.
Latihan: bangun keempat alurnya. Lalu uji kebocorannya: kirim permintaan login dengan email
terdaftar dan tidak terdaftar, dan bandingkan pesan error dan waktu
respons keduanya โ pakai curl -w '%{time_total}'. Keduanya harus tidak bisa
dibedakan. Lalu coba /masuk?tujuan=//contoh-jahat.id dan pastikan ia ditolak.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.