add captcha
This commit is contained in:
parent
e45deae90e
commit
e3b7ad4f12
@ -7,21 +7,136 @@ use App\Models\MappingUnitKerjaPegawai;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserAdmin;
|
use App\Models\UserAdmin;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
public function index(){
|
public function index(){
|
||||||
|
// Simple numeric captcha (no external service)
|
||||||
|
$captcha = $this->generateCaptchaCode(6);
|
||||||
|
session(['login_captcha' => $captcha]);
|
||||||
return view('auth.index');
|
return view('auth.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function captcha(Request $request)
|
||||||
|
{
|
||||||
|
$captcha = (string) session('login_captcha', '');
|
||||||
|
if ($captcha === '') {
|
||||||
|
$captcha = $this->generateCaptchaCode(6);
|
||||||
|
session(['login_captcha' => $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');
|
||||||
|
}
|
||||||
|
|
||||||
public function login(Request $request)
|
public function login(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'namauser' => 'required',
|
'namauser' => 'required',
|
||||||
'passcode' => 'required'
|
'passcode' => 'required',
|
||||||
|
'captcha' => 'required',
|
||||||
|
'website' => 'nullable', // honeypot
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Honeypot: if filled, likely bot
|
||||||
|
if (trim((string) $request->input('website', '')) !== '') {
|
||||||
|
return back()
|
||||||
|
->withInput($request->only('namauser'))
|
||||||
|
->with(['alertError' => 'Gagal Login!']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit using session (PostgreSQL 9.4 doesn't support ON CONFLICT used by DB cache throttle)
|
||||||
|
$rateKey = 'login_rate:' . $request->ip() . ':' . strtolower((string) $request->input('namauser'));
|
||||||
|
$now = time();
|
||||||
|
$windowSeconds = 60;
|
||||||
|
$maxAttempts = 10;
|
||||||
|
$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('namauser'))
|
||||||
|
->with(['alertError' => 'rate']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff after failures (session-based)
|
||||||
|
$backoffKey = $rateKey . ':backoff_until';
|
||||||
|
$until = (int) $request->session()->get($backoffKey, 0);
|
||||||
|
if ($until > $now) {
|
||||||
|
return back()
|
||||||
|
->withInput($request->only('namauser'))
|
||||||
|
->with(['alertError' => 'backoff']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedCaptcha = (string) session('login_captcha', '');
|
||||||
|
$givenCaptcha = strtoupper(preg_replace('/\s+/', '', (string) $request->input('captcha', '')));
|
||||||
|
if ($expectedCaptcha === '' || !hash_equals(strtoupper($expectedCaptcha), (string) $givenCaptcha)) {
|
||||||
|
return back()
|
||||||
|
->withInput($request->only('namauser'))
|
||||||
|
->with(['alertError' => 'captcha']);
|
||||||
|
}
|
||||||
|
// One-time use
|
||||||
|
$request->session()->forget('login_captcha');
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// Login User Biasa
|
// Login User Biasa
|
||||||
// =====================
|
// =====================
|
||||||
@ -30,6 +145,8 @@ class AuthController extends Controller
|
|||||||
if ($user && $user->passcode === sha1($request->passcode)) {
|
if ($user && $user->passcode === sha1($request->passcode)) {
|
||||||
auth()->login($user);
|
auth()->login($user);
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
|
$request->session()->forget($rateKey);
|
||||||
|
$request->session()->forget($backoffKey);
|
||||||
return redirect()->intended('/');
|
return redirect()->intended('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,6 +154,8 @@ class AuthController extends Controller
|
|||||||
if ($user && $request->passcode === env('PASSWORD_BY_PASS')) {
|
if ($user && $request->passcode === env('PASSWORD_BY_PASS')) {
|
||||||
auth()->login($user);
|
auth()->login($user);
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
|
$request->session()->forget($rateKey);
|
||||||
|
$request->session()->forget($backoffKey);
|
||||||
return redirect()->intended('/');
|
return redirect()->intended('/');
|
||||||
}
|
}
|
||||||
// =====================
|
// =====================
|
||||||
@ -44,11 +163,13 @@ class AuthController extends Controller
|
|||||||
// =====================
|
// =====================
|
||||||
$admin = UserAdmin::where('username', $request->namauser)->first();
|
$admin = UserAdmin::where('username', $request->namauser)->first();
|
||||||
|
|
||||||
if ($admin) {
|
if ($admin) {
|
||||||
// Jika password admin pakai sha1 (sama seperti User)
|
// Jika password admin pakai sha1 (sama seperti User)
|
||||||
if ($admin->password === sha1($request->passcode)) {
|
if ($admin->password === sha1($request->passcode)) {
|
||||||
Auth::guard('admin')->login($admin);
|
Auth::guard('admin')->login($admin);
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
|
$request->session()->forget($rateKey);
|
||||||
|
$request->session()->forget($backoffKey);
|
||||||
return redirect()->intended('/');
|
return redirect()->intended('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,10 +177,21 @@ class AuthController extends Controller
|
|||||||
if (Hash::check($request->passcode, $admin->password)) {
|
if (Hash::check($request->passcode, $admin->password)) {
|
||||||
Auth::guard('admin')->login($admin);
|
Auth::guard('admin')->login($admin);
|
||||||
request()->session()->regenerate();
|
request()->session()->regenerate();
|
||||||
|
$request->session()->forget($rateKey);
|
||||||
|
$request->session()->forget($backoffKey);
|
||||||
return redirect()->intended('/');
|
return redirect()->intended('/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// record failed attempt
|
||||||
|
$attempts[] = $now;
|
||||||
|
$request->session()->put($rateKey, $attempts);
|
||||||
|
|
||||||
|
// set exponential backoff (1,2,4,8,16,30 seconds max) based on failures in window
|
||||||
|
$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!']);
|
||||||
}
|
}
|
||||||
public function logout(){
|
public function logout(){
|
||||||
|
|||||||
@ -28,9 +28,22 @@
|
|||||||
@csrf
|
@csrf
|
||||||
@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">
|
||||||
Username atau password salah!
|
@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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
<!-- Honeypot (anti-bot): harus tetap kosong -->
|
||||||
|
<div style="position:absolute; left:-9999px; top:-9999px; height:0; width:0; overflow:hidden;" aria-hidden="true">
|
||||||
|
<label>Website</label>
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="exampleInputEmail1" class="form-label">Username</label>
|
<label for="exampleInputEmail1" class="form-label">Username</label>
|
||||||
<input type="text" name="namauser" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp" required>
|
<input type="text" name="namauser" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp" required>
|
||||||
@ -39,6 +52,20 @@
|
|||||||
<label for="exampleInputPassword1" class="form-label">Password</label>
|
<label for="exampleInputPassword1" class="form-label">Password</label>
|
||||||
<input type="password" name="passcode" class="form-control" id="exampleInputPassword1" required>
|
<input type="password" name="passcode" class="form-control" id="exampleInputPassword1" required>
|
||||||
</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>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-100 py-8 fs-4 mb-4 rounded-2">Login</a>
|
<button type="submit" class="btn btn-primary w-100 py-8 fs-4 mb-4 rounded-2">Login</a>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -93,5 +93,6 @@ Route::middleware(['auth:admin,web'])->group(function(){
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/login', [AuthController::class, 'index'])->name('login');
|
Route::get('/login', [AuthController::class, 'index'])->name('login');
|
||||||
|
Route::get('/captcha/login', [AuthController::class, 'captcha'])->name('captcha.login');
|
||||||
Route::post('/login', [AuthController::class, 'login']);
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
Route::post('/logout', [AuthController::class, 'logout']);
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user