From 72809c44b3dfd6d50ebf0fe996e028a55358a7ec Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Mon, 14 Sep 2026 12:14:47 -0400 Subject: [PATCH 1/3] feat(FOUR-28542): Authenticator app option still available even the user has already configured it. --- .../Http/Controllers/Api/UserController.php | 24 ++++++ .../Auth/TwoFactorAuthController.php | 12 ++- ProcessMaker/Models/User.php | 7 ++ ProcessMaker/TwoFactorAuthentication.php | 26 +++++- ..._auth_app_configured_at_to_users_table.php | 22 +++++ resources/views/admin/users/edit.blade.php | 22 +++++ resources/views/auth/2fa/otp.blade.php | 3 +- .../views/shared/users/sidebar.blade.php | 15 ++++ routes/api.php | 1 + tests/Feature/Api/ResetAuthAppTest.php | 37 +++++++++ tests/Feature/Auth/TwoFactorAuthAppTest.php | 80 +++++++++++++++++++ 11 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_09_14_000000_add_auth_app_configured_at_to_users_table.php create mode 100644 tests/Feature/Api/ResetAuthAppTest.php create mode 100644 tests/Feature/Auth/TwoFactorAuthAppTest.php 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(); + } +} From 605bc8010ba6f5749140781a73703516965fee6c Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Mon, 28 Sep 2026 09:10:17 -0400 Subject: [PATCH 2/3] fix: cursor boot notes was fixed --- ProcessMaker/Models/User.php | 8 +++ tests/Feature/Auth/TwoFactorAuthAppTest.php | 79 +++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/ProcessMaker/Models/User.php b/ProcessMaker/Models/User.php index 190b1738da..d917d84ef5 100644 --- a/ProcessMaker/Models/User.php +++ b/ProcessMaker/Models/User.php @@ -171,6 +171,14 @@ public static function boot() $user->status = 'INACTIVE'; $user->removeFromGroups(); }); + + static::updating(function (self $user) { + // Authenticator secrets include the username, so a rename invalidates + // enrolled codes. Clear enrollment so the user can scan a new QR code. + if ($user->isDirty('username') && $user->hasAuthAppConfigured()) { + $user->auth_app_configured_at = null; + } + }); } /** diff --git a/tests/Feature/Auth/TwoFactorAuthAppTest.php b/tests/Feature/Auth/TwoFactorAuthAppTest.php index 3488e24595..151134ea01 100644 --- a/tests/Feature/Auth/TwoFactorAuthAppTest.php +++ b/tests/Feature/Auth/TwoFactorAuthAppTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Auth; +use Database\Seeders\PermissionSeeder; use OTPHP\TOTP; use ParagonIE\ConstantTime\Base32; use ProcessMaker\Models\User; @@ -27,6 +28,11 @@ protected function setUp(): void ]); } + protected function withUserSetup(): void + { + (new PermissionSeeder)->run(); + } + public function test_otp_shows_authenticator_link_before_setup(): void { $this->user->update(['auth_app_configured_at' => null]); @@ -68,6 +74,79 @@ public function test_valid_auth_app_code_marks_user_as_configured(): void $this->assertNotNull($this->user->fresh()->auth_app_configured_at); } + public function test_self_service_username_change_reopens_authenticator_enrollment(): void + { + $this->user = User::factory()->create([ + 'is_administrator' => false, + 'status' => 'ACTIVE', + 'auth_app_configured_at' => now(), + ]); + $this->user->giveDirectPermission('edit-personal-profile'); + $this->user->giveDirectPermission('edit-user-and-password'); + $this->user->refresh(); + $this->flushSession(); + + $staleCode = $this->generateAuthAppCode($this->user); + + $response = $this->apiCall('PUT', route('api.users.update', $this->user), [ + 'username' => 'reenroll-user', + 'firstname' => $this->user->firstname, + 'lastname' => $this->user->lastname, + 'title' => $this->user->title, + 'email' => $this->user->email, + 'status' => $this->user->status, + ]); + + $response->assertStatus(204); + $this->user->refresh(); + $this->assertSame('reenroll-user', $this->user->username); + $this->assertNull($this->user->auth_app_configured_at); + $this->assertFalse((new TwoFactorAuthentication())->validateCode($this->user, $staleCode)); + + $this->apiCall('PUT', route('api.users.reset_auth_app', $this->user))->assertStatus(403); + + $otp = $this->webGet(route('2fa')); + $otp->assertStatus(200); + $otp->assertSee('Authenticator app', false); + + $this->webGet(route('2fa.auth_app_qr'))->assertOk(); + + $response = $this->webCall('POST', route('2fa.validate'), [ + 'code' => $this->generateAuthAppCode($this->user), + ]); + + $response->assertRedirect(route('login')); + $this->assertNotNull($this->user->fresh()->auth_app_configured_at); + } + + public function test_profile_update_without_username_change_keeps_authenticator_enrollment(): void + { + $configuredAt = now()->startOfSecond(); + $this->user = User::factory()->create([ + 'is_administrator' => false, + 'status' => 'ACTIVE', + 'auth_app_configured_at' => $configuredAt, + ]); + $this->user->giveDirectPermission('edit-personal-profile'); + $this->user->refresh(); + $this->flushSession(); + + $response = $this->apiCall('PUT', route('api.users.update', $this->user), [ + 'username' => $this->user->username, + 'firstname' => 'Updated', + 'lastname' => $this->user->lastname, + 'title' => $this->user->title, + 'email' => $this->user->email, + 'status' => $this->user->status, + ]); + + $response->assertStatus(204); + $this->assertEquals( + $configuredAt->toDateTimeString(), + $this->user->fresh()->auth_app_configured_at->toDateTimeString() + ); + } + private function generateAuthAppCode(User $user): string { $secret = trim(Base32::encodeUpper($user->uuid . '_' . $user->username), '='); From f65514ecad76c5581c410c1f558087e0a2bb8645 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Mon, 28 Sep 2026 09:29:54 -0400 Subject: [PATCH 3/3] fix authentication enrollment and adds an admin reset path --- .../Http/Controllers/Api/UserController.php | 22 ++- ProcessMaker/Models/User.php | 1 - resources/views/admin/users/edit.blade.php | 4 +- tests/Feature/Auth/TwoFactorAuthAppTest.php | 129 +++++++++++++++++- 4 files changed, 148 insertions(+), 8 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/UserController.php b/ProcessMaker/Http/Controllers/Api/UserController.php index 8655417403..d68aa6c785 100644 --- a/ProcessMaker/Http/Controllers/Api/UserController.php +++ b/ProcessMaker/Http/Controllers/Api/UserController.php @@ -352,7 +352,7 @@ public function store(Request $request) $request->validate(User::rules()); $user = new User(); - $fields = $request->json()->all(); + $fields = $this->withoutAuthenticatorEnrollment($request->json()->all()); // Enable this parameter if the parameter is not sent $fields['email_task_notification'] = $request->input('email_task_notification', true); @@ -499,7 +499,7 @@ public function update(User $user, Request $request) throw new AuthorizationException(__('Not authorized to update this user.')); } - $fields = $request->json()->all(); + $fields = $this->withoutAuthenticatorEnrollment($request->json()->all()); $isSelfServiceUpdate = $this->authorizeSelfServiceUpdate($authenticatedUser, $user, $fields); $rules = User::rules($user); if ($isSelfServiceUpdate) { @@ -646,6 +646,24 @@ private function authorizeSelfServiceUpdate(User $authenticatedUser, User $targe return true; } + /** + * Drop authenticator enrollment from profile create/update payloads. + * + * The column is not mass assignable. Enrollment is recorded only after a + * verified authenticator code, and cleared only by the reset endpoint or + * a username change. Admin edit posts the full user snapshot, so a stale + * page must not be able to set or clear this lock. + * + * @param array $fields + * @return array + */ + private function withoutAuthenticatorEnrollment(array $fields): array + { + unset($fields['auth_app_configured_at']); + + return $fields; + } + /** * Merge self-service metadata into the persisted server-managed values. */ diff --git a/ProcessMaker/Models/User.php b/ProcessMaker/Models/User.php index d917d84ef5..8b9d89d64b 100644 --- a/ProcessMaker/Models/User.php +++ b/ProcessMaker/Models/User.php @@ -130,7 +130,6 @@ class User extends Authenticatable implements HasMedia 'password_changed_at', 'connected_accounts', 'preferences_2fa', - 'auth_app_configured_at', 'email_task_notification', ]; diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index c7df98421a..7777a3b271 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -611,7 +611,9 @@ if (!this.validatePassword()) return false; if (@json($enabled2FA) && typeof this.formData.preferences_2fa != "undefined" && this.formData.preferences_2fa != null && this.formData.preferences_2fa.length < 1) return false; - ProcessMaker.apiClient.put('users/' + this.formData.id, this.formData) + const payload = { ...this.formData }; + delete payload.auth_app_configured_at; + ProcessMaker.apiClient.put('users/' + this.formData.id, payload) .then(response => { ProcessMaker.alert(this.$t('User Updated Successfully '), 'success'); this.originalEmail = this.formData.email; diff --git a/tests/Feature/Auth/TwoFactorAuthAppTest.php b/tests/Feature/Auth/TwoFactorAuthAppTest.php index 151134ea01..0a07399b15 100644 --- a/tests/Feature/Auth/TwoFactorAuthAppTest.php +++ b/tests/Feature/Auth/TwoFactorAuthAppTest.php @@ -35,7 +35,7 @@ protected function withUserSetup(): void public function test_otp_shows_authenticator_link_before_setup(): void { - $this->user->update(['auth_app_configured_at' => null]); + $this->user->forceFill(['auth_app_configured_at' => null])->save(); $response = $this->webGet(route('2fa')); @@ -45,7 +45,7 @@ public function test_otp_shows_authenticator_link_before_setup(): void public function test_otp_hides_authenticator_link_after_setup(): void { - $this->user->update(['auth_app_configured_at' => now()]); + $this->user->forceFill(['auth_app_configured_at' => now()])->save(); $response = $this->webGet(route('2fa')); @@ -55,7 +55,7 @@ public function test_otp_hides_authenticator_link_after_setup(): void public function test_auth_app_qr_is_blocked_after_setup(): void { - $this->user->update(['auth_app_configured_at' => now()]); + $this->user->forceFill(['auth_app_configured_at' => now()])->save(); $response = $this->webGet(route('2fa.auth_app_qr')); @@ -64,7 +64,7 @@ public function test_auth_app_qr_is_blocked_after_setup(): void public function test_valid_auth_app_code_marks_user_as_configured(): void { - $this->user->update(['auth_app_configured_at' => null]); + $this->user->forceFill(['auth_app_configured_at' => null])->save(); $code = $this->generateAuthAppCode($this->user); @@ -119,6 +119,114 @@ public function test_self_service_username_change_reopens_authenticator_enrollme $this->assertNotNull($this->user->fresh()->auth_app_configured_at); } + public function test_admin_save_with_stale_snapshot_does_not_change_authenticator_enrollment(): void + { + $configuredAt = now()->startOfSecond(); + $targetUser = User::factory()->create([ + 'auth_app_configured_at' => $configuredAt, + ]); + + $response = $this->apiCall('PUT', route('api.users.update', $targetUser), $this->profileSnapshot($targetUser, [ + 'firstname' => 'Renamed', + 'auth_app_configured_at' => null, + ])); + + $response->assertStatus(204); + $targetUser->refresh(); + $this->assertSame('Renamed', $targetUser->firstname); + $this->assertEquals( + $configuredAt->toDateTimeString(), + $targetUser->auth_app_configured_at?->toDateTimeString() + ); + } + + public function test_admin_save_cannot_mark_or_restore_authenticator_enrollment(): void + { + $targetUser = User::factory()->create([ + 'auth_app_configured_at' => null, + ]); + $forgedAt = now()->subHour()->startOfSecond(); + + $response = $this->apiCall('PUT', route('api.users.update', $targetUser), $this->profileSnapshot($targetUser, [ + 'auth_app_configured_at' => $forgedAt->toDateTimeString(), + ])); + + $response->assertStatus(204); + $this->assertNull($targetUser->fresh()->auth_app_configured_at); + + $targetUser->forceFill(['auth_app_configured_at' => $forgedAt])->save(); + $targetUser->auth_app_configured_at = null; + $targetUser->save(); + + $response = $this->apiCall('PUT', route('api.users.update', $targetUser), $this->profileSnapshot($targetUser, [ + 'auth_app_configured_at' => $forgedAt->toDateTimeString(), + ])); + + $response->assertStatus(204); + $this->assertNull($targetUser->fresh()->auth_app_configured_at); + } + + public function test_create_user_ignores_authenticator_enrollment(): void + { + $username = 'new-auth-user-' . uniqid(); + + $response = $this->apiCall('POST', route('api.users.store'), [ + 'username' => $username, + 'firstname' => 'New', + 'lastname' => 'User', + 'email' => $username . '@example.com', + 'status' => 'ACTIVE', + 'password' => 'Password1!', + 'auth_app_configured_at' => now()->toDateTimeString(), + ]); + + $response->assertStatus(201); + $this->assertNull(User::where('username', $username)->first()->auth_app_configured_at); + } + + public function test_self_service_save_ignores_authenticator_timestamp_in_profile_snapshot(): void + { + $configuredAt = now()->startOfSecond(); + $this->user = User::factory()->create([ + 'is_administrator' => false, + 'status' => 'ACTIVE', + 'auth_app_configured_at' => $configuredAt, + ]); + $this->user->giveDirectPermission('edit-personal-profile'); + $this->user->refresh(); + $this->flushSession(); + + $response = $this->apiCall('PUT', route('api.users.update', $this->user), $this->profileSnapshot($this->user, [ + 'firstname' => 'Updated', + 'auth_app_configured_at' => null, + ])); + + $response->assertStatus(204); + $this->user->refresh(); + $this->assertSame('Updated', $this->user->firstname); + $this->assertEquals( + $configuredAt->toDateTimeString(), + $this->user->auth_app_configured_at?->toDateTimeString() + ); + } + + public function test_username_change_still_clears_enrollment_when_snapshot_keeps_timestamp(): void + { + $configuredAt = now()->startOfSecond(); + $targetUser = User::factory()->create([ + 'auth_app_configured_at' => $configuredAt, + ]); + + $response = $this->apiCall('PUT', route('api.users.update', $targetUser), $this->profileSnapshot($targetUser, [ + 'username' => 'renamed-enrolled-' . uniqid(), + 'auth_app_configured_at' => $configuredAt->toDateTimeString(), + ])); + + $response->assertStatus(204); + $targetUser->refresh(); + $this->assertNull($targetUser->auth_app_configured_at); + } + public function test_profile_update_without_username_change_keeps_authenticator_enrollment(): void { $configuredAt = now()->startOfSecond(); @@ -147,6 +255,19 @@ public function test_profile_update_without_username_change_keeps_authenticator_ ); } + private function profileSnapshot(User $user, array $overrides = []): array + { + return array_merge([ + 'username' => $user->username, + 'firstname' => $user->firstname, + 'lastname' => $user->lastname, + 'title' => $user->title, + 'email' => $user->email, + 'status' => $user->status, + 'auth_app_configured_at' => $user->auth_app_configured_at?->toDateTimeString(), + ], $overrides); + } + private function generateAuthAppCode(User $user): string { $secret = trim(Base32::encodeUpper($user->uuid . '_' . $user->username), '=');