production #20

Open
Joko wants to merge 3 commits from production into main
2 changed files with 57 additions and 76 deletions

View File

@ -5,51 +5,17 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
class AuthController extends Controller class AuthController extends Controller
{ {
private int $captchaTtlSeconds = 120;
private int $loginDecaySeconds = 60; private int $loginDecaySeconds = 60;
private int $maxLoginAttempts = 10; 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(){ public function index(){
$this->refreshCaptcha(); $captcha = $this->generateCaptchaCode(6);
session(['login_captcha' => $captcha]);
$data = [ $data = [
'title' => 'Login Admin | Order Gizi' 'title' => 'Login Admin | Order Gizi',
]; ];
return view('auth.index', $data); return view('auth.index', $data);
} }
@ -69,25 +35,30 @@ class AuthController extends Controller
$now = time(); $now = time();
$rateKey = 'login:' . $request->ip() . ':' . strtolower((string) $request->input('username')); $rateKey = 'login:' . $request->ip() . ':' . strtolower((string) $request->input('username'));
if (RateLimiter::tooManyAttempts($rateKey, $this->maxLoginAttempts)) { $windowSeconds = 60;
return back() $maxAttempts = 10;
->withInput($request->only('username')) $attempts = (array) $request->session()->get($rateKey, []);
$attempts = array_values(array_filter($attempts, fn($ts) => is_int($ts) && $ts > ($now - $windowSeconds)));
if(count($attempts) >= $maxAttempts){
return back()->withInput($request->only('username'))
->with(['alertError' => 'rate']); ->with(['alertError' => 'rate']);
} }
$this->ensureCaptchaValid(); $backoffKey = $rateKey . ':backoff_until';
$until = (int) $request->session()->get($backoffKey, 0);
if($until > $now){
return back()->withInput($request->only('username'))
->with(['alertError' => 'backoff']);
}
$expectedCaptcha = (string) session('login_captcha', ''); $expectedCaptcha = (string) session('login_captcha', '');
$givenCaptcha = strtoupper(preg_replace('/\s+/', '', (string) $request->input('captcha', ''))); $givenCaptcha = strtoupper(preg_replace('/\s+/', '', (string) $request->input('captcha', '')));
if($expectedCaptcha === '' || !hash_equals(strtoupper($expectedCaptcha), (string) $givenCaptcha)){ if($expectedCaptcha === '' || !hash_equals(strtoupper($expectedCaptcha), (string) $givenCaptcha)){
RateLimiter::hit($rateKey, $this->loginDecaySeconds); return back()->withInput($request->only('username'))
$this->refreshCaptcha();
return back()
->withInput($request->only('username'))
->with(['alertError' => 'captcha']); ->with(['alertError' => 'captcha']);
} }
// One-time use
$request->session()->forget('login_captcha'); $request->session()->forget('login_captcha');
$request->session()->forget('login_captcha_created_at');
// IMPORTANT: only pass auth credentials to Auth::attempt // IMPORTANT: only pass auth credentials to Auth::attempt
// (do not include captcha / honeypot fields, otherwise Laravel will query non-existent columns) // (do not include captcha / honeypot fields, otherwise Laravel will query non-existent columns)
@ -98,13 +69,18 @@ class AuthController extends Controller
if(Auth::attempt($credentials)){ if(Auth::attempt($credentials)){
$request->session()->regenerate(); $request->session()->regenerate();
RateLimiter::clear($rateKey); $request->session()->forget($rateKey);
$request->session()->forget($backoffKey);
return redirect()->intended('/dashboard'); return redirect()->intended('/dashboard');
} }
// record failed attempt
$attempts[] = $now;
$request->session()->put($rateKey, $attempts);
RateLimiter::hit($rateKey, $this->loginDecaySeconds); // set exponential backoff (1,2,4,8,16,30 seconds max) based on failures in window
$this->refreshCaptcha(); $failCount = count($attempts);
$delay = min(30, (int) pow(2, max(0, $failCount - 1)));
$request->session()->put($backoffKey, $now + $delay);
return back()->with(['alertError' => 'Gagal Login!']); return back()->with(['alertError' => 'Gagal Login!']);
} }
@ -116,26 +92,25 @@ class AuthController extends Controller
return redirect('/login'); return redirect('/login');
} }
public function captcha(Request $request){ public function captcha(){
$this->ensureCaptchaValid();
$captcha = (string) session('login_captcha', ''); $captcha = (string) session('login_captcha', '');
if($captcha === ''){
$captcha = $this->generateCaptchaCode(6);
session(['login_captcha' => $captcha]);
}
if(!function_exists('imagecreatetruecolor')){ if(!function_exists('imagecreatetruecolor')){
return response('GD extension is not available', Response::HTTP_INTERNAL_SERVER_ERROR) return response('GD extension is not available', Response::HTTP_INTERNAL_SERVER_ERROR)->header('Content-Type', 'text/plain');
->header('Content-Type', 'text/plain');
} }
$width=140; $width=140;
$height=44; $height=44;
$img = imagecreatetruecolor($width, $height); $img = imagecreatetruecolor($width, $height);
$bg = imagecolorallocate($img, 245, 247, 250); $bg = imagecolorallocate($img, 245, 247, 250);
$fg = imagecolorallocate($img, 35, 45, 70); $fg = imagecolorallocate($img, 35, 45, 70);
$noise = imagecolorallocate($img, 120, 130, 150); $noise = imagecolorallocate($img, 120, 130, 150);
imagefilledrectangle($img, 0, 0, $width, $height, $bg); imagefilledrectangle($img, 0, 0, $width, $height, $bg);
// noise lines
for($i = 0; $i < 6; $i++){ for($i = 0; $i < 6; $i++){
imageline( imageline(
$img, $img,
@ -147,15 +122,13 @@ class AuthController extends Controller
); );
} }
// noise dots
for($i = 0; $i < 180; $i++){ for($i = 0; $i < 180; $i++){
imagesetpixel($img, random_int(0, $width - 1), random_int(0, $height - 1), $noise); imagesetpixel($img, random_int(0, $width - 1), random_int(0, $height - 1), $noise);
} }
// draw text (built-in font to avoid font dependency)
$font = 5; $font = 5;
$textWidth = imagefontwidth($font) * strlen($captcha); $textWidth = imagefontwidth($font) * strlen($captcha);
$textHeight = imagefontheight($font); $textHeight = imagefontwidth($font);
$x = (int) (($width - $textWidth) / 2); $x = (int) (($width - $textWidth) / 2);
$y = (int) (($height - $textHeight) / 2); $y = (int) (($height - $textHeight) / 2);
imagestring($img, $font, $x, $y, $captcha, $fg); imagestring($img, $font, $x, $y, $captcha, $fg);
@ -164,9 +137,17 @@ class AuthController extends Controller
imagepng($img); imagepng($img);
$png = ob_get_clean(); $png = ob_get_clean();
imagedestroy($img); imagedestroy($img);
return response($png, 200)->header('Content-Type', 'image/png')->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
}
return response($png, 200) public function generateCaptchaCode(int $length = 6): string
->header('Content-Type', 'image/png') {
->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); $chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
$out = '';
$max = strlen($chars) - 1;
for ($i = 0; $i < $length; $i++){
$out .= $chars[random_int(0, $max)];
}
return $out;
} }
} }

View File

@ -60,12 +60,12 @@
<p class="mb-4">Please sign-in to your account</p> <p class="mb-4">Please sign-in to your account</p>
@if (session()->has('alertError')) @if (session()->has('alertError'))
<div class="alert alert-danger fw-bold" role="alert"> <div class="alert alert-danger fw-bold" role="alert">
@if(session('alertError') === 'captcha') @if(session('alertError') === 'rate')
Captcha salah!
@elseif(session('alertError') === 'rate')
Terlalu banyak percobaan login. Coba lagi dalam 1 menit. Terlalu banyak percobaan login. Coba lagi dalam 1 menit.
@elseif(session('alertError') === 'backoff') @elseif(session('alertError') === 'backoff')
Mohon tunggu beberapa detik sebelum mencoba lagi. Mohon tunggu beberapa detik sebelum mencoba lagi.
@elseif(session('alertError') === 'captcha')
Captcha tidak sesuai
@else @else
Username atau password salah! Username atau password salah!
@endif @endif