96 lines
3.6 KiB
PHP
96 lines
3.6 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* Self-hosted CAPTCHA for the admin login: the image is drawn by the server
|
|
* (GD), so no third-party service is called and the Content-Security-Policy
|
|
* stays strict. The answer lives only in the session, as a hash, and is valid
|
|
* once, for CAPTCHA_TTL seconds.
|
|
*/
|
|
|
|
const CAPTCHA_TTL = 300;
|
|
const CAPTCHA_LENGTH = 5;
|
|
const CAPTCHA_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I/L
|
|
|
|
function captcha_available(): bool
|
|
{
|
|
return function_exists('imagecreatetruecolor') && function_exists('imagepng');
|
|
}
|
|
|
|
/** Starts a new challenge (called when the login form is rendered). */
|
|
function captcha_new(): void
|
|
{
|
|
start_session();
|
|
if (!captcha_available()) {
|
|
// Without GD: a text question, weaker than the image, so login never becomes impossible.
|
|
error_log('[serubi] Ekstensi GD tidak tersedia: CAPTCHA memakai soal teks.');
|
|
$a = random_int(2, 9);
|
|
$b = random_int(2, 9);
|
|
$_SESSION['captcha'] = ['code' => (string)($a + $b), 'question' => "Berapa $a + $b?", 'exp' => time() + CAPTCHA_TTL];
|
|
return;
|
|
}
|
|
$code = '';
|
|
for ($i = 0; $i < CAPTCHA_LENGTH; $i++) {
|
|
$code .= CAPTCHA_ALPHABET[random_int(0, strlen(CAPTCHA_ALPHABET) - 1)];
|
|
}
|
|
$_SESSION['captcha'] = ['code' => $code, 'exp' => time() + CAPTCHA_TTL];
|
|
}
|
|
|
|
/** Text question when the image cannot be drawn, else null. */
|
|
function captcha_question(): ?string
|
|
{
|
|
return $_SESSION['captcha']['question'] ?? null;
|
|
}
|
|
|
|
/** Checks the answer and always consumes the challenge (one attempt per image). */
|
|
function captcha_check(string $answer): bool
|
|
{
|
|
start_session();
|
|
$c = $_SESSION['captcha'] ?? null;
|
|
unset($_SESSION['captcha']);
|
|
if (!$c || time() > $c['exp']) {
|
|
return false;
|
|
}
|
|
$answer = strtoupper(preg_replace('/\s+/', '', $answer));
|
|
return $answer !== '' && hash_equals($c['code'], $answer);
|
|
}
|
|
|
|
/** Outputs the PNG for the current challenge. */
|
|
function captcha_render(): void
|
|
{
|
|
start_session();
|
|
$code = (string)($_SESSION['captcha']['code'] ?? '');
|
|
$w = 200;
|
|
$h = 64;
|
|
$img = imagecreatetruecolor($w, $h);
|
|
imagefilledrectangle($img, 0, 0, $w, $h, imagecolorallocate($img, 244, 246, 250));
|
|
// background noise
|
|
for ($i = 0; $i < 9; $i++) {
|
|
$c = imagecolorallocate($img, random_int(140, 200), random_int(150, 205), random_int(170, 215));
|
|
imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $c);
|
|
}
|
|
// each glyph is drawn small (built-in font), then scaled and jittered
|
|
$x = 14;
|
|
foreach (str_split($code) as $ch) {
|
|
$g = imagecreatetruecolor(10, 16);
|
|
$bg = imagecolorallocate($g, 244, 246, 250);
|
|
imagefill($g, 0, 0, $bg);
|
|
imagecolortransparent($g, $bg);
|
|
imagestring($g, 5, 1, 0, $ch, imagecolorallocate($g, random_int(10, 60), random_int(30, 70), random_int(90, 140)));
|
|
$size = random_int(30, 38);
|
|
imagecopyresized($img, $g, $x, random_int(4, $h - $size - 10) + 6, 0, 0, (int)($size * 0.66), $size, 10, 16);
|
|
$x += random_int(31, 36);
|
|
}
|
|
// foreground noise over the text
|
|
for ($i = 0; $i < 3; $i++) {
|
|
imagesetthickness($img, 2);
|
|
imageline($img, 0, random_int(10, $h - 10), $w, random_int(10, $h - 10), imagecolorallocate($img, 60, 80, 130));
|
|
}
|
|
for ($i = 0; $i < 220; $i++) {
|
|
imagesetpixel($img, random_int(0, $w - 1), random_int(0, $h - 1), imagecolorallocate($img, random_int(60, 160), random_int(60, 160), random_int(60, 160)));
|
|
}
|
|
header('Content-Type: image/png');
|
|
header('Cache-Control: no-store, max-age=0');
|
|
imagepng($img);
|
|
}
|