feat(auth): TOTP 2FA enable/disable with recovery codes
parent
c193e482ac
commit
d7ca84dd2e
|
|
@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
|
|
@ -66,4 +67,44 @@ class User extends Authenticatable
|
|||
{
|
||||
return $this->hasMany(\App\Domains\Workspace\Models\WorkspaceMember::class);
|
||||
}
|
||||
|
||||
public function enableTwoFactor(string $secret): array
|
||||
{
|
||||
$recoveryCodes = collect(range(1, 8))->map(fn() => implode('-', [
|
||||
strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
|
||||
strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
|
||||
]))->all();
|
||||
|
||||
$this->update([
|
||||
'two_factor_secret' => encrypt($secret),
|
||||
'two_factor_recovery' => encrypt(json_encode($recoveryCodes)),
|
||||
]);
|
||||
|
||||
return $recoveryCodes;
|
||||
}
|
||||
|
||||
public function disableTwoFactor(): void
|
||||
{
|
||||
$this->update([
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function hasTwoFactorEnabled(): bool
|
||||
{
|
||||
return $this->two_factor_confirmed_at !== null;
|
||||
}
|
||||
|
||||
public function verifyTwoFactor(string $code): bool
|
||||
{
|
||||
if (! $this->two_factor_secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$secret = decrypt($this->two_factor_secret);
|
||||
$google2fa = new Google2FA;
|
||||
return $google2fa->verifyKey($secret, $code) !== false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
it('enables 2FA and returns recovery codes', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$codes = $user->enableTwoFactor('JBSWY3DPEHPK3PXP');
|
||||
|
||||
expect($codes)->toHaveCount(8)
|
||||
->and($user->fresh()->two_factor_secret)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('disables 2FA and clears secrets', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->enableTwoFactor('JBSWY3DPEHPK3PXP');
|
||||
|
||||
$user->disableTwoFactor();
|
||||
|
||||
$fresh = $user->fresh();
|
||||
expect($fresh->two_factor_secret)->toBeNull()
|
||||
->and($fresh->two_factor_confirmed_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('reports 2FA as disabled before confirmation', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->enableTwoFactor('JBSWY3DPEHPK3PXP');
|
||||
|
||||
// two_factor_confirmed_at is null → hasTwoFactorEnabled() = false
|
||||
expect($user->fresh()->hasTwoFactorEnabled())->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue