From 94042dd38ab9fcc048285503bc000a9fe9100a0d Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 11 Mar 2026 10:04:54 +0700 Subject: [PATCH 1/4] [SECURITY] Enforce Strong Password Policy di Seluruh Fitur (Change/Reset/Registration) --- app/Console/Commands/AuditWeakPasswords.php | 280 ++++++++++++++++++ .../Auth/ChangePasswordController.php | 28 +- .../Controllers/Auth/RegisterController.php | 38 ++- .../Auth/ResetPasswordController.php | 70 +++++ .../ForcePasswordResetController.php | 67 +++++ app/Http/Kernel.php | 1 + app/Http/Middleware/CheckPasswordExpiry.php | 54 ++++ app/Http/Requests/ChangePasswordRequest.php | 59 ++++ .../Requests/ForcePasswordResetRequest.php | 55 ++++ app/Http/Requests/RegisterRequest.php | 64 ++++ app/Http/Requests/ResetPasswordRequest.php | 62 ++++ app/Models/PasswordHistory.php | 37 +++ app/Models/User.php | 74 +++++ app/Rules/StrongPassword.php | 267 +++++++++++++++++ config/password.php | 114 +++++++ ...000001_create_password_histories_table.php | 32 ++ ...add_password_expires_at_to_users_table.php | 29 ++ .../views/auth/force-password-reset.blade.php | 78 +++++ routes/web.php | 12 +- tests/Feature/StrongPasswordRuleTest.php | 225 ++++++++++++++ 20 files changed, 1610 insertions(+), 36 deletions(-) create mode 100644 app/Console/Commands/AuditWeakPasswords.php create mode 100644 app/Http/Controllers/ForcePasswordResetController.php create mode 100644 app/Http/Middleware/CheckPasswordExpiry.php create mode 100644 app/Http/Requests/ChangePasswordRequest.php create mode 100644 app/Http/Requests/ForcePasswordResetRequest.php create mode 100644 app/Http/Requests/RegisterRequest.php create mode 100644 app/Http/Requests/ResetPasswordRequest.php create mode 100644 app/Models/PasswordHistory.php create mode 100644 app/Rules/StrongPassword.php create mode 100644 config/password.php create mode 100644 database/migrations/2026_03_11_000001_create_password_histories_table.php create mode 100644 database/migrations/2026_03_11_091131_add_password_expires_at_to_users_table.php create mode 100644 resources/views/auth/force-password-reset.blade.php create mode 100644 tests/Feature/StrongPasswordRuleTest.php diff --git a/app/Console/Commands/AuditWeakPasswords.php b/app/Console/Commands/AuditWeakPasswords.php new file mode 100644 index 000000000..c5269337c --- /dev/null +++ b/app/Console/Commands/AuditWeakPasswords.php @@ -0,0 +1,280 @@ +info('=== Audit Password Lemah ==='); + $this->newLine(); + + $dryRun = $this->option('dry-run'); + $force = $this->option('force'); + + if ($dryRun) { + $this->warn('MODE DRY-RUN: Tidak ada perubahan yang akan dilakukan.'); + $this->newLine(); + } + + // Get all users + $users = User::all(); + $totalUsers = $users->count(); + $weakPasswordCount = 0; + $affectedUsers = []; + + $this->info("Memeriksa {$totalUsers} user..."); + $this->newLine(); + + $bar = $this->output->createProgressBar($totalUsers); + $bar->start(); + + foreach ($users as $user) { + $bar->advance(); + + // Skip users without password (e.g., OAuth users) + if (!$user->password) { + continue; + } + + if ($this->isPasswordWeak($user->password)) { + $weakPasswordCount++; + $affectedUsers[] = [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name, + 'reason' => $this->getWeakReason($user->password), + ]; + + if (!$dryRun) { + $this->flagUserForPasswordReset($user); + } + } + } + + $bar->finish(); + $this->newLine(2); + + $this->table( + ['ID', 'Email', 'Name', 'Alasan'], + $affectedUsers + ); + + $this->newLine(); + $this->info("Ditemukan {$weakPasswordCount} user dengan password lemah dari {$totalUsers} total user."); + + if ($dryRun) { + $this->warn("Dry-run selesai. Jalankan tanpa --dry-run untuk menerapkan perubahan."); + return 0; + } + + if ($weakPasswordCount > 0 && !$force) { + if (!$this->confirm("Apakah Anda yakin ingin memaksa {$weakPasswordCount} user untuk reset password?")) { + $this->info('Operasi dibatalkan.'); + return 1; + } + + // Re-flag all users since confirmation was given + foreach ($affectedUsers as $userData) { + $user = User::find($userData['id']); + $this->flagUserForPasswordReset($user); + } + } + + if ($weakPasswordCount > 0) { + $this->info("Berhasil menandai {$weakPasswordCount} user untuk reset password wajib."); + $this->info('User tersebut akan diminta reset password saat login berikutnya.'); + } else { + $this->info('Tidak ada user dengan password lemah yang terdeteksi.'); + } + + return 0; + } + + /** + * Check if a password hash is weak. + * + * Note: Since we can't know the original password from the hash, + * we check for common patterns by testing common passwords. + */ + protected function isPasswordWeak(string $passwordHash): bool + { + // Check if password matches any common password + foreach ($this->commonPasswords as $common) { + if (Hash::check($common, $passwordHash)) { + return true; + } + } + + // Check for short passwords (less than 8 characters) + // We can't directly check length from hash, but we can check + // if any short common password matches + for ($i = 1; $i <= 7; $i++) { + // Generate test patterns + $patterns = [ + str_repeat('a', $i), + str_repeat('1', $i), + implode('', range('a', chr(ord('a') + $i - 1))), + implode('', range('1', (string) $i)), + ]; + + foreach ($patterns as $pattern) { + if (Hash::check($pattern, $passwordHash)) { + return true; + } + } + } + + // Check if password doesn't meet complexity requirements + // by testing common variations + $weakVariations = [ + 'Password1!', + 'Password123!', + 'Welcome1!', + 'Welcome123!', + 'Admin123!', + 'admin123!', + 'password123!', + 'Qwerty123!', + 'qwerty123!', + ]; + + foreach ($weakVariations as $variation) { + if (Hash::check($variation, $passwordHash)) { + return true; + } + } + + // Additional heuristic: check for users who never changed password + // and have old-style hashes (not using bcrypt) + // Old MD5/SHA1 hashes are 32/40 chars, bcrypt is 60 chars + if (strlen($passwordHash) < 60) { + return true; + } + + return false; + } + + /** + * Get the reason why password is weak. + */ + protected function getWeakReason(string $passwordHash): string + { + // Check common passwords + foreach ($this->commonPasswords as $common) { + if (Hash::check($common, $passwordHash)) { + return 'Password umum/terkenal'; + } + } + + // Check hash length (old hash algorithm) + if (strlen($passwordHash) < 60) { + return 'Menggunakan algoritma hash lama'; + } + + // Check weak variations + $weakVariations = [ + 'Password1!', + 'Password123!', + 'Welcome1!', + 'Welcome123!', + 'Admin123!', + 'admin123!', + 'password123!', + 'Qwerty123!', + 'qwerty123!', + ]; + + foreach ($weakVariations as $variation) { + if (Hash::check($variation, $passwordHash)) { + return 'Password mudah ditebak'; + } + } + + return 'Tidak memenuhi standar password kuat'; + } + + /** + * Flag a user for password reset. + */ + protected function flagUserForPasswordReset(User $user): void + { + // Only flag if not already flagged + if (!$user->force_password_reset) { + $user->force_password_reset = true; + $user->save(); + + // Record in password history + PasswordHistory::create([ + 'user_id' => $user->id, + 'password' => $user->password, + 'reason' => 'security_audit', + ]); + + $this->info(" → User {$user->email} ditandai untuk reset password"); + } + } +} diff --git a/app/Http/Controllers/Auth/ChangePasswordController.php b/app/Http/Controllers/Auth/ChangePasswordController.php index 9fc8005c7..aef094410 100644 --- a/app/Http/Controllers/Auth/ChangePasswordController.php +++ b/app/Http/Controllers/Auth/ChangePasswordController.php @@ -2,11 +2,11 @@ namespace App\Http\Controllers\Auth; +use App\Http\Controllers\Controller; +use App\Http\Requests\ChangePasswordRequest; use App\Models\User as ModelsUser; -use App\Rules\MatchOldPassword; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Validation\Rules\Password; class ChangePasswordController extends ResetPasswordController { @@ -16,26 +16,10 @@ public function showResetForm(Request $request, $token = null) } /** - * Get the password reset validation rules. - * - * @return array + * Change user password. */ - protected function rules() + public function change(ChangePasswordRequest $request) { - return [ - 'password_old' => ['required', new MatchOldPassword], - 'password' => ['required', 'confirmed', Password::min(8)->letters()->symbols()->numbers()->mixedCase()->uncompromised()], - ]; - } - - /** - * Reset the given user's password. - * - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse - */ - public function reset(Request $request) - { - $request->validate($this->rules(), $this->validationErrorMessages()); $password = $request->get('password'); $user = Auth::user(); $this->changePassword($user, $password); @@ -68,7 +52,7 @@ public function resetByAdmin(ModelsUser $user, Request $request) */ protected function changePassword($user, $password) { - $user->password = $password; - $user->save(); + $expiryDays = config('password.expiry_days'); + $user->setPasswordWithHistory($password, 'password_change', $expiryDays); } } diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 279708c9a..0ecaa966e 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -3,11 +3,12 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Http\Requests\RegisterRequest; use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Foundation\Auth\RegistersUsers; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Validator; class RegisterController extends Controller { @@ -42,17 +43,23 @@ public function __construct() } /** - * Get a validator for an incoming registration request. - * - * @return \Illuminate\Contracts\Validation\Validator + * Show the registration form. */ - protected function validator(array $data) + public function showRegistrationForm() { - return Validator::make($data, [ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], - ]); + return view('auth.register'); + } + + /** + * Register a new user. + */ + public function register(RegisterRequest $request) + { + $user = $this->create($request->validated()); + + $this->guard()->login($user); + + return redirect($this->redirectTo); } /** @@ -62,10 +69,19 @@ protected function validator(array $data) */ protected function create(array $data) { - return User::create([ + $user = User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), ]); + + // Set password expiry if configured + $expiryDays = config('password.expiry_days'); + if ($expiryDays) { + $user->password_expires_at = now()->addDays($expiryDays); + $user->save(); + } + + return $user; } } diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index b1726a364..ad4b0f2df 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -3,8 +3,11 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Http\Requests\ResetPasswordRequest; +use App\Models\PasswordHistory; use App\Providers\RouteServiceProvider; use Illuminate\Foundation\Auth\ResetsPasswords; +use Illuminate\Support\Facades\Password; class ResetPasswordController extends Controller { @@ -27,4 +30,71 @@ class ResetPasswordController extends Controller * @var string */ protected $redirectTo = RouteServiceProvider::HOME; + + /** + * Display the password reset view. + */ + public function showResetForm(ResetPasswordRequest $request, $token = null) + { + return view('auth.passwords.reset')->with( + ['token' => $token, 'email' => $request->email] + ); + } + + /** + * Reset the given user's password. + */ + public function reset(ResetPasswordRequest $request) + { + $password = $request->get('password'); + $email = $request->get('email'); + $token = $request->get('token'); + + // Find user by email + $user = \App\Models\User::where('email', $email)->first(); + + if (!$user) { + return back()->withErrors(['email' => 'Email tidak ditemukan.']); + } + + $this->resetPassword($user, $password); + + return redirect($this->redirectTo) + ->with('success', 'Password berhasil direset. Silakan login dengan password baru.'); + } + + /** + * Reset the given user's password. + * + * @param \Illuminate\Contracts\Auth\CanResetPassword $user + * @param string $password + * + * @return void + */ + 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->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 new file mode 100644 index 000000000..92c7d20ff --- /dev/null +++ b/app/Http/Controllers/ForcePasswordResetController.php @@ -0,0 +1,67 @@ +requiresPasswordReset()) { + return redirect()->route('home'); + } + + return view('auth.force-password-reset'); + } + + /** + * Process the force password reset. + */ + public function reset(ForcePasswordResetRequest $request) + { + $user = Auth::user(); + + // Only allow if user actually needs to reset + if (!$user->requiresPasswordReset()) { + return redirect()->route('home'); + } + + $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->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(); + + // Redirect to intended URL or home + $intendedUrl = session('intended_url', route('home')); + session()->forget('intended_url'); + + return redirect($intendedUrl) + ->with('success', 'Password berhasil diubah. Sekarang Anda dapat melanjutkan menggunakan aplikasi.'); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 15994938e..178c1426a 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -72,6 +72,7 @@ class Kernel extends HttpKernel 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, 'teams_permission' => Middleware\TeamsPermission::class, 'password.weak' => Middleware\WeakPassword::class, + 'password.expiry' => Middleware\CheckPasswordExpiry::class, 'website.enable' => Middleware\WebsiteEnable::class, 'log.visitor' => \Shetabit\Visitor\Middlewares\LogVisits::class, 'easyauthorize' => Middleware\EasyAuthorize::class, diff --git a/app/Http/Middleware/CheckPasswordExpiry.php b/app/Http/Middleware/CheckPasswordExpiry.php new file mode 100644 index 000000000..b8a9f4881 --- /dev/null +++ b/app/Http/Middleware/CheckPasswordExpiry.php @@ -0,0 +1,54 @@ +is('api/*')) { + return $next($request); + } + + // Check if user needs to reset password + if ($user->requiresPasswordReset()) { + // Allow access to password reset routes and logout + if ($request->is('password-reset/*', 'user/reset-password', 'logout', 'change-password/*')) { + return $next($request); + } + + // Store intended destination + session(['intended_url' => url()->current()]); + + // Redirect to password reset page + if ($request->ajax() || $request->wantsJson()) { + return response()->json([ + 'message' => 'Password Anda telah expired atau perlu direset. Silakan reset password untuk melanjutkan.', + 'requires_password_reset' => true, + ], 403); + } + + return redirect()->route('password.reset.form') + ->with('warning', 'Password Anda telah expired atau perlu direset demi keamanan. Silakan buat password baru.'); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/ChangePasswordRequest.php b/app/Http/Requests/ChangePasswordRequest.php new file mode 100644 index 000000000..9fc31729c --- /dev/null +++ b/app/Http/Requests/ChangePasswordRequest.php @@ -0,0 +1,59 @@ +check(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'password_old' => ['required', new MatchOldPassword], + 'password' => ['required', 'string', 'confirmed', new StrongPassword()], + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'password_old.required' => 'Password lama harus diisi.', + 'password.required' => 'Password baru harus diisi.', + 'password.confirmed' => 'Konfirmasi password tidak sesuai.', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'password_old' => 'password lama', + 'password' => 'password baru', + 'password_confirmation' => 'konfirmasi password', + ]; + } +} diff --git a/app/Http/Requests/ForcePasswordResetRequest.php b/app/Http/Requests/ForcePasswordResetRequest.php new file mode 100644 index 000000000..35e18ce63 --- /dev/null +++ b/app/Http/Requests/ForcePasswordResetRequest.php @@ -0,0 +1,55 @@ +check(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'password' => ['required', 'string', 'confirmed', new StrongPassword()], + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'password.required' => 'Password baru harus diisi.', + 'password.confirmed' => 'Konfirmasi password tidak sesuai.', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'password' => 'password baru', + 'password_confirmation' => 'konfirmasi password', + ]; + } +} diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php new file mode 100644 index 000000000..044266459 --- /dev/null +++ b/app/Http/Requests/RegisterRequest.php @@ -0,0 +1,64 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'password' => ['required', 'string', 'confirmed', new StrongPassword()], + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'name.required' => 'Nama harus diisi.', + 'name.max' => 'Nama tidak boleh lebih dari 255 karakter.', + 'email.required' => 'Email harus diisi.', + 'email.email' => 'Format email tidak valid.', + 'email.unique' => 'Email sudah digunakan.', + 'password.required' => 'Password harus diisi.', + 'password.confirmed' => 'Konfirmasi password tidak sesuai.', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'name' => 'nama', + 'email' => 'email', + 'password' => 'password', + 'password_confirmation' => 'konfirmasi password', + ]; + } +} diff --git a/app/Http/Requests/ResetPasswordRequest.php b/app/Http/Requests/ResetPasswordRequest.php new file mode 100644 index 000000000..bfe40e89c --- /dev/null +++ b/app/Http/Requests/ResetPasswordRequest.php @@ -0,0 +1,62 @@ +|string> + */ + public function rules(): array + { + return [ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => ['required', 'string', 'confirmed', new StrongPassword()], + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'token.required' => 'Token reset password tidak valid.', + 'email.required' => 'Email harus diisi.', + 'email.email' => 'Format email tidak valid.', + 'password.required' => 'Password baru harus diisi.', + 'password.confirmed' => 'Konfirmasi password tidak sesuai.', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'token' => 'token reset', + 'email' => 'email', + 'password' => 'password baru', + 'password_confirmation' => 'konfirmasi password', + ]; + } +} diff --git a/app/Models/PasswordHistory.php b/app/Models/PasswordHistory.php new file mode 100644 index 000000000..68659b159 --- /dev/null +++ b/app/Models/PasswordHistory.php @@ -0,0 +1,37 @@ + + */ + protected $fillable = [ + 'user_id', + 'password', + 'reason', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'password', + ]; + + /** + * Get the user that owns the password history. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index de56a4ac8..2d1a0ea81 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -46,6 +46,8 @@ class User extends Authenticatable '2fa_enabled', '2fa_channel', '2fa_identifier', + 'password_expires_at', + 'force_password_reset', ]; /** @@ -65,6 +67,8 @@ class User extends Authenticatable 'tempat_dilahirkan' => Enums\StatusEnum::class, '2fa_enabled' => 'boolean', 'otp_enabled' => 'boolean', + 'password_expires_at' => 'datetime', + 'force_password_reset' => 'boolean', ]; public function teams() @@ -211,6 +215,14 @@ public function otpTokens() return $this->hasMany(OtpToken::class); } + /** + * Relasi ke Password History + */ + public function passwordHistory() + { + return $this->hasMany(PasswordHistory::class); + } + /** * Cek apakah user memiliki OTP aktif */ @@ -226,4 +238,66 @@ public function getOtpChannels() { return $this->otp_channel ? json_decode($this->otp_channel, true) : []; } + + /** + * Cek apakah password sudah expired + */ + public function isPasswordExpired(): bool + { + if (!$this->password_expires_at) { + return false; + } + + return $this->password_expires_at->isPast(); + } + + /** + * Cek apakah user harus reset password + */ + public function requiresPasswordReset(): bool + { + return $this->force_password_reset || $this->isPasswordExpired(); + } + + /** + * Set password dengan expiry dan history + */ + 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, + ]); + } + + // 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(); + } + + /** + * Force user untuk reset password + */ + public function forcePasswordReset(string $reason = 'security_audit'): void + { + $this->force_password_reset = true; + $this->save(); + + // Catat di history + $this->passwordHistory()->create([ + 'password' => $this->password, + 'reason' => $reason, + ]); + } } diff --git a/app/Rules/StrongPassword.php b/app/Rules/StrongPassword.php new file mode 100644 index 000000000..65dcadf8d --- /dev/null +++ b/app/Rules/StrongPassword.php @@ -0,0 +1,267 @@ +minLength = $minLength ?? config('password.min_length', 12); + $this->checkHibp = $checkHibp ?? config('password.check_hibp', true); + $this->historySize = $historySize ?? config('password.history_count', 5); + $this->weakPatterns = config('password.weak_patterns', []); + $this->commonPasswords = config('password.common_passwords', []); + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute + * @param mixed $value + * + * @return bool + */ + public function passes($attribute, $value) + { + // Check minimum length + if (strlen($value) < $this->minLength) { + return false; + } + + // Check for at least one uppercase letter + if (!preg_match('/[A-Z]/', $value)) { + return false; + } + + // Check for at least one lowercase letter + if (!preg_match('/[a-z]/', $value)) { + return false; + } + + // Check for at least one number + if (!preg_match('/[0-9]/', $value)) { + return false; + } + + // Check for at least one special character + if (!preg_match('/[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\;\'\/`~]/', $value)) { + return false; + } + + // Check for weak patterns + if ($this->matchesWeakPattern($value)) { + return false; + } + + // Check for common passwords + if ($this->isCommonPassword($value)) { + return false; + } + + // Check HIBP database + if ($this->checkHibp && !$this->isNotPwned($value)) { + return false; + } + + // Check password history + if (!$this->isNotInHistory($value)) { + return false; + } + + return true; + } + + /** + * Check if password matches any weak pattern. + */ + protected function matchesWeakPattern(string $password): bool + { + foreach ($this->weakPatterns as $pattern) { + if (preg_match($pattern, $password)) { + return true; + } + } + + return false; + } + + /** + * Check if password is in the common passwords list. + */ + protected function isCommonPassword(string $password): bool + { + return in_array(strtolower($password), array_map('strtolower', $this->commonPasswords)); + } + + /** + * Check if password has been pwned using HIBP API. + * Uses k-anonymity model (only send first 5 chars of SHA1 hash). + */ + protected function isNotPwned(string $password): bool + { + $sha1 = strtoupper(sha1($password)); + $prefix = substr($sha1, 0, 5); + $suffix = substr($sha1, 5); + + try { + $response = Http::withHeaders([ + 'User-Agent' => 'OpenKab-Password-Policy/1.0', + 'Add-Padding' => 'true', + ])->get("https://api.pwnedpasswords.com/range/{$prefix}"); + + if ($response->successful()) { + $lines = explode("\n", $response->body()); + foreach ($lines as $line) { + [$hashSuffix, ] = explode(':', $line); + if (strtoupper(trim($hashSuffix)) === $suffix) { + return false; // Password is pwned + } + } + } + } catch (\Exception $e) { + // If HIBP API fails, we allow the password but log the issue + \Log::warning('HIBP API check failed: '.$e->getMessage()); + } + + return true; // Password is not pwned or API unavailable + } + + /** + * Check if password was used recently (in password history). + */ + protected function isNotInHistory(string $password): bool + { + if (!auth()->check()) { + return true; // No logged in user to check history for + } + + $user = auth()->user(); + $passwordHistory = PasswordHistory::where('user_id', $user->id) + ->orderBy('created_at', 'desc') + ->limit($this->historySize) + ->get(); + + foreach ($passwordHistory as $history) { + if (Hash::check($password, $history->password)) { + return false; // Password is in history + } + } + + return true; // Password is not in history + } + + /** + * Get the validation error message. + * + * @return string|array + */ + public function message() + { + return [ + 'length' => 'Password harus memiliki minimal :min 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.', + ]; + } + + /** + * Get the validation error message with proper formatting. + */ + public function failedMessage(?string $failedRule = null): string + { + $messages = $this->message(); + $value = $this->getValue() ?? ''; + + if (strlen($value) < $this->minLength) { + return str_replace(':min', (string) $this->minLength, $messages['length']); + } + + if (!preg_match('/[A-Z]/', $value) || + !preg_match('/[a-z]/', $value) || + !preg_match('/[0-9]/', $value) || + !preg_match('/[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\;\'\/`~]/', $value)) { + return $messages['complexity']; + } + + if ($this->matchesWeakPattern($value)) { + return $messages['weak_pattern']; + } + + if ($this->isCommonPassword($value)) { + return $messages['common']; + } + + if ($this->checkHibp && !$this->isNotPwned($value)) { + return $messages['hibp']; + } + + if (!$this->isNotInHistory($value)) { + return $messages['history']; + } + + return 'Password tidak memenuhi persyaratan keamanan.'; + } + + /** + * Store the value being validated for error messaging. + */ + protected ?string $value = null; + + /** + * Get the value being validated. + */ + protected function getValue(): ?string + { + return $this->value; + } + + /** + * Set the value being validated. + */ + public function setValue(string $value): self + { + $this->value = $value; + + return $this; + } +} diff --git a/config/password.php b/config/password.php new file mode 100644 index 000000000..c448fc8cb --- /dev/null +++ b/config/password.php @@ -0,0 +1,114 @@ + 12, + + /* + |-------------------------------------------------------------------------- + | Password Expiry + |-------------------------------------------------------------------------- + | + | Number of days before a password expires. Set to null to disable expiry. + | When enabled, users will be forced to change their password after expiry. + | + */ + 'expiry_days' => 90, + + /* + |-------------------------------------------------------------------------- + | Password History + |-------------------------------------------------------------------------- + | + | Number of previous passwords to remember and prevent reuse. + | Set to 0 to disable password history check. + | + */ + 'history_count' => 5, + + /* + |-------------------------------------------------------------------------- + | HIBP (Have I Been Pwned) Check + |-------------------------------------------------------------------------- + | + | Enable checking passwords against the HIBP database of breached passwords. + | Uses k-anonymity model for privacy. + | + */ + 'check_hibp' => true, + + /* + |-------------------------------------------------------------------------- + | Force Reset for Existing Users + |-------------------------------------------------------------------------- + | + | When enabled, existing users with weak passwords will be flagged for + | forced password reset on next login. + | + */ + 'force_reset_weak_passwords' => true, + + /* + |-------------------------------------------------------------------------- + | Weak Password Patterns + |-------------------------------------------------------------------------- + | + | Additional patterns that indicate a weak password. + | These are checked in addition to the standard requirements. + | + */ + 'weak_patterns' => [ + '/^(.)\1+$/', // Same character repeated (e.g., aaaaaa) + '/^(012|123|234|345|456|567|678|789|890)/', // Sequential numbers + '/^(abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz)/i', // Sequential letters + ], + + /* + |-------------------------------------------------------------------------- + | Common Passwords List + |-------------------------------------------------------------------------- + | + | List of common passwords to reject (in addition to HIBP check). + | + */ + 'common_passwords' => [ + 'password', + 'password123', + '123456', + '123456789', + 'qwerty', + 'abc123', + 'monkey', + 'master', + 'dragon', + 'letmein', + 'login', + 'admin', + 'welcome', + 'admin123', + 'root', + 'toor', + 'pass', + 'test', + 'guest', + 'guest123', + ], +]; diff --git a/database/migrations/2026_03_11_000001_create_password_histories_table.php b/database/migrations/2026_03_11_000001_create_password_histories_table.php new file mode 100644 index 000000000..2424808f0 --- /dev/null +++ b/database/migrations/2026_03_11_000001_create_password_histories_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->string('password'); + $table->string('reason')->default('password_change'); // password_change, admin_reset, forced_reset + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('password_histories'); + } +}; diff --git a/database/migrations/2026_03_11_091131_add_password_expires_at_to_users_table.php b/database/migrations/2026_03_11_091131_add_password_expires_at_to_users_table.php new file mode 100644 index 000000000..5df22af25 --- /dev/null +++ b/database/migrations/2026_03_11_091131_add_password_expires_at_to_users_table.php @@ -0,0 +1,29 @@ +timestamp('password_expires_at')->nullable()->after('password'); + $table->boolean('force_password_reset')->default(false)->after('password_expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['password_expires_at', 'force_password_reset']); + }); + } +}; diff --git a/resources/views/auth/force-password-reset.blade.php b/resources/views/auth/force-password-reset.blade.php new file mode 100644 index 000000000..478d59ea6 --- /dev/null +++ b/resources/views/auth/force-password-reset.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.app') + +@section('title', 'Reset Password Wajib') + +@section('content') +
+
+
+
+
+

Reset Password Wajib

+
+
+
+ + Perhatian: Password Anda telah expired atau terdeteksi lemah. + Demi keamanan akun, silakan buat password baru dengan persyaratan berikut: +
+ +
+ Persyaratan Password: +
    +
  • Minimal 12 karakter
  • +
  • Mengandung huruf kapital (A-Z)
  • +
  • Mengandung huruf kecil (a-z)
  • +
  • Mengandung angka (0-9)
  • +
  • Mengandung karakter spesial (!@#$%^&*...)
  • +
  • Tidak boleh sama dengan password sebelumnya
  • +
  • Tidak boleh ada di database password yang pernah bocor (HIBP)
  • +
+
+ +
+ @csrf + +
+ + + @error('password') + + {{ $message }} + + @enderror +
+ +
+ + +
+ +
+ +
+
+
+
+
+
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 013484a95..33b1ad9ae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Controllers\DasborDemografiController; use App\Http\Controllers\DataPokokController; use App\Http\Controllers\DesaController; +use App\Http\Controllers\ForcePasswordResetController; use App\Http\Controllers\GroupController; use App\Http\Controllers\IdentitasController; use App\Http\Controllers\KecamatanController; @@ -32,6 +33,7 @@ use App\Http\Middleware\KabupatenMiddleware; use App\Http\Middleware\KecamatanMiddleware; use App\Http\Middleware\WilayahMiddleware; +use App\Http\Middleware\CheckPasswordExpiry; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; @@ -61,13 +63,17 @@ Route::get('pengaturan/logo', [IdentitasController::class, 'logo']); -Route::middleware(['auth', 'teams_permission', 'password.weak', '2fa'])->group(function () { +Route::middleware(['auth', 'teams_permission', 'password.expiry', 'password.weak', '2fa'])->group(function () { Route::get('catatan-rilis', CatatanRilis::class); Route::get('/dasbor', [DasborController::class, 'index'])->name('dasbor'); Route::get('dasbor-demografi', [DasborDemografiController::class, 'index'])->name('dasbor-demografi'); - Route::get('password.change', [ChangePasswordController::class, 'showResetForm'])->name('password.change'); - Route::post('password.change', [ChangePasswordController::class, 'reset'])->name('password.change'); + // Force Password Reset Routes + Route::get('password-reset/force', [ForcePasswordResetController::class, 'showForm'])->name('password.reset.form'); + Route::post('password-reset/force', [ForcePasswordResetController::class, 'reset'])->name('password.reset.force'); + + Route::get('password.change', [ChangePasswordController::class, 'showResetForm'])->name('password.change.form'); + Route::post('password.change', [ChangePasswordController::class, 'change'])->name('password.change'); Route::get('users/list', [UserController::class, 'getUsers'])->name('users.list'); Route::get('users/status/{id}/{status}', [UserController::class, 'status'])->name('users.status'); Route::get('users/{user}', [UserController::class, 'profile'])->name('profile.edit'); diff --git a/tests/Feature/StrongPasswordRuleTest.php b/tests/Feature/StrongPasswordRuleTest.php new file mode 100644 index 000000000..9173c3748 --- /dev/null +++ b/tests/Feature/StrongPasswordRuleTest.php @@ -0,0 +1,225 @@ +assertTrue($rule->passes('password', 'SecurePass123!')); + $this->assertTrue($rule->passes('password', 'MyP@ssw0rd2024')); + $this->assertTrue($rule->passes('password', 'Str0ng!Passw0rd')); + } + + /** + * Test password that is too short. + */ + public function test_password_too_short_fails(): void + { + $rule = new StrongPassword(); + + $this->assertFalse($rule->passes('password', 'Short1!')); + $this->assertFalse($rule->passes('password', 'Test123!')); + $this->assertFalse($rule->passes('password', 'Abc123!@#')); + } + + /** + * Test password without uppercase letter. + */ + public function test_password_without_uppercase_fails(): void + { + $rule = new StrongPassword(); + + $this->assertFalse($rule->passes('password', 'securepass123!')); + $this->assertFalse($rule->passes('password', 'mysecurepassword1!')); + } + + /** + * Test password without lowercase letter. + */ + public function test_password_without_lowercase_fails(): void + { + $rule = new StrongPassword(); + + $this->assertFalse($rule->passes('password', 'SECUREPASS123!')); + $this->assertFalse($rule->passes('password', 'MYSECUREPASSWORD1!')); + } + + /** + * Test password without number. + */ + public function test_password_without_number_fails(): void + { + $rule = new StrongPassword(); + + $this->assertFalse($rule->passes('password', 'SecurePassword!')); + $this->assertFalse($rule->passes('password', 'MySecurePassword@')); + } + + /** + * Test password without special character. + */ + public function test_password_without_special_char_fails(): void + { + $rule = new StrongPassword(); + + $this->assertFalse($rule->passes('password', 'SecurePass123')); + $this->assertFalse($rule->passes('password', 'MySecurePass456')); + } + + /** + * Test common passwords are rejected. + */ + public function test_common_passwords_fail(): void + { + $rule = new StrongPassword(); + + $commonPasswords = [ + 'password', + 'password123', + '123456', + '123456789', + 'qwerty', + 'abc123', + 'admin', + 'admin123', + 'welcome', + 'letmein', + ]; + + foreach ($commonPasswords as $password) { + $this->assertFalse( + $rule->passes('password', $password), + "Common password '{$password}' should fail" + ); + } + } + + /** + * Test weak patterns are rejected. + */ + public function test_weak_patterns_fail(): void + { + $rule = new StrongPassword(checkHibp: false); + + // Sequential numbers (pattern checks for 123, 234, etc at start) + $this->assertFalse($rule->passes('password', '123abc!ABCdef')); + $this->assertFalse($rule->passes('password', '234abc!ABCdef')); + + // Sequential letters (pattern checks for abc, bcd, etc at start) + $this->assertFalse($rule->passes('password', 'abcdef1!ABCdef')); + $this->assertFalse($rule->passes('password', 'xyzabc1!ABCdef')); + } + + /** + * Test validation with custom minimum length. + */ + public function test_custom_min_length(): void + { + $rule = new StrongPassword(minLength: 16, checkHibp: false); + + // 12 chars - should fail with 16 min + $this->assertFalse($rule->passes('password', 'SecurePass123!')); + + // 16+ chars - should pass (avoid weak patterns) + $this->assertTrue($rule->passes('password', 'MyStr0ng!P@ssw0rd')); + } + + /** + * Test validation error messages. + */ + 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); + } + + /** + * Test validator integration. + */ + public function test_validator_integration(): void + { + $validator = Validator::make( + ['password' => 'SecurePass123!'], + ['password' => ['required', new StrongPassword(checkHibp: false)]] + ); + + $this->assertFalse($validator->fails()); + + $validator = Validator::make( + ['password' => 'weak'], + ['password' => ['required', new StrongPassword(checkHibp: false)]] + ); + + $this->assertTrue($validator->fails()); + } + + /** + * Test password with all special characters. + */ + public function test_various_special_characters(): void + { + $rule = new StrongPassword(checkHibp: false); + + $specialChars = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '=', '+']; + + foreach ($specialChars as $char) { + // Use unique password base to avoid HIBP/common password issues + $password = "Test{$char}Pass1234"; + $this->assertTrue( + $rule->passes('password', $password), + "Password with special char '{$char}' should pass" + ); + } + } + + /** + * Test password history check (when logged in). + */ + public function test_password_history(): void + { + // Create a test user + $user = User::factory()->create([ + 'password' => \Illuminate\Support\Facades\Hash::make('OldSecurePass123!'), + ]); + + // Create password history + $user->passwordHistory()->create([ + 'password' => \Illuminate\Support\Facades\Hash::make('OldSecurePass123!'), + 'reason' => 'password_change', + ]); + + // Login as this user + $this->actingAs($user); + + $rule = new StrongPassword(checkHibp: false); + + // Old password should fail (in history) + $this->assertFalse($rule->passes('password', 'OldSecurePass123!')); + + // New password should pass + $this->assertTrue($rule->passes('password', 'NewSecurePass456!')); + } +} From 22511d5866d0e1454e5e0093a6517d557e8b4492 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 11 Mar 2026 11:04:54 +0700 Subject: [PATCH 2/4] perbaikan extends layout --- resources/views/auth/force-password-reset.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/auth/force-password-reset.blade.php b/resources/views/auth/force-password-reset.blade.php index 478d59ea6..6cb2a609f 100644 --- a/resources/views/auth/force-password-reset.blade.php +++ b/resources/views/auth/force-password-reset.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.app') +@extends('layouts.index') @section('title', 'Reset Password Wajib') From fd7757d22294e5693cacdc0156c3b724a8e2ccda Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 11 Mar 2026 11:49:01 +0700 Subject: [PATCH 3/4] update --- app/Http/Controllers/ForcePasswordResetController.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/ForcePasswordResetController.php b/app/Http/Controllers/ForcePasswordResetController.php index 92c7d20ff..245574739 100644 --- a/app/Http/Controllers/ForcePasswordResetController.php +++ b/app/Http/Controllers/ForcePasswordResetController.php @@ -4,6 +4,7 @@ use App\Http\Requests\ForcePasswordResetRequest; use App\Models\PasswordHistory; +use App\Providers\RouteServiceProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -18,7 +19,7 @@ public function showForm() // Only show if user actually needs to reset if (!$user->requiresPasswordReset()) { - return redirect()->route('home'); + return redirect()->route('dasbor'); } return view('auth.force-password-reset'); @@ -33,7 +34,7 @@ public function reset(ForcePasswordResetRequest $request) // Only allow if user actually needs to reset if (!$user->requiresPasswordReset()) { - return redirect()->route('home'); + return redirect()->route('dasbor'); } $expiryDays = config('password.expiry_days'); @@ -58,7 +59,7 @@ public function reset(ForcePasswordResetRequest $request) $user->save(); // Redirect to intended URL or home - $intendedUrl = session('intended_url', route('home')); + $intendedUrl = session('intended_url', url(RouteServiceProvider::HOME)); session()->forget('intended_url'); return redirect($intendedUrl) From 5fb293ff6145cbb7f75e94c9ee8c56a0c40cf4e8 Mon Sep 17 00:00:00 2001 From: Abah Roland <59082428+vickyrolanda@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:04:08 +0700 Subject: [PATCH 4/4] [ci skip] memutahirkan catatan rilis --- catatan_rilis.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/catatan_rilis.md b/catatan_rilis.md index c39643333..0c8ab8c3f 100644 --- a/catatan_rilis.md +++ b/catatan_rilis.md @@ -17,4 +17,5 @@ Di rilis ini, versi 2603.0.0 berisi penambahan dan perbaikan yang diminta penggu 2. [#969](https://github.com/OpenSID/OpenKab/issues/969) Terapkan CAPTCHA pada Login & Endpoint Auth untuk Batasi Bot/Bruteforce. 3. [#962](https://github.com/OpenSID/OpenKab/issues/962) Pencegahan Kerentanan XSS (Cross-Site Scripting). 4. [#966](https://github.com/OpenSID/OpenKab/issues/966) Prevent IDOR (Insecure Direct Object Reference) pada Endpoint Berbasis ID. -5. [#968](https://github.com/OpenSID/OpenKab/issues/968) Jadikan Content Security Policy (CSP) Selalu Aktif, Tidak Boleh Auto-Disable Walau di Debug/Dev. \ No newline at end of file +5. [#968](https://github.com/OpenSID/OpenKab/issues/968) Jadikan Content Security Policy (CSP) Selalu Aktif, Tidak Boleh Auto-Disable Walau di Debug/Dev. +6. [#963](https://github.com/OpenSID/OpenKab/issues/963) Enforce Strong Password Policy di Seluruh Fitur (Change/Reset/Registration). \ No newline at end of file