Cara Membuat E-Voting yang Rahasia dan Anti Suara Ganda di PHP & MySQL
Membuat aplikasi e-voting kelihatannya sederhana: tabel kandidat, tabel pemilih, tombol pilih, selesai. Tapi ada dua syarat yang sering dilupakan dan justru paling penting dalam pemilihan:
- Rahasia: tidak boleh ada yang bisa tahu siapa memilih siapa, termasuk admin yang punya akses database.
- Satu orang satu suara: tidak boleh ada suara ganda, bahkan jika tombol diklik dua kali atau pemilih login dari dua perangkat.
Di tutorial ini kita bahas cara mewujudkan keduanya dengan PHP & MySQL. Contohnya diambil dari aplikasi E-Voting Pemilihan Ketua OSIS & Organisasi yang bisa kamu download gratis di Papicode (link di akhir artikel).
1. Kesalahan paling umum: menyimpan pilihan di tabel pemilih
Banyak tutorial e-voting menyimpan data seperti ini:
-- JANGAN: siapa pun yang membuka database tahu Budi memilih kandidat 2
CREATE TABLE suara (
id INT AUTO_INCREMENT PRIMARY KEY,
pemilih_id INT,
kandidat_id INT,
waktu DATETIME
);
Desain ini memudahkan rekap, tetapi menghilangkan asas rahasia.
2. Pisahkan "sudah memilih" dari "memilih siapa"
Kita butuh dua informasi yang sengaja tidak dihubungkan:
- Apakah seseorang sudah memilih? Ini disimpan di tabel
pemilih, untuk mencegah suara ganda dan menghitung partisipasi. - Berapa suara yang didapat tiap kandidat? Ini disimpan di tabel
suara, tanpa identitas pemilih.
CREATE TABLE pemilih (
id INT AUTO_INCREMENT PRIMARY KEY,
pemilihan_id INT NOT NULL,
nis VARCHAR(20) NOT NULL,
nama VARCHAR(100) NOT NULL,
token CHAR(6) NOT NULL,
sudah_memilih TINYINT(1) NOT NULL DEFAULT 0,
waktu_memilih DATETIME NULL,
UNIQUE KEY uk_nis (pemilihan_id, nis),
UNIQUE KEY uk_token (pemilihan_id, token)
) ENGINE=InnoDB;
-- Kotak suara: TIDAK ADA kolom pemilih_id
CREATE TABLE suara (
id CHAR(32) PRIMARY KEY, -- acak, bukan AUTO_INCREMENT
pemilihan_id INT NOT NULL,
kandidat_id INT NOT NULL,
jam DATETIME NOT NULL -- dibulatkan ke jam, bukan detik
) ENGINE=InnoDB;
Kenapa ID acak dan jam dibulatkan?
Meski tidak ada pemilih_id, admin yang jeli masih bisa mencocokkan data:
- Kalau ID suara auto increment, suara #57 adalah suara ke-57. Cocokkan dengan pemilih ke-57 yang diurutkan berdasarkan
waktu_memilih, dan kerahasiaan pun bocor. - Kalau
suara.waktudisimpan sampai detik, suara jam 09:14:32 bisa dicocokkan dengan pemilih yangwaktu_memilih-nya 09:14:32.
Dengan ID acak (bin2hex(random_bytes(16))) dan jam dibulatkan (date('Y-m-d H:00:00')), suara di jam yang sama tidak bisa dibedakan satu sama lain.
3. Satu orang satu suara: UPDATE bersyarat + transaksi
Cara naif: SELECT sudah_memilih, kalau 0 lalu INSERT suara dan UPDATE pemilih. Celahnya: dua request yang datang bersamaan (klik ganda, atau dua perangkat) sama-sama membaca sudah_memilih = 0, lalu keduanya memasukkan suara.
Solusinya: jadikan UPDATE bersyarat sebagai "gerbang" di dalam transaksi, lalu periksa rowCount():
<?php
$pdo->beginTransaction();
try {
// Gerbang: hanya SATU request yang bisa mengubah 0 -> 1
$st = $pdo->prepare('UPDATE pemilih SET sudah_memilih = 1, waktu_memilih = NOW()
WHERE id = ? AND sudah_memilih = 0');
$st->execute([$pemilihId]);
if ($st->rowCount() !== 1) {
throw new RuntimeException('Hak pilih ini sudah digunakan.');
}
// Suara tanpa identitas: ID acak, jam dibulatkan
$pdo->prepare('INSERT INTO suara (id, pemilihan_id, kandidat_id, jam) VALUES (?, ?, ?, ?)')
->execute([bin2hex(random_bytes(16)), $pemilihanId, $kandidatId, date('Y-m-d H:00:00')]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
echo $e->getMessage();
}
Kenapa aman? InnoDB mengunci baris pemilih saat UPDATE. Request kedua menunggu, lalu setelah request pertama commit, kondisi sudah_memilih = 0 sudah tidak terpenuhi, sehingga rowCount() bernilai 0 dan suara kedua tidak pernah masuk.
Karena UPDATE dan INSERT berada dalam satu transaksi, tidak mungkin terjadi "pemilih tercatat sudah memilih tetapi suaranya tidak masuk", atau sebaliknya.
4. Cek integritas otomatis
Dengan desain di atas, berlaku aturan sederhana yang bisa ditampilkan di dashboard panitia:
SELECT
(SELECT COUNT(*) FROM pemilih WHERE pemilihan_id = 1 AND sudah_memilih = 1) AS pemilih_sudah,
(SELECT COUNT(*) FROM suara WHERE pemilihan_id = 1) AS jumlah_suara;
-- Kedua angka harus SAMA. Kalau berbeda, ada yang tidak beres.
5. Token yang mudah diketik dan sulit ditebak
<?php
function buat_token(): string
{
// Tanpa O/0 dan I/1/L supaya tidak salah ketik
$abjad = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
$t = '';
for ($i = 0; $i < 6; $i++) {
$t .= $abjad[random_int(0, strlen($abjad) - 1)];
}
return $t;
}
random_int() memakai sumber acak yang aman untuk kriptografi. Jangan pakai rand() atau md5(time()), karena hasilnya bisa ditebak. Dengan 31 karakter dan panjang 6 ada sekitar 887 juta kombinasi. Tambahkan juga batas percobaan login supaya token tidak bisa ditebak dengan mencoba berulang kali, dan bandingkan token dengan hash_equals().
6. Jangan tampilkan hasil saat pemilihan masih berjalan
Hasil sementara bisa memengaruhi pemilih yang belum memilih. Batasi di sisi server:
if ($role !== 'admin' && status_pemilihan($pemilihan) !== 'tutup') {
exit('Rekap dibuka setelah pemilihan ditutup.');
}
Ringkasan
- Pisahkan status "sudah memilih" (tabel pemilih) dari isi suara (tabel suara).
- Tabel suara tanpa pemilih_id, dengan ID acak dan waktu dibulatkan.
- Cegah suara ganda dengan UPDATE ... WHERE sudah_memilih = 0 + cek
rowCount()di dalam transaksi. - Pasang cek integritas: jumlah suara harus sama dengan jumlah pemilih yang sudah memilih.
- Buat token dengan
random_int(), batasi percobaan login, dan tahan hasil sampai pemilihan ditutup.
Download source code lengkapnya
Semua logika di atas sudah dipakai di E-Voting Pemilihan Ketua OSIS & Organisasi PHP Native & MySQL: bilik suara mode kios, token per pemilih, impor DPT dari Excel, cetak kartu token, monitor partisipasi per kelas, rekap, dan berita acara. Gratis dan langsung jalan di XAMPP.
Source code lengkapnya gratis
Lihat semua yang dibahas di artikel ini langsung berjalan.