โ† Semua pembelajaran / Astro Nol โ†’ Portal Berita
Fase 5 ยท Membership, Auth & Paywall

Alur masuk, daftar & lupa sandi

Alur autentikasi terlihat sederhana sampai kamu memperhatikan apa yang bocor dari pesan errornya. Ini bentuk yang benar untuk keempatnya.

Sumber asli owasp.org Artikel Rangkuman ~9 menit baca

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

PesanYang 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

LangkahCatatan
Normalisasi emailtrim().toLowerCase(). Simpan satu bentuk saja
Verifikasi emailWajib sebelum bisa berlangganan โ€” mencegah pendaftaran sampah
Rate limit per IPTanpa ini, satu skrip membuat sepuluh ribu akun semalam
Jangan bilang "email sudah dipakai"Enumerasi. Kirim email alih-alih menampilkan pesan
Cek daftar sandi bocorLebih efektif daripada aturan komposisi karakter
Simpan waktu & IP pendaftaranBerguna 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.