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)
{{ __('Authenticator app') }} diff --git a/resources/views/shared/users/sidebar.blade.php b/resources/views/shared/users/sidebar.blade.php index 68a60d4853..4b4c02d5d6 100644 --- a/resources/views/shared/users/sidebar.blade.php +++ b/resources/views/shared/users/sidebar.blade.php @@ -92,6 +92,21 @@ >
+ @if (!\Request::is('profile/edit') && in_array(\ProcessMaker\TwoFactorAuthentication::AUTH_APP, $global2FAEnabled)) +
+ + + {{ __('The user will configure a new authenticator on next login.') }} + +
+ @endif @endif diff --git a/routes/api.php b/routes/api.php index a94349b9b8..7b514db371 100644 --- a/routes/api.php +++ b/routes/api.php @@ -66,6 +66,7 @@ // User Groups Route::put('users/{user}/groups', [UserController::class, 'updateGroups'])->name('users.groups.update')->middleware('can:edit-users'); + Route::put('users/{user}/reset_auth_app', [UserController::class, 'resetAuthApp'])->name('users.reset_auth_app')->middleware('can:edit-users'); // User personal access tokens Route::get('users/{user}/tokens', [UserTokenController::class, 'index'])->name('users.tokens.index'); // Permissions handled in the controller Route::get('users/{user}/tokens/{tokenId}', [UserTokenController::class, 'show'])->name('users.tokens.show'); // Permissions handled in the controller diff --git a/tests/Feature/Api/ResetAuthAppTest.php b/tests/Feature/Api/ResetAuthAppTest.php new file mode 100644 index 0000000000..338ad591c7 --- /dev/null +++ b/tests/Feature/Api/ResetAuthAppTest.php @@ -0,0 +1,37 @@ +create([ + 'auth_app_configured_at' => now(), + ]); + + $response = $this->apiCall('PUT', route('api.users.reset_auth_app', $targetUser)); + + $response->assertStatus(200); + $this->assertNull($targetUser->fresh()->auth_app_configured_at); + } + + public function test_reset_returns_error_when_authenticator_is_not_configured(): void + { + $targetUser = User::factory()->create([ + 'auth_app_configured_at' => null, + ]); + + $response = $this->apiCall('PUT', route('api.users.reset_auth_app', $targetUser)); + + $response->assertStatus(422); + } +} diff --git a/tests/Feature/Auth/TwoFactorAuthAppTest.php b/tests/Feature/Auth/TwoFactorAuthAppTest.php new file mode 100644 index 0000000000..3488e24595 --- /dev/null +++ b/tests/Feature/Auth/TwoFactorAuthAppTest.php @@ -0,0 +1,80 @@ +requestHelperSetUp(); + + config([ + 'password-policies.2fa_enabled' => true, + 'password-policies.2fa_method' => [TwoFactorAuthentication::AUTH_APP], + ]); + } + + public function test_otp_shows_authenticator_link_before_setup(): void + { + $this->user->update(['auth_app_configured_at' => null]); + + $response = $this->webGet(route('2fa')); + + $response->assertStatus(200); + $response->assertSee('Authenticator app', false); + } + + public function test_otp_hides_authenticator_link_after_setup(): void + { + $this->user->update(['auth_app_configured_at' => now()]); + + $response = $this->webGet(route('2fa')); + + $response->assertStatus(200); + $response->assertDontSee('>Authenticator app<', false); + } + + public function test_auth_app_qr_is_blocked_after_setup(): void + { + $this->user->update(['auth_app_configured_at' => now()]); + + $response = $this->webGet(route('2fa.auth_app_qr')); + + $response->assertRedirect(route('2fa')); + } + + public function test_valid_auth_app_code_marks_user_as_configured(): void + { + $this->user->update(['auth_app_configured_at' => null]); + + $code = $this->generateAuthAppCode($this->user); + + $response = $this->webCall('POST', route('2fa.validate'), ['code' => $code]); + + $response->assertRedirect(route('login')); + $this->assertNotNull($this->user->fresh()->auth_app_configured_at); + } + + private function generateAuthAppCode(User $user): string + { + $secret = trim(Base32::encodeUpper($user->uuid . '_' . $user->username), '='); + $otp = TOTP::createFromSecret($secret); + $otp->setIssuer('ProcessMaker'); + $otp->setLabel($user->username); + + return $otp->now(); + } +}