Compare commits

..

1 Commits

Author SHA1 Message Date
8c8863ec9a Merge pull request 'production' (#20) from production into main
Reviewed-on: #20
2026-05-05 07:59:15 +00:00
3 changed files with 143 additions and 247 deletions

View File

@ -5,17 +5,51 @@ 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;
public function index(){
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]);
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',
'title' => 'Login Admin | Order Gizi'
];
return view('auth.index', $data);
}
@ -35,30 +69,25 @@ class AuthController extends Controller
$now = time();
$rateKey = 'login:' . $request->ip() . ':' . strtolower((string) $request->input('username'));
$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('username'))
if (RateLimiter::tooManyAttempts($rateKey, $this->maxLoginAttempts)) {
return back()
->withInput($request->only('username'))
->with(['alertError' => 'rate']);
}
$backoffKey = $rateKey . ':backoff_until';
$until = (int) $request->session()->get($backoffKey, 0);
if($until > $now){
return back()->withInput($request->only('username'))
->with(['alertError' => 'backoff']);
}
$this->ensureCaptchaValid();
$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('username'))
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)
@ -69,18 +98,13 @@ class AuthController extends Controller
if(Auth::attempt($credentials)){
$request->session()->regenerate();
$request->session()->forget($rateKey);
$request->session()->forget($backoffKey);
RateLimiter::clear($rateKey);
return redirect()->intended('/dashboard');
}
// 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);
RateLimiter::hit($rateKey, $this->loginDecaySeconds);
$this->refreshCaptcha();
return back()->with(['alertError' => 'Gagal Login!']);
}
@ -92,26 +116,27 @@ class AuthController extends Controller
return redirect('/login');
}
public function captcha(){
public function captcha(Request $request){
$this->ensureCaptchaValid();
$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');
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;
$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);
for($i = 0; $i < 6; $i++){
// noise lines
for ($i = 0; $i < 6; $i++) {
imageline(
$img,
random_int(0, $width),
@ -122,13 +147,15 @@ class AuthController extends Controller
);
}
for($i = 0; $i < 180; $i++){
// 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 = imagefontwidth($font);
$textHeight = imagefontheight($font);
$x = (int) (($width - $textWidth) / 2);
$y = (int) (($height - $textHeight) / 2);
imagestring($img, $font, $x, $y, $captcha, $fg);
@ -137,17 +164,9 @@ class AuthController extends Controller
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 generateCaptchaCode(int $length = 6): string
{
$chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
$out = '';
$max = strlen($chars) - 1;
for ($i = 0; $i < $length; $i++){
$out .= $chars[random_int(0, $max)];
}
return $out;
return response($png, 200)
->header('Content-Type', 'image/png')
->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
}
}

Binary file not shown.

View File

@ -47,149 +47,25 @@
<!--? Config: Mandatory theme config file contain global vars & default theme options, Set your preferred theme option in this file. -->
<script src="{{ ver('/assets/js/config.js') }}"></script>
</head>
<style>
body{
background-color: #E2E7E0;
}
.login-wrapper{
min-height: 650px;
}
.carousel-inner{
height: 490px;
}
.carousel-item{
height: 490px;
}
.carousel-item img{
width: 100%;
height: 100%;
object-fit: contain;
}
.login-card{
min-height: 300px;
}
.login-card .card-body{
display:flex;
flex-direction:column;
justify-content:center;
}
.carousel-indicators{
bottom:-35px;
}
.carousel-indicators button{
width:10px !important;
height:10px !important;
border-radius:50% !important;
border:none !important;
margin:0 4px !important;
background:#adb5bd !important;
opacity:1 !important;
}
.carousel-indicators .active{
background:#212529 !important;
transform:scale(1.3);
}
.carousel-control-prev,
.carousel-control-next{
width:45px;
}
.carousel-control-prev-icon,
.carousel-control-next-icon{
background-color:#fff;
border-radius:50%;
padding:18px;
box-shadow:0 2px 8px rgba(0,0,0,.15);
filter: invert(1);
}
</style>
<body>
<!-- Content -->
<div class="container">
<div class="row min-vh-100">
<div class="col-lg-8 d-none d-lg-block">
<div class="text-center pt-5">
<img src="/logo/logo_rsabhk.png"
class="img-fluid"
style="max-height:90px;">
<h5 class="fw-bold mt-3 mb-2">
RUMAH SAKIT ANAK DAN BUNDA HARAPAN KITA
</h5>
</div>
<div id="loginCarousel"
class="carousel slide"
data-bs-ride="carousel"
data-bs-interval="3000">
<!-- Indicator -->
<div class="carousel-indicators">
<button type="button"
data-bs-target="#loginCarousel"
data-bs-slide-to="0"
class="active"
aria-current="true"></button>
<button type="button"
data-bs-target="#loginCarousel"
data-bs-slide-to="1"></button>
<button type="button"
data-bs-target="#loginCarousel"
data-bs-slide-to="2"></button>
</div>
<div class="carousel-inner">
<div class="carousel-item active text-center">
<img src="https://iki-sdm.rsabhk.co.id/metronic/assets/media/illustrations/sigma-1/17.png"
class="carousel-image">
</div>
<div class="carousel-item text-center">
<img src="https://iki-sdm.rsabhk.co.id/metronic/assets/media/illustrations/dozzy-1/6.png"
class="carousel-image">
</div>
<div class="carousel-item text-center">
<img src="https://iki-sdm.rsabhk.co.id/metronic/assets/media/illustrations/unitedpalms-1/12.png"
class="carousel-image">
</div>
</div>
</div>
</div>
<div class="col-md-4 d-flex flex-column justify-content-center mb-5">
<div class="card d-flex flex-row justify-content-center align-items-center p-3 fw-bold mb-3 fs-4 text-primary">
<i class="menu-icon tf-icons bx bx-user-circle me-2 fw-bold rounded fs-2"></i>
<span>Dashboard Admin GIZI</span>
</div>
<div class="card login-card">
<div class="container-xxl">
<div class="authentication-wrapper authentication-basic container-p-y">
<div class="authentication-inner">
<!-- Register -->
<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 fw-bold" role="alert">
@if(session('alertError') === 'rate')
@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.
@elseif(session('alertError') === 'captcha')
Captcha tidak sesuai
@else
Username atau password salah!
@endif
@ -241,10 +117,6 @@
<div class="mb-3">
<button class="btn btn-primary d-grid w-100" type="submit">Login</button>
</div>
<div class="alert alert-info" role="alert">
<b>Info Juknis</b> <br/>
<a href="/assets/juknis.pptx" class="fw-semibold text-primary"><u>Silahkan klik ini untuk download juknis</u></a>
</div>
</form>
</div>
</div>
@ -261,9 +133,14 @@
<script src="{{ ver('/assets/vendor/js/menu.js') }}"></script>
<!-- endbuild -->
<!-- Vendors JS -->
<!-- Main JS -->
<script src="{{ ver('/assets/js/main.js') }}"></script>
<!-- Page JS -->
<!-- Place this tag in your head or just before your close body tag. -->
<script async defer src="https://buttons.github.io/buttons.js"></script>
</body>