From f3e595ed452ea62a9d6351da0f68c83879c8c95c Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 16:48:21 +0200 Subject: [PATCH] feat(auth): store 2FA recovery codes (encrypted) on users Add the two_factor_recovery_codes column (encrypted:array) + User helpers replaceRecoveryCodes / recoveryCodes / hasRecoveryCodes / useRecoveryCode (one-time consumption). Hidden from serialization. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Models/User.php | 45 ++++++++++++++++++- ...o_factor_recovery_codes_to_users_table.php | 22 +++++++++ tests/Feature/UserRecoveryCodesTest.php | 35 +++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php create mode 100644 tests/Feature/UserRecoveryCodesTest.php diff --git a/app/Models/User.php b/app/Models/User.php index b38ca1c..127251d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,9 +8,10 @@ use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Str; #[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])] -#[Hidden(['password', 'remember_token', 'two_factor_secret'])] +#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] class User extends Authenticatable { /** @use HasFactory */ @@ -22,11 +23,53 @@ class User extends Authenticatable 'email_verified_at' => 'datetime', 'password' => 'hashed', 'two_factor_secret' => 'encrypted', + 'two_factor_recovery_codes' => 'encrypted:array', 'two_factor_confirmed_at' => 'datetime', 'must_change_password' => 'boolean', ]; } + /** @return array The 8 freshly generated one-time codes (also persisted). */ + public function replaceRecoveryCodes(): array + { + $codes = array_map( + fn () => Str::random(10).'-'.Str::random(10), + range(1, 8), + ); + + $this->forceFill(['two_factor_recovery_codes' => $codes])->save(); + + return $codes; + } + + /** @return array */ + public function recoveryCodes(): array + { + return $this->two_factor_recovery_codes ?? []; + } + + public function hasRecoveryCodes(): bool + { + return count($this->recoveryCodes()) > 0; + } + + /** Consume one recovery code (one-time). Returns true if it was valid + removed. */ + public function useRecoveryCode(string $code): bool + { + $code = trim($code); + $codes = $this->recoveryCodes(); + + if (! in_array($code, $codes, true)) { + return false; + } + + $this->forceFill([ + 'two_factor_recovery_codes' => array_values(array_filter($codes, fn ($c) => $c !== $code)), + ])->save(); + + return true; + } + public function hasTwoFactorEnabled(): bool { return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret); diff --git a/database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php b/database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php new file mode 100644 index 0000000..058048a --- /dev/null +++ b/database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php @@ -0,0 +1,22 @@ +text('two_factor_recovery_codes')->nullable()->after('two_factor_secret'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('two_factor_recovery_codes'); + }); + } +}; diff --git a/tests/Feature/UserRecoveryCodesTest.php b/tests/Feature/UserRecoveryCodesTest.php new file mode 100644 index 0000000..9dcb612 --- /dev/null +++ b/tests/Feature/UserRecoveryCodesTest.php @@ -0,0 +1,35 @@ +create(); + $codes = $user->replaceRecoveryCodes(); + + $this->assertCount(8, $codes); + $this->assertCount(8, array_unique($codes)); + $this->assertSame($codes, $user->fresh()->recoveryCodes()); + $this->assertTrue($user->fresh()->hasRecoveryCodes()); + } + + public function test_use_recovery_code_consumes_once(): void + { + $user = User::factory()->create(); + $codes = $user->replaceRecoveryCodes(); + $one = $codes[0]; + + $this->assertTrue($user->useRecoveryCode(' '.$one.' ')); // trims + $this->assertFalse($user->fresh()->useRecoveryCode($one)); // already consumed + $this->assertFalse($user->fresh()->useRecoveryCode('not-a-code')); + $this->assertCount(7, $user->fresh()->recoveryCodes()); + } +}