diff --git a/ProcessMaker/Http/Controllers/Api/ChangePasswordController.php b/ProcessMaker/Http/Controllers/Api/ChangePasswordController.php index e14ea0f0d7..4083cabcaf 100644 --- a/ProcessMaker/Http/Controllers/Api/ChangePasswordController.php +++ b/ProcessMaker/Http/Controllers/Api/ChangePasswordController.php @@ -2,6 +2,7 @@ namespace ProcessMaker\Http\Controllers\Api; +use Carbon\Carbon; use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -35,6 +36,10 @@ public function update(Request $request) $user->setAttribute('password', Hash::make($request->json('password'))); $user->setAttribute('force_change_password', 0); + $user->setAttribute('password_changed_at', Carbon::now()->toDateTimeString()); + + // Remove login error message related to password expired if exists + session()->forget('login-error'); try { $user = $user->save(); diff --git a/ProcessMaker/Http/Controllers/Api/UserController.php b/ProcessMaker/Http/Controllers/Api/UserController.php index 59aedb7a08..caf2c72aca 100644 --- a/ProcessMaker/Http/Controllers/Api/UserController.php +++ b/ProcessMaker/Http/Controllers/Api/UserController.php @@ -2,6 +2,7 @@ namespace ProcessMaker\Http\Controllers\Api; +use Carbon\Carbon; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -167,6 +168,10 @@ public function store(Request $request) if (isset($fields['password'])) { $fields['password'] = Hash::make($fields['password']); + $fields['password_changed_at'] = Carbon::now()->toDateTimeString(); + + // Remove login error message related to password expired if exists + session()->forget('login-error'); } $user->fill($fields); @@ -301,6 +306,10 @@ public function update(User $user, Request $request) $fields = $request->json()->all(); if (isset($fields['password'])) { $fields['password'] = Hash::make($fields['password']); + $fields['password_changed_at'] = Carbon::now()->toDateTimeString(); + + // Remove login error message related to password expired if exists + session()->forget('login-error'); } $original = $user->getOriginal(); $user->fill($fields); diff --git a/ProcessMaker/Http/Controllers/Auth/LoginController.php b/ProcessMaker/Http/Controllers/Auth/LoginController.php index d4012a6877..45e8f64f58 100644 --- a/ProcessMaker/Http/Controllers/Auth/LoginController.php +++ b/ProcessMaker/Http/Controllers/Auth/LoginController.php @@ -38,6 +38,8 @@ class LoginController extends Controller */ protected $redirectTo = '/'; + protected $maxAttempts; + /** * Create a new controller instance. * @@ -46,6 +48,7 @@ class LoginController extends Controller public function __construct() { $this->middleware('guest')->except(['logout', 'beforeLogout', 'keepAlive']); + $this->maxAttempts = (int) config('password-policies.login_attempts'); } /** diff --git a/ProcessMaker/Http/Middleware/VerifyChangePasswordNeeded.php b/ProcessMaker/Http/Middleware/VerifyChangePasswordNeeded.php index 018d853a70..08754c3849 100644 --- a/ProcessMaker/Http/Middleware/VerifyChangePasswordNeeded.php +++ b/ProcessMaker/Http/Middleware/VerifyChangePasswordNeeded.php @@ -2,6 +2,7 @@ namespace ProcessMaker\Http\Middleware; +use Carbon\Carbon; use Closure; use Illuminate\Support\Facades\Auth; @@ -20,6 +21,14 @@ public function handle($request, Closure $next) return redirect()->route('password.change'); } + if ($this->checkPasswordExpiration()) { + // Set the error message + session()->put('login-error', _('Your password has expired.')); + + // Redirect to change password screen + return redirect()->route('password.change'); + } + return $next($request); } @@ -27,4 +36,14 @@ public function checkForForceChangePassword() { return Auth::user() && Auth::user()->force_change_password; } + + public function checkPasswordExpiration() + { + $validationRequired = config('password-policies.expiration_days') && + Auth::user() && Auth::user()->password_changed_at; + + return $validationRequired && + (Carbon::now()->diffInDays(Auth::user()->password_changed_at) >= + config('password-policies.expiration_days')); + } } diff --git a/ProcessMaker/Models/User.php b/ProcessMaker/Models/User.php index 8e2cd4e8ef..fdbb48213b 100644 --- a/ProcessMaker/Models/User.php +++ b/ProcessMaker/Models/User.php @@ -8,12 +8,12 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Password; use Laravel\Passport\HasApiTokens; use ProcessMaker\Models\EmptyModel; use ProcessMaker\Notifications\ResetPassword as ResetPasswordNotification; use ProcessMaker\Query\Traits\PMQL; use ProcessMaker\Rules\StringHasAtLeastOneUpperCaseCharacter; -use ProcessMaker\Rules\StringHasNumberOrSpecialCharacter; use ProcessMaker\Traits\Exportable; use ProcessMaker\Traits\HasAuthorization; use ProcessMaker\Traits\HideSystemResources; @@ -122,6 +122,7 @@ class User extends Authenticatable implements HasMedia 'manager_id', 'schedule', 'force_change_password', + 'password_changed_at', ]; protected $appends = [ @@ -183,13 +184,28 @@ public static function rules(self $existing = null) */ public static function passwordRules(self $existing = null) { - return array_filter([ + // Mandatory policies + $passwordPolicies = [ 'required', $existing ? 'sometimes' : '', - 'min:8', - new StringHasNumberOrSpecialCharacter(), - new StringHasAtLeastOneUpperCaseCharacter(), - ]); + ]; + // Configurable policies + $passwordRules = Password::min(config('password-policies.minimum_length')); + if (config('password-policies.maximum_length')) { + $passwordPolicies[] = 'max:' . config('password-policies.maximum_length'); + } + if (config('password-policies.numbers')) { + $passwordRules->numbers(); + } + if (config('password-policies.uppercase')) { + $passwordPolicies[] = new StringHasAtLeastOneUpperCaseCharacter(); + } + if (config('password-policies.special')) { + $passwordRules->symbols(); + } + $passwordPolicies[] = $passwordRules; + + return $passwordPolicies; } /** diff --git a/config/password-policies.php b/config/password-policies.php new file mode 100644 index 0000000000..a0bbb07490 --- /dev/null +++ b/config/password-policies.php @@ -0,0 +1,11 @@ + env('PASSWORD_POLICY_MINIMUM_LENGTH', 8), + 'maximum_length' => env('PASSWORD_POLICY_MAXIMUM_LENGTH', null), + 'numbers' => env('PASSWORD_POLICY_NUMBERS', true), + 'uppercase' => env('PASSWORD_POLICY_UPPERCASE', true), + 'special' => env('PASSWORD_POLICY_SPECIAL', true), + 'expiration_days' => env('PASSWORD_POLICY_EXPIRATION_DAYS', null), + 'login_attempts' => env('PASSWORD_POLICY_LOGIN_ATTEMPTS', 5), +]; diff --git a/database/migrations/2023_11_28_200725_add_password_changed_at_column_to_users_table.php b/database/migrations/2023_11_28_200725_add_password_changed_at_column_to_users_table.php new file mode 100644 index 0000000000..c40f6fa2c9 --- /dev/null +++ b/database/migrations/2023_11_28_200725_add_password_changed_at_column_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('password_changed_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('password_changed_at'); + }); + } +}; diff --git a/resources/lang/de.json b/resources/lang/de.json index b8a0e92c0a..106ee7b5c9 100644 --- a/resources/lang/de.json +++ b/resources/lang/de.json @@ -1801,5 +1801,6 @@ "This environment already contains an older version of the {{ item }} named '{{ name }}.'": "Diese Umgebung enthält bereits eine ältere Version des {{ item }} namens '{{ name }}'.", "This environment already contains the same version of the {{ item }} named '{{ name }}.'": "Diese Umgebung enthält bereits die gleiche Version des {{ item }} namens '{{ name }}'.", "Visit our Gallery for more Templates": "Besuchen Sie unsere Galerie für mehr Vorlagen", - "Start a new process from a blank canvas, a text description, or a preset template.": "Starten Sie einen neuen Prozess von einer leeren Leinwand, einer Textbeschreibung oder einer voreingestellten Vorlage." + "Start a new process from a blank canvas, a text description, or a preset template.": "Starten Sie einen neuen Prozess von einer leeren Leinwand, einer Textbeschreibung oder einer voreingestellten Vorlage.", + "Your password has expired.": "Your password has expired." } \ No newline at end of file diff --git a/resources/lang/en.json b/resources/lang/en.json index b7e37e07ca..4c361672e6 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1870,6 +1870,7 @@ "Your account has been timed out for security.": "Your account has been timed out for security.", "Your password has been reset!": "Your password has been reset!", "Your password has been updated.": "Your password has been updated.", + "Your password has expired.": "Your password has expired.", "Your PMQL contains invalid syntax.": "Your PMQL contains invalid syntax.", "Your PMQL search could not be completed.": "Your PMQL search could not be completed.", "Your profile was saved.": "Your profile was saved.", diff --git a/resources/lang/es.json b/resources/lang/es.json index 7ce552475a..ccc3d6ceb5 100644 --- a/resources/lang/es.json +++ b/resources/lang/es.json @@ -1802,5 +1802,6 @@ "This environment already contains an older version of the {{ item }} named '{{ name }}.'": "Este entorno ya contiene una versión más antigua del {{ item }} llamado '{{ name }}'.", "This environment already contains the same version of the {{ item }} named '{{ name }}.'": "Este entorno ya contiene la misma versión del {{ item }} llamado '{{ name }}'.", "Visit our Gallery for more Templates": "Visita nuestra Galería para más Plantillas", - "Start a new process from a blank canvas, a text description, or a preset template.": "Inicie un nuevo proceso desde un lienzo en blanco, una descripción de texto o una plantilla preestablecida." + "Start a new process from a blank canvas, a text description, or a preset template.": "Inicie un nuevo proceso desde un lienzo en blanco, una descripción de texto o una plantilla preestablecida.", + "Your password has expired.": "Tu contraseña ha expirado." } \ No newline at end of file diff --git a/resources/lang/fr.json b/resources/lang/fr.json index e00faf5a98..4786805050 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -1801,5 +1801,6 @@ "This environment already contains an older version of the {{ item }} named '{{ name }}.'": "Cet environnement contient déjà une version plus ancienne de {{ item }} nommé '{{ name }}'.", "This environment already contains the same version of the {{ item }} named '{{ name }}.'": "Cet environnement contient déjà la même version de l'{{ item }} nommé '{{ name }}'.", "Visit our Gallery for more Templates": "Visitez notre Galerie pour plus de Modèles", - "Start a new process from a blank canvas, a text description, or a preset template.": "Démarrez un nouveau processus à partir d'une toile vierge, d'une description textuelle ou d'un modèle prédéfini." + "Start a new process from a blank canvas, a text description, or a preset template.": "Démarrez un nouveau processus à partir d'une toile vierge, d'une description textuelle ou d'un modèle prédéfini.", + "Your password has expired.": "Your password has expired." } \ No newline at end of file diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index f00ae382df..c34292b5cb 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -396,12 +396,6 @@ delete this.formData.password; return true } - if (this.formData.password.trim().length > 0 && this.formData.password.trim().length < 8) { - this.errors.password = ['Password must be at least 8 characters'] - this.password = '' - this.submitted = false - return false - } if (this.formData.password !== this.formData.confPassword) { this.errors.password = ['Passwords must match'] this.password = '' diff --git a/resources/views/auth/passwords/change.blade.php b/resources/views/auth/passwords/change.blade.php index 0627e07982..5b5ddca593 100644 --- a/resources/views/auth/passwords/change.blade.php +++ b/resources/views/auth/passwords/change.blade.php @@ -45,7 +45,7 @@ 'v-bind:class' => '{\'form-control\':true, \'is-invalid\':errors.password}']) !!} - @{{ errors.password[0] }} + @{{ error }}