add captcha

This commit is contained in:
JokoPrasetio 2026-05-05 14:55:06 +07:00
parent a831b56177
commit d4cd157c16
4 changed files with 178 additions and 12 deletions

View File

@ -4,26 +4,108 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
class AuthController extends Controller
{
private int $captchaTtlSeconds = 120;
private int $loginDecaySeconds = 60;
private int $maxLoginAttempts = 10;
private function generateCaptchaCode(int $length = 6): string
{
// Avoid ambiguous chars: 0,O,1,I,l
$chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
$out = '';
$max = strlen($chars) - 1;
for ($i = 0; $i < $length; $i++) {
$out .= $chars[random_int(0, $max)];
}
return $out;
}
private function refreshCaptcha(): string
{
$captcha = $this->generateCaptchaCode(6);
session([
'login_captcha' => $captcha,
'login_captcha_created_at' => now()->getTimestamp(),
]);
return $captcha;
}
private function ensureCaptchaValid(): void
{
$createdAt = (int) session('login_captcha_created_at', 0);
$expired = $createdAt <= 0 || (now()->getTimestamp() - $createdAt) > $this->captchaTtlSeconds;
if ($expired || (string) session('login_captcha', '') === '') {
$this->refreshCaptcha();
}
}
public function index(){
$this->refreshCaptcha();
$data = [
'title' => 'Login Admin | Order Gizi'
];
return view('auth.index', $data);
}
public function authanticate(){
$credentials = request()->validate([
public function authanticate(Request $request){
$validated = $request->validate([
'username' => 'required',
'password' => 'required'
'password' => 'required',
'captcha' => 'required',
'website' => 'nullable',
]);
if (trim((string) $request->input('website', '')) !== '') {
return back()->withInput($request->only('username'))
->with(['alertError' => 'Gagal Login!']);
}
$now = time();
$rateKey = 'login:' . $request->ip() . ':' . strtolower((string) $request->input('username'));
if (RateLimiter::tooManyAttempts($rateKey, $this->maxLoginAttempts)) {
return back()
->withInput($request->only('username'))
->with(['alertError' => 'rate']);
}
$this->ensureCaptchaValid();
$expectedCaptcha = (string) session('login_captcha', '');
$givenCaptcha = strtoupper(preg_replace('/\s+/', '', (string) $request->input('captcha', '')));
if ($expectedCaptcha === '' || !hash_equals(strtoupper($expectedCaptcha), (string) $givenCaptcha)) {
RateLimiter::hit($rateKey, $this->loginDecaySeconds);
$this->refreshCaptcha();
return back()
->withInput($request->only('username'))
->with(['alertError' => 'captcha']);
}
// One-time use
$request->session()->forget('login_captcha');
$request->session()->forget('login_captcha_created_at');
// IMPORTANT: only pass auth credentials to Auth::attempt
// (do not include captcha / honeypot fields, otherwise Laravel will query non-existent columns)
$credentials = [
'username' => (string) ($validated['username'] ?? ''),
'password' => (string) ($validated['password'] ?? ''),
];
if(Auth::attempt($credentials)){
request()->session()->regenerate();
$request->session()->regenerate();
RateLimiter::clear($rateKey);
return redirect()->intended('/dashboard');
}
return back()->with(['alertError' => 'Terdapat kesalahan disini!']);
RateLimiter::hit($rateKey, $this->loginDecaySeconds);
$this->refreshCaptcha();
return back()->with(['alertError' => 'Gagal Login!']);
}
public function logout()
@ -33,4 +115,58 @@ class AuthController extends Controller
request()->session()->regenerateToken();
return redirect('/login');
}
public function captcha(Request $request){
$this->ensureCaptchaValid();
$captcha = (string) session('login_captcha', '');
if (!function_exists('imagecreatetruecolor')) {
return response('GD extension is not available', Response::HTTP_INTERNAL_SERVER_ERROR)
->header('Content-Type', 'text/plain');
}
$width = 140;
$height = 44;
$img = imagecreatetruecolor($width, $height);
$bg = imagecolorallocate($img, 245, 247, 250);
$fg = imagecolorallocate($img, 35, 45, 70);
$noise = imagecolorallocate($img, 120, 130, 150);
imagefilledrectangle($img, 0, 0, $width, $height, $bg);
// noise lines
for ($i = 0; $i < 6; $i++) {
imageline(
$img,
random_int(0, $width),
random_int(0, $height),
random_int(0, $width),
random_int(0, $height),
$noise
);
}
// noise dots
for ($i = 0; $i < 180; $i++) {
imagesetpixel($img, random_int(0, $width - 1), random_int(0, $height - 1), $noise);
}
// draw text (built-in font to avoid font dependency)
$font = 5;
$textWidth = imagefontwidth($font) * strlen($captcha);
$textHeight = imagefontheight($font);
$x = (int) (($width - $textWidth) / 2);
$y = (int) (($height - $textHeight) / 2);
imagestring($img, $font, $x, $y, $captcha, $fg);
ob_start();
imagepng($img);
$png = ob_get_clean();
imagedestroy($img);
return response($png, 200)
->header('Content-Type', 'image/png')
->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
}
}

View File

@ -2,7 +2,10 @@
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
@ -21,6 +24,11 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
RateLimiter::for('login', function (Request $request) {
// Coarse protection against floods; detailed failure-based limiting stays in AuthController.
return Limit::perMinute(60)->by($request->ip());
});
Blade::component('dashboard.pesanan.components.modalExport', 'modalExport');
Blade::component('dashboard.pesanan.components.infoPesanan', 'infoPesanan');
if ($this->app->environment('production')) {

View File

@ -58,11 +58,19 @@
<div class="card">
<div class="card-body">
<p class="mb-4">Please sign-in to your account</p>
@if (session()->has('alertError'))
<div class="alert alert-danger" role="alert">
Username atau password salah!
</div>
@endif
@if (session()->has('alertError'))
<div class="alert alert-danger fw-bold" role="alert">
@if(session('alertError') === 'captcha')
Captcha salah!
@elseif(session('alertError') === 'rate')
Terlalu banyak percobaan login. Coba lagi dalam 1 menit.
@elseif(session('alertError') === 'backoff')
Mohon tunggu beberapa detik sebelum mencoba lagi.
@else
Username atau password salah!
@endif
</div>
@endif
<form action="/login" class="mb-3" method="POST">
@csrf
<div class="mb-3">
@ -92,6 +100,20 @@
<span class="input-group-text cursor-pointer"><i class="bx bx-hide"></i></span>
</div>
</div>
<div class="mb-4">
<label class="form-label">Captcha</label>
<div class="d-flex align-items-center gap-2">
<img
src="{{ route('captcha.login') }}?t={{ time() }}"
alt="captcha"
class="border rounded"
style="height: 44px; width: 140px; object-fit: cover;"
>
<input type="text" name="captcha" class="form-control text-uppercase" placeholder="Masukkan kode di gambar" autocomplete="off" required>
<a href="/login" class="btn btn-outline-secondary" title="Refresh captcha">Refresh</a>
</div>
<div class="form-text text-muted">Masukkan kode sesuai yang ditampilkan (huruf tidak membedakan kapital/kecil).</div>
</div>
<div class="mb-3">
<button class="btn btn-primary d-grid w-100" type="submit">Login</button>
</div>

View File

@ -21,8 +21,8 @@ use Illuminate\Support\Facades\Route;
// return view('layouts.blank');
// });
Route::get('/login', [AuthController::class, 'index'])->name('login')->middleware('guest');
Route::post('/login', [AuthController::class, 'authanticate']);
Route::post('/login', [AuthController::class, 'authanticate'])->middleware('throttle:login');
Route::get('/captcha/login', [AuthController::class, 'captcha'])->name('captcha.login');
Route::group(['middleware' => ['auth']], function(){
Route::group(['prefix' => 'dashboard'], function(){