diff --git a/ProcessMaker/Http/Controllers/Api/UserController.php b/ProcessMaker/Http/Controllers/Api/UserController.php index ea56691df4..817761a5cb 100644 --- a/ProcessMaker/Http/Controllers/Api/UserController.php +++ b/ProcessMaker/Http/Controllers/Api/UserController.php @@ -1150,4 +1150,28 @@ public function updateLanguage(Request $request) return response([], 204); } + + public function resetAuthApp(User $user) + { + if (!Auth::user()->can('edit', $user)) { + throw new AuthorizationException(__('Not authorized to update this user.')); + } + + if (!$user->hasAuthAppConfigured()) { + return response([ + 'message' => __('Authenticator app is not configured for this user.'), + ], 422); + } + + $original = $user->getOriginal(); + $user->auth_app_configured_at = null; + $user->saveOrFail(); + + UserUpdated::dispatch($user, $user->getChanges(), $original); + + return response([ + 'message' => __('Authenticator app reset successfully.'), + 'auth_app_configured_at' => null, + ]); + } } diff --git a/ProcessMaker/Http/Controllers/Auth/TwoFactorAuthController.php b/ProcessMaker/Http/Controllers/Auth/TwoFactorAuthController.php index ed50fda849..5d8dc3ce24 100644 --- a/ProcessMaker/Http/Controllers/Auth/TwoFactorAuthController.php +++ b/ProcessMaker/Http/Controllers/Auth/TwoFactorAuthController.php @@ -59,7 +59,9 @@ public function displayTwoFactorAuthForm(Request $request) } // Display view - return view('auth.2fa.otp'); + return view('auth.2fa.otp', [ + 'showAuthAppSetup' => $this->twoFactorAuthentication->userCanSetUpAuthApp($user), + ]); } public function validateTwoFactorAuthCode(Request $request) @@ -89,6 +91,10 @@ public function validateTwoFactorAuthCode(Request $request) session()->put(self::TFA_VALIDATED, $validated); if ($validated) { + if ($this->twoFactorAuthentication->isAuthAppCode($code)) { + $this->twoFactorAuthentication->markAuthAppConfigured($user); + } + // Remove 2fa values in session session()->remove(self::TFA_MESSAGE); session()->remove(self::TFA_ERROR); @@ -133,6 +139,10 @@ public function displayAuthAppQr(Request $request) return redirect()->route('login'); } + if (!$this->twoFactorAuthentication->userCanSetUpAuthApp($user)) { + return redirect()->route('2fa'); + } + // Generate QR code $qrCode = $this->twoFactorAuthentication->generateQr($user); diff --git a/ProcessMaker/Models/User.php b/ProcessMaker/Models/User.php index 87a8ed4487..086821e4cb 100644 --- a/ProcessMaker/Models/User.php +++ b/ProcessMaker/Models/User.php @@ -130,6 +130,7 @@ class User extends Authenticatable implements HasMedia 'password_changed_at', 'connected_accounts', 'preferences_2fa', + 'auth_app_configured_at', 'email_task_notification', ]; @@ -144,6 +145,7 @@ class User extends Authenticatable implements HasMedia 'loggedin_at' => 'datetime', 'schedule' => 'array', 'preferences_2fa' => 'array', + 'auth_app_configured_at' => 'datetime', ]; /** @@ -550,6 +552,11 @@ public function sessions(): HasMany return $this->hasMany(UserSession::class); } + public function hasAuthAppConfigured(): bool + { + return $this->auth_app_configured_at !== null; + } + public function getValid2FAPreferences(): array { // Get global and user values diff --git a/ProcessMaker/TwoFactorAuthentication.php b/ProcessMaker/TwoFactorAuthentication.php index 4fe8f04807..42139bb99c 100644 --- a/ProcessMaker/TwoFactorAuthentication.php +++ b/ProcessMaker/TwoFactorAuthentication.php @@ -80,18 +80,36 @@ private function getCodeForEmailSms(User $user): string return $otp->now(); } - public function validateCode(User $user, string $code) + public function isAuthAppCode(string $code): bool { - // The code is for Google Authenticator app? - $forGoogleAuthApp = strlen($code) === 6; + return strlen($code) === 6; + } + public function validateCode(User $user, string $code) + { // Create OTP instance - $otp = $this->createOtpInstance($user, $forGoogleAuthApp); + $otp = $this->createOtpInstance($user, $this->isAuthAppCode($code)); // Validate code return $otp->verify($code); } + public function markAuthAppConfigured(User $user): void + { + if ($user->hasAuthAppConfigured()) { + return; + } + + $user->auth_app_configured_at = now(); + $user->save(); + } + + public function userCanSetUpAuthApp(User $user): bool + { + return in_array(self::AUTH_APP, $user->getValid2FAPreferences(), true) + && !$user->hasAuthAppConfigured(); + } + /** * @param User $user * @param string $code diff --git a/database/migrations/2026_09_14_000000_add_auth_app_configured_at_to_users_table.php b/database/migrations/2026_09_14_000000_add_auth_app_configured_at_to_users_table.php new file mode 100644 index 0000000000..a45636298f --- /dev/null +++ b/database/migrations/2026_09_14_000000_add_auth_app_configured_at_to_users_table.php @@ -0,0 +1,22 @@ +timestamp('auth_app_configured_at')->nullable()->after('preferences_2fa'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('auth_app_configured_at'); + }); + } +}; diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index 180739726b..a9aef5990c 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -287,6 +287,7 @@ originalEmail: '', emailHasChanged: false, canCreateTokens: @json($canCreateTokens), + resettingAuthApp: false, } }, created() { @@ -555,6 +556,27 @@ this.errors = error.response.data.errors; }); }, + resetAuthApp() { + if (!confirm(this.$t('Reset the authenticator app for this user?'))) { + return; + } + + this.resettingAuthApp = true; + + ProcessMaker.apiClient.put(`users/${this.formData.id}/reset_auth_app`) + .then(() => { + this.formData.auth_app_configured_at = null; + ProcessMaker.alert(this.$t('Authenticator app reset successfully.'), 'success'); + }) + .catch(error => { + const message = error.response?.data?.message + || this.$t('Unable to reset authenticator app.'); + ProcessMaker.alert(message, 'danger'); + }) + .finally(() => { + this.resettingAuthApp = false; + }); + }, loadGroups(filter) { filter = typeof filter === 'string' ? '?filter=' + filter + '&' : '?'; ProcessMaker.apiClient diff --git a/resources/views/auth/2fa/otp.blade.php b/resources/views/auth/2fa/otp.blade.php index b08295a3c0..6320f076c3 100644 --- a/resources/views/auth/2fa/otp.blade.php +++ b/resources/views/auth/2fa/otp.blade.php @@ -69,8 +69,7 @@ class="form-control{{ $errors->has('code') ? ' is-invalid' : '' }}" {{ __('Send Again') }} - @if (in_array(\ProcessMaker\TwoFactorAuthentication::AUTH_APP, - config('password-policies.2fa_method', []))) + @if ($showAuthAppSetup ?? false)
+ @if (!\Request::is('profile/edit') && in_array(\ProcessMaker\TwoFactorAuthentication::AUTH_APP, $global2FAEnabled)) +