From b27039eaeb5e9631676a3d08978b6a7f94a76dd4 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 8 Jul 2026 10:21:14 +0700 Subject: [PATCH 1/4] tambah .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index aaec5046..0d3d06af 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ AGENTS.md tests/Browser/Screenshots/ tests/Browser/screenshots/ tests/Browser/.session_state.json +graphify-out \ No newline at end of file From dc706e9616854acc110f22f281b3af940e319349 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 15 Jul 2026 10:18:56 +0700 Subject: [PATCH 2/4] Terapkan Password History (10 Kata Sandi Terakhir) --- .../Auth/ResetPasswordController.php | 16 +-- .../ForcePasswordResetController.php | 12 +- app/Http/Requests/ResetPasswordRequest.php | 7 +- app/Models/User.php | 17 +-- app/Observers/UserObserver.php | 46 +++++++ app/Providers/AppServiceProvider.php | 3 + app/Rules/StrongPassword.php | 19 ++- config/password.php | 2 +- tests/Feature/PasswordHistoryTest.php | 128 ++++++++++++++++++ tests/Feature/PasswordResetFlowTest.php | 86 ++++++++++++ tests/Feature/StrongPasswordRuleTest.php | 81 +++++++++++ 11 files changed, 373 insertions(+), 44 deletions(-) create mode 100644 app/Observers/UserObserver.php create mode 100644 tests/Feature/PasswordHistoryTest.php create mode 100644 tests/Feature/PasswordResetFlowTest.php diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index 22f8fa74..14290db9 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -4,10 +4,8 @@ use App\Http\Controllers\Controller; use App\Http\Requests\ResetPasswordRequest; -use App\Models\PasswordHistory; use App\Providers\AppServiceProvider; use Illuminate\Foundation\Auth\ResetsPasswords; -use Illuminate\Support\Facades\Password; class ResetPasswordController extends Controller { @@ -75,26 +73,14 @@ protected function resetPassword($user, $password) { $expiryDays = config('password.expiry_days'); - // Save old password to history - if ($user->password) { - PasswordHistory::create([ - 'user_id' => $user->id, - 'password' => $user->password, - 'reason' => 'password_reset', - ]); - } - - // Set new password + $user->passwordHistoryReason = 'password_reset'; $user->password = $password; - // Set expiry if configured if ($expiryDays) { $user->password_expires_at = now()->addDays($expiryDays); } - // Reset force_password_reset flag $user->force_password_reset = false; - $user->save(); } } diff --git a/app/Http/Controllers/ForcePasswordResetController.php b/app/Http/Controllers/ForcePasswordResetController.php index 411e3d46..398ef030 100644 --- a/app/Http/Controllers/ForcePasswordResetController.php +++ b/app/Http/Controllers/ForcePasswordResetController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers; use App\Http\Requests\ForcePasswordResetRequest; -use App\Models\PasswordHistory; use App\Providers\AppServiceProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -39,22 +38,13 @@ public function reset(ForcePasswordResetRequest $request) $expiryDays = config('password.expiry_days'); - // Save old password to history - PasswordHistory::create([ - 'user_id' => $user->id, - 'password' => $user->password, - 'reason' => 'forced_reset_completed', - ]); - - // Set new password + $user->passwordHistoryReason = 'forced_reset_completed'; $user->password = $request->password; - // Set expiry if configured if ($expiryDays) { $user->password_expires_at = now()->addDays($expiryDays); } - // Reset force_password_reset flag $user->force_password_reset = false; $user->save(); diff --git a/app/Http/Requests/ResetPasswordRequest.php b/app/Http/Requests/ResetPasswordRequest.php index bfe40e89..b33bc456 100644 --- a/app/Http/Requests/ResetPasswordRequest.php +++ b/app/Http/Requests/ResetPasswordRequest.php @@ -22,10 +22,15 @@ public function authorize(): bool */ public function rules(): array { + $user = null; + if ($this->has('email')) { + $user = \App\Models\User::where('email', $this->input('email'))->first(); + } + return [ 'token' => ['required'], 'email' => ['required', 'email'], - 'password' => ['required', 'string', 'confirmed', new StrongPassword()], + 'password' => ['required', 'string', 'confirmed', new StrongPassword(user: $user)], ]; } diff --git a/app/Models/User.php b/app/Models/User.php index a2573fbb..07d2b028 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -127,6 +127,9 @@ public function delete() return parent::delete(); } + public ?string $passwordHistoryReason = null; + public ?string $oldPasswordHash = null; + public function team() { return $this->belongsToMany( @@ -265,27 +268,19 @@ public function requiresPasswordReset(): bool } /** - * Set password dengan expiry dan history + * Set password dengan expiry. + * Riwayat password lama otomatis disimpan oleh UserObserver. */ public function setPasswordWithHistory(string $password, string $reason = 'password_change', ?int $expiryDays = null): void { - // Simpan password lama ke history - if ($this->exists && $this->password) { - $this->passwordHistory()->create([ - 'password' => $this->password, - 'reason' => $reason, - ]); - } + $this->passwordHistoryReason = $reason; - // Set password baru $this->password = $password; - // Set expiry jika ditentukan if ($expiryDays !== null) { $this->password_expires_at = now()->addDays($expiryDays); } - // Reset flag force_password_reset $this->force_password_reset = false; $this->save(); diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php new file mode 100644 index 00000000..26b6858e --- /dev/null +++ b/app/Observers/UserObserver.php @@ -0,0 +1,46 @@ +exists && $user->isDirty('password')) { + $user->oldPasswordHash = $user->getOriginal('password'); + } + } + + public function saved(User $user): void + { + if (!isset($user->oldPasswordHash)) { + return; + } + + DB::transaction(function () use ($user) { + $reason = $user->passwordHistoryReason ?? 'password_change'; + + $user->passwordHistory()->create([ + 'password' => $user->oldPasswordHash, + 'reason' => $reason, + ]); + + $historyCount = config('password.history_count', 10); + if ($historyCount > 0) { + $recentIds = $user->passwordHistory() + ->orderBy('created_at', 'desc') + ->limit($historyCount) + ->pluck('id'); + + $user->passwordHistory() + ->whereNotIn('id', $recentIds) + ->delete(); + } + }); + + $user->oldPasswordHash = null; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a363a11b..f130cfbb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -16,6 +16,8 @@ use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; +use App\Models\User; +use App\Observers\UserObserver; use App\Observers\VisitorObserver; use Exception; use Illuminate\Support\Facades\Log; @@ -104,6 +106,7 @@ private function shareViewIdentitas(): void protected function configureObservers(): void { + User::observe(UserObserver::class); Visit::observe(VisitorObserver::class); } diff --git a/app/Rules/StrongPassword.php b/app/Rules/StrongPassword.php index 65dcadf8..a2208857 100644 --- a/app/Rules/StrongPassword.php +++ b/app/Rules/StrongPassword.php @@ -3,6 +3,7 @@ namespace App\Rules; use App\Models\PasswordHistory; +use App\Models\User; use Illuminate\Contracts\Validation\Rule; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Http; @@ -30,6 +31,11 @@ class StrongPassword implements Rule */ protected array $weakPatterns; + /** + * Specific user to check history against (for unauthenticated contexts). + */ + protected ?User $user = null; + /** * Common passwords list. */ @@ -41,13 +47,15 @@ class StrongPassword implements Rule public function __construct( ?int $minLength = null, ?bool $checkHibp = null, - ?int $historySize = null + ?int $historySize = null, + ?User $user = null ) { $this->minLength = $minLength ?? config('password.min_length', 12); $this->checkHibp = $checkHibp ?? config('password.check_hibp', true); - $this->historySize = $historySize ?? config('password.history_count', 5); + $this->historySize = $historySize ?? config('password.history_count', 10); $this->weakPatterns = config('password.weak_patterns', []); $this->commonPasswords = config('password.common_passwords', []); + $this->user = $user; } /** @@ -168,11 +176,12 @@ protected function isNotPwned(string $password): bool */ protected function isNotInHistory(string $password): bool { - if (!auth()->check()) { - return true; // No logged in user to check history for + $user = $this->user ?? (auth()->check() ? auth()->user() : null); + + if (!$user) { + return true; } - $user = auth()->user(); $passwordHistory = PasswordHistory::where('user_id', $user->id) ->orderBy('created_at', 'desc') ->limit($this->historySize) diff --git a/config/password.php b/config/password.php index c448fc8c..5c758469 100644 --- a/config/password.php +++ b/config/password.php @@ -42,7 +42,7 @@ | Set to 0 to disable password history check. | */ - 'history_count' => 5, + 'history_count' => 10, /* |-------------------------------------------------------------------------- diff --git a/tests/Feature/PasswordHistoryTest.php b/tests/Feature/PasswordHistoryTest.php new file mode 100644 index 00000000..e09a1b10 --- /dev/null +++ b/tests/Feature/PasswordHistoryTest.php @@ -0,0 +1,128 @@ +user = User::factory()->create([ + 'password' => 'OldP@ssw0rd123!', + ]); +}); + +it('stores old password hash in password_histories when password is changed', function () { + $this->user->password = 'NewP@ssw0rd456!'; + $this->user->save(); + + $this->assertDatabaseHas('password_histories', [ + 'user_id' => $this->user->id, + 'reason' => 'password_change', + ]); + + $history = PasswordHistory::where('user_id', $this->user->id)->first(); + expect(Hash::check('OldP@ssw0rd123!', $history->password))->toBeTrue(); +}); + +it('uses custom reason when passwordHistoryReason is set', function () { + $this->user->passwordHistoryReason = 'custom_reason'; + $this->user->password = 'NewP@ssw0rd456!'; + $this->user->save(); + + $this->assertDatabaseHas('password_histories', [ + 'user_id' => $this->user->id, + 'reason' => 'custom_reason', + ]); +}); + +it('does not store history for new users', function () { + $newUser = User::factory()->create([ + 'password' => 'FirstP@ssw0rd!', + ]); + + expect(PasswordHistory::where('user_id', $newUser->id)->count())->toBe(0); +}); + +it('does not store history when password is not changed', function () { + $this->user->name = 'Updated Name'; + $this->user->save(); + + expect(PasswordHistory::where('user_id', $this->user->id)->count())->toBe(0); +}); + +it('prunes old history entries beyond the configured limit', function () { + $historyCount = config('password.history_count', 10); + + for ($i = 0; $i < $historyCount; $i++) { + $this->user->passwordHistory()->create([ + 'password' => Hash::make("OldP@ssw0rd{$i}!"), + 'reason' => 'password_change', + ]); + } + + expect(PasswordHistory::where('user_id', $this->user->id)->count())->toBe($historyCount); + + $this->user->password = 'NewP@ssw0rdLatest!'; + $this->user->save(); + + $remaining = PasswordHistory::where('user_id', $this->user->id)->count(); + expect($remaining)->toBe($historyCount) + ->and($remaining)->toBeLessThanOrEqual($historyCount); +}); + +it('resets oldPasswordHash to null after processing', function () { + $observer = new UserObserver; + + $observer->saving($this->user); + expect(isset($this->user->oldPasswordHash))->toBeFalse(); + + $this->user->password = 'NewP@ssw0rd456!'; + + $observer->saving($this->user); + expect(isset($this->user->oldPasswordHash))->toBeTrue(); + + $this->user->passwordHistoryReason = 'test'; + $observer->saved($this->user); + expect(isset($this->user->oldPasswordHash))->toBeFalse(); +}); + +it('stores history with correct user context', function () { + $otherUser = User::factory()->create([ + 'password' => 'OtherP@ss!1234', + ]); + + $this->user->password = 'NewP@ssw0rd456!'; + $this->user->save(); + + $otherUser->password = 'OtherNewP@ss!5678'; + $otherUser->save(); + + expect(PasswordHistory::where('user_id', $this->user->id)->count())->toBe(1); + expect(PasswordHistory::where('user_id', $otherUser->id)->count())->toBe(1); +}); + +it('prunes only current user history entries', function () { + $otherUser = User::factory()->create([ + 'password' => 'OtherP@ss!1234', + ]); + + for ($i = 0; $i < 10; $i++) { + $this->user->passwordHistory()->create([ + 'password' => Hash::make("OldP@ssw0rd{$i}!"), + 'reason' => 'password_change', + ]); + $otherUser->passwordHistory()->create([ + 'password' => Hash::make("OtherOldP@ss{$i}!"), + 'reason' => 'password_change', + ]); + } + + $this->user->password = 'NewP@ssw0rdLatest!'; + $this->user->save(); + + expect(PasswordHistory::where('user_id', $this->user->id)->count())->toBe(10); + expect(PasswordHistory::where('user_id', $otherUser->id)->count())->toBe(10); +}); diff --git a/tests/Feature/PasswordResetFlowTest.php b/tests/Feature/PasswordResetFlowTest.php new file mode 100644 index 00000000..1d1159b9 --- /dev/null +++ b/tests/Feature/PasswordResetFlowTest.php @@ -0,0 +1,86 @@ + false]); + $this->user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => 'CurrentP@ssw0rd!', + 'force_password_reset' => true, + 'password_expires_at' => null, + ]); +}); + +it('shows force password reset form when user requires reset', function () { + $this->actingAs($this->user) + ->get(route('password.reset.form')) + ->assertStatus(200) + ->assertViewIs('auth.force-password-reset'); +}); + +it('redirects from force password reset form when user does not require reset', function () { + $this->user->update(['force_password_reset' => false]); + + $this->actingAs($this->user) + ->get(route('password.reset.form')) + ->assertRedirect(route('dasbor')); +}); + +it('processes force password reset successfully', function () { + $this->actingAs($this->user) + ->post(route('password.reset.force'), [ + 'password' => 'Str0ng!NewP@ssword', + 'password_confirmation' => 'Str0ng!NewP@ssword', + ]) + ->assertRedirect(AppServiceProvider::HOME) + ->assertSessionHas('success'); + + $this->user->refresh(); + expect($this->user->force_password_reset)->toBeFalse(); + expect($this->user->password_expires_at)->not->toBeNull(); +}); + +it('stores old password to history on force reset', function () { + $this->actingAs($this->user) + ->post(route('password.reset.force'), [ + 'password' => 'Str0ng!NewP@ssword', + 'password_confirmation' => 'Str0ng!NewP@ssword', + ]); + + $history = PasswordHistory::where('user_id', $this->user->id)->first(); + expect($history)->not->toBeNull(); + expect($history->reason)->toBe('forced_reset_completed'); + expect(Hash::check('CurrentP@ssw0rd!', $history->password))->toBeTrue(); +}); + +it('rejects reused password in force reset', function () { + $this->user->passwordHistory()->create([ + 'password' => Hash::make('Str0ng!N3wP@ssword'), + 'reason' => 'password_change', + ]); + + $this->actingAs($this->user) + ->post(route('password.reset.force'), [ + 'password' => 'Str0ng!N3wP@ssword', + 'password_confirmation' => 'Str0ng!N3wP@ssword', + ]) + ->assertSessionHasErrors(); +}); + +it('redirects from force reset when user does not require reset', function () { + $this->user->update(['force_password_reset' => false]); + + $this->actingAs($this->user) + ->post(route('password.reset.force'), [ + 'password' => 'Str0ng!NewP@ssword', + 'password_confirmation' => 'Str0ng!NewP@ssword', + ]) + ->assertRedirect(route('dasbor')); +}); diff --git a/tests/Feature/StrongPasswordRuleTest.php b/tests/Feature/StrongPasswordRuleTest.php index 491c57cb..48975915 100644 --- a/tests/Feature/StrongPasswordRuleTest.php +++ b/tests/Feature/StrongPasswordRuleTest.php @@ -223,4 +223,85 @@ public function test_password_history(): void // New password should pass $this->assertTrue($rule->passes('password', 'NewSecurePass456!')); } + + /** + * Test password history check with $user parameter (unauthenticated). + */ + public function test_password_history_with_user_parameter(): void + { + $user = User::factory()->create(); + $user->passwordHistory()->create([ + 'password' => \Illuminate\Support\Facades\Hash::make('OldSecurePass123!'), + 'reason' => 'password_change', + ]); + + // Not logged in — history check should use the $user parameter + $rule = new StrongPassword(checkHibp: false, user: $user); + + $this->assertFalse($rule->passes('password', 'OldSecurePass123!')); + $this->assertTrue($rule->passes('password', 'NewSecurePass456!')); + } + + /** + * Test password history check skips when no user context. + */ + public function test_password_history_skips_when_no_user(): void + { + // No user passed, not logged in — history check should allow + $rule = new StrongPassword(checkHibp: false); + + $this->assertTrue($rule->passes('password', 'AnyPassword123!')); + } + + /** + * Test password check against configured history count (10). + */ + public function test_password_history_checks_against_all_entries(): void + { + $user = User::factory()->create(); + $historyCount = config('password.history_count', 10); + + for ($i = 0; $i < $historyCount; $i++) { + $user->passwordHistory()->create([ + 'password' => \Illuminate\Support\Facades\Hash::make("UsedP@ssw0rd{$i}!"), + 'reason' => 'password_change', + ]); + } + + $rule = new StrongPassword(checkHibp: false, user: $user); + + for ($i = 0; $i < $historyCount; $i++) { + $this->assertFalse( + $rule->passes('password', "UsedP@ssw0rd{$i}!"), + "Password 'UsedP@ssw0rd{$i}!' should be rejected" + ); + } + + $this->assertTrue($rule->passes('password', 'BrandN3wP@ss!')); + } + + /** + * Test history check respects custom historySize parameter. + */ + public function test_password_history_respects_custom_size(): void + { + $user = User::factory()->create(); + + // Create 10 history entries + for ($i = 0; $i < 10; $i++) { + $user->passwordHistory()->create([ + 'password' => \Illuminate\Support\Facades\Hash::make("UsedP@ssw0rd{$i}!"), + 'reason' => 'password_change', + ]); + } + + // Only check last 3 + $rule = new StrongPassword(checkHibp: false, historySize: 3, user: $user); + + // The 10th entry (most recent, index 9) should be blocked + $this->assertFalse($rule->passes('password', 'UsedP@ssw0rd9!')); + + // The 1st entry (oldest, index 0) should be allowed (outside the 3) + $this->assertTrue($rule->passes('password', 'UsedP@ssw0rd0!')); + } } From e1c5d08c7a6e808b3f13eaea2378f5ee8325795d Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 15 Jul 2026 10:37:47 +0700 Subject: [PATCH 3/4] perbaiki test --- tests/Feature/LoginControllerTest.php | 26 +++++++++++++---------- tests/Feature/OtpLoginControllerTest.php | 27 ++++++++++++++++++------ 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/tests/Feature/LoginControllerTest.php b/tests/Feature/LoginControllerTest.php index a066a109..c21484ce 100644 --- a/tests/Feature/LoginControllerTest.php +++ b/tests/Feature/LoginControllerTest.php @@ -55,9 +55,7 @@ public function setUp(): void $this->app->instance(TwoFactorService::class, $this->twoFactorService); // Clear any existing rate limiters - $userAgent = $this->app['request']->userAgent() ?? 'unknown'; - $ip = '127.0.0.1'; - RateLimiter::clear("captcha:{$ip}:" . hash('xxh64', $userAgent)); + RateLimiter::clear('captcha:127.0.0.1:' . hash('xxh64', 'Symfony')); } #[Test] @@ -74,9 +72,18 @@ public function it_shows_login_form() #[Test] public function it_shows_login_form_with_captcha_when_threshold_reached() { - // Simulate failed attempts to trigger captcha - $key = 'captcha:127.0.0.1:' . hash('xxh64', $this->app['request']->userAgent() ?? 'unknown'); - RateLimiter::clear($key); // Clear first to ensure clean state + // Ensure captcha is enabled for this test + \App\Models\Setting::updateOrCreate( + ['key' => 'captcha_enabled'], + ['value' => '1'] + ); + \App\Models\Setting::updateOrCreate( + ['key' => 'captcha_threshold'], + ['value' => '2'] + ); + + $key = 'captcha:127.0.0.1:' . hash('xxh64', 'Symfony'); + RateLimiter::clear($key); RateLimiter::hit($key); RateLimiter::hit($key); RateLimiter::hit($key); @@ -384,11 +391,8 @@ private function getUsernameField() protected function tearDown(): void { - // Clean up rate limiters - $userAgent = $this->app['request']->userAgent() ?? 'unknown'; - $ip = '127.0.0.1'; - RateLimiter::clear("captcha:{$ip}:" . hash('xxh64', $userAgent)); - + RateLimiter::clear('captcha:127.0.0.1:' . hash('xxh64', 'Symfony')); + parent::tearDown(); } } \ No newline at end of file diff --git a/tests/Feature/OtpLoginControllerTest.php b/tests/Feature/OtpLoginControllerTest.php index 5eff789f..762953ab 100644 --- a/tests/Feature/OtpLoginControllerTest.php +++ b/tests/Feature/OtpLoginControllerTest.php @@ -48,12 +48,16 @@ public function setUp(): void $this->app->instance(OtpService::class, $this->otpService); $this->app->instance(TwoFactorService::class, $this->twoFactorService); + // Ensure consistent user agent for rate limiter keys. + // Request::create() defaults HTTP_USER_AGENT to 'Symfony', but the + // app request in CLI context may have null UA. + $this->app['request']->server->set('HTTP_USER_AGENT', 'Symfony'); + // Clear any existing rate limiters - $userAgent = $this->app['request']->userAgent() ?? 'unknown'; - RateLimiter::clear('captcha:127.0.0.1:' . hash('xxh64', $userAgent)); - RateLimiter::clear('otp:login:test@example.com:127.0.0.1:' . hash('xxh64', $userAgent)); - RateLimiter::clear('otp:verify:' . $this->user->id . ':127.0.0.1:' . hash('xxh64', $userAgent)); - RateLimiter::clear('otp:resend:' . $this->user->id . ':127.0.0.1:' . hash('xxh64', $userAgent)); + RateLimiter::clear('captcha:127.0.0.1:' . hash('xxh64', 'Symfony')); + RateLimiter::clear('otp:login:test@example.com:127.0.0.1:' . hash('xxh64', 'Symfony')); + RateLimiter::clear('otp:verify:' . $this->user->id . ':127.0.0.1:' . hash('xxh64', 'Symfony')); + RateLimiter::clear('otp:resend:' . $this->user->id . ':127.0.0.1:' . hash('xxh64', 'Symfony')); } #[Test] @@ -70,8 +74,17 @@ public function it_shows_otp_login_form() #[Test] public function it_shows_otp_login_form_with_captcha_when_threshold_reached() { - // Simulate failed attempts to trigger captcha - $key = 'captcha:127.0.0.1:' . hash('xxh64', $this->app['request']->userAgent() ?? 'unknown'); + // Ensure captcha is enabled for this test + \App\Models\Setting::updateOrCreate( + ['key' => 'captcha_enabled'], + ['value' => '1'] + ); + \App\Models\Setting::updateOrCreate( + ['key' => 'captcha_threshold'], + ['value' => '2'] + ); + + $key = 'captcha:127.0.0.1:' . hash('xxh64', 'Symfony'); RateLimiter::clear($key); RateLimiter::hit($key); RateLimiter::hit($key); From 83aaef0796c67454303a7a9485abb91c9b297a2a Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 15 Jul 2026 11:00:41 +0700 Subject: [PATCH 4/4] perbaiki notifikasi --- app/Http/Requests/ChangePasswordRequest.php | 11 +++++++ app/Http/Requests/ResetPasswordRequest.php | 11 +++++++ app/Rules/StrongPassword.php | 33 ++++++++++++++------- tests/Feature/StrongPasswordRuleTest.php | 26 +++++++++++----- 4 files changed, 62 insertions(+), 19 deletions(-) diff --git a/app/Http/Requests/ChangePasswordRequest.php b/app/Http/Requests/ChangePasswordRequest.php index 9fc31729..834375a1 100644 --- a/app/Http/Requests/ChangePasswordRequest.php +++ b/app/Http/Requests/ChangePasswordRequest.php @@ -4,6 +4,7 @@ use App\Rules\MatchOldPassword; use App\Rules\StrongPassword; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; class ChangePasswordRequest extends FormRequest @@ -16,6 +17,16 @@ public function authorize(): bool return auth()->check(); } + /** + * Handle a failed validation attempt. + */ + protected function failedValidation(Validator $validator): void + { + session()->flash('error', $validator->errors()->first()); + + parent::failedValidation($validator); + } + /** * Get the validation rules that apply to the request. * diff --git a/app/Http/Requests/ResetPasswordRequest.php b/app/Http/Requests/ResetPasswordRequest.php index b33bc456..74fb5b66 100644 --- a/app/Http/Requests/ResetPasswordRequest.php +++ b/app/Http/Requests/ResetPasswordRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Rules\StrongPassword; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; class ResetPasswordRequest extends FormRequest @@ -15,6 +16,16 @@ public function authorize(): bool return true; } + /** + * Handle a failed validation attempt. + */ + protected function failedValidation(Validator $validator): void + { + session()->flash('error', $validator->errors()->first()); + + parent::failedValidation($validator); + } + /** * Get the validation rules that apply to the request. * diff --git a/app/Rules/StrongPassword.php b/app/Rules/StrongPassword.php index a2208857..2ad4a359 100644 --- a/app/Rules/StrongPassword.php +++ b/app/Rules/StrongPassword.php @@ -41,6 +41,11 @@ class StrongPassword implements Rule */ protected array $commonPasswords; + /** + * The validation rule that failed. + */ + protected ?string $failedRule = null; + /** * Create a new rule instance. */ @@ -68,48 +73,48 @@ public function __construct( */ public function passes($attribute, $value) { - // Check minimum length if (strlen($value) < $this->minLength) { + $this->failedRule = 'length'; return false; } - // Check for at least one uppercase letter if (!preg_match('/[A-Z]/', $value)) { + $this->failedRule = 'complexity'; return false; } - // Check for at least one lowercase letter if (!preg_match('/[a-z]/', $value)) { + $this->failedRule = 'complexity'; return false; } - // Check for at least one number if (!preg_match('/[0-9]/', $value)) { + $this->failedRule = 'complexity'; return false; } - // Check for at least one special character if (!preg_match('/[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\;\'\/`~]/', $value)) { + $this->failedRule = 'complexity'; return false; } - // Check for weak patterns if ($this->matchesWeakPattern($value)) { + $this->failedRule = 'weak_pattern'; return false; } - // Check for common passwords if ($this->isCommonPassword($value)) { + $this->failedRule = 'common'; return false; } - // Check HIBP database if ($this->checkHibp && !$this->isNotPwned($value)) { + $this->failedRule = 'hibp'; return false; } - // Check password history if (!$this->isNotInHistory($value)) { + $this->failedRule = 'history'; return false; } @@ -203,14 +208,20 @@ protected function isNotInHistory(string $password): bool */ public function message() { - return [ - 'length' => 'Password harus memiliki minimal :min karakter.', + $messages = [ + 'length' => 'Password harus memiliki minimal '.$this->minLength.' karakter.', 'complexity' => 'Password harus mengandung huruf kapital, huruf kecil, angka, dan karakter spesial (!@#$%^&*...).', 'hibp' => 'Password ini telah bocor di database password yang pernah diretas. Silakan gunakan password lain yang lebih unik.', 'history' => 'Password ini telah digunakan sebelumnya. Silakan gunakan password baru.', 'weak_pattern' => 'Password terlalu lemah atau mudah ditebak. Hindari pola berulang atau berurutan.', 'common' => 'Password ini terlalu umum dan mudah ditebak. Silakan gunakan password yang lebih unik.', ]; + + if ($this->failedRule && isset($messages[$this->failedRule])) { + return $messages[$this->failedRule]; + } + + return 'Password tidak memenuhi persyaratan keamanan.'; } /** diff --git a/tests/Feature/StrongPasswordRuleTest.php b/tests/Feature/StrongPasswordRuleTest.php index 48975915..d6ba06ac 100644 --- a/tests/Feature/StrongPasswordRuleTest.php +++ b/tests/Feature/StrongPasswordRuleTest.php @@ -147,14 +147,24 @@ public function test_custom_min_length(): void public function test_error_messages(): void { $rule = new StrongPassword(); - $messages = $rule->message(); - - $this->assertArrayHasKey('length', $messages); - $this->assertArrayHasKey('complexity', $messages); - $this->assertArrayHasKey('hibp', $messages); - $this->assertArrayHasKey('history', $messages); - $this->assertArrayHasKey('weak_pattern', $messages); - $this->assertArrayHasKey('common', $messages); + + $this->assertFalse($rule->passes('password', 'short1A@')); + $this->assertStringContainsString('minimal 12', $rule->message()); + + $this->assertFalse($rule->passes('password', 'nouppercase1!@#')); + $this->assertStringContainsString('huruf kapital', $rule->message()); + + $this->assertFalse($rule->passes('password', 'NOLOWERCASE1!@#')); + $this->assertStringContainsString('huruf kecil', $rule->message()); + + $this->assertFalse($rule->passes('password', 'NoNumber!@#abc')); + $this->assertStringContainsString('angka', $rule->message()); + + $this->assertFalse($rule->passes('password', 'NoSpecialChar1abc')); + $this->assertStringContainsString('karakter spesial', $rule->message()); + + $generic = (new StrongPassword())->message(); + $this->assertIsString($generic); } /**