Skip to content

Commit

Permalink
Recovery: Skip the recovery code challenge when no codes exist (#30)
Browse files Browse the repository at this point in the history
* Use security indicator in example views

* Recovery: Skip the recovery code challenge when no codes exist
  • Loading branch information
claudiodekker authored Apr 3, 2023
1 parent 873eee6 commit 72ab21f
Show file tree
Hide file tree
Showing 6 changed files with 99 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ trait AccountRecoveryChallengeViewTests
/** @test */
public function the_account_recovery_challenge_page_uses_blade_views(): void
{
$user = $this->generateUser();
$user = $this->generateUser(['recovery_codes' => ['H4PFK-ENVZV', 'PIPIM-7LTUT', 'GPP13-AEXMR', 'WGAHD-95VNQ', 'BSFYG-VFG2N', 'AWOPQ-NWYJX', '2PVJM-QHPBM', 'STR7J-5ND0P']]);
$token = Password::getRepository()->create($user);

$response = $this->get(route('recover-account.challenge', [
Expand Down
4 changes: 2 additions & 2 deletions packages/bladebones/stubs/defaults/views/home.blade.stub
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<h1>Authenticated as {{ Auth::user()->name }}</h1>

@if (! Auth::user()->recovery_codes)
<h3 style="color: #FF0000;">You currently do not have any recovery codes configured</h3>
@if (($indicator = Auth::user()->accountSecurityIndicator()) && $indicator->hasIssues())
<h3 style="padding: 4px; color: #FFFFFF; background-color: {{ $indicator->color() === 'RED' ? '#FF0000': '#FFA500' }};">{{ $indicator->message() }}</h3>
@endif

<a href="{{ route('auth.settings') }}">View Authentication Settings</a>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<h1>Authentication Settings</h1>

@if (($indicator = Auth::user()->accountSecurityIndicator()) && $indicator->hasIssues())
<h3 style="padding: 4px; color: #FFFFFF; background-color: {{ $indicator->color() === 'RED' ? '#FF0000': '#FFA500' }};">{{ $indicator->message() }}</h3>
@endif

<a href="/home">Home</a>

@if (session('status'))
Expand Down Expand Up @@ -115,6 +120,10 @@
<h2>Recovery Codes</h2>
<p>Recovery codes can be used to access your account in the event you lose access to your credentials.</p>

@if (! Auth::user()->recovery_codes)
<p style="color: #FF0000">You currently have no recovery codes configured.</p>
@endif

<form method="POST" action="{{ route('auth.settings.generate_recovery') }}">
<input type="hidden" name="_token" value="{{ csrf_token() }}" />
<input type="hidden" name="_method" value="POST" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ public function create(Request $request, string $token)
return $this->sendInvalidRecoveryLinkResponse($request);
}

if (! $this->hasRecoveryCodes($request, $user)) {
$this->invalidateRecoveryLink($request, $user);

return $this->handleAccountRecoveredResponse($request, $user);
}

return $this->sendChallengePageResponse($request, $token);
}

Expand Down Expand Up @@ -105,6 +111,12 @@ public function store(Request $request, string $token)
return $this->sendInvalidRecoveryLinkResponse($request);
}

if (! $this->hasRecoveryCodes($request, $user)) {
$this->invalidateRecoveryLink($request, $user);

return $this->handleAccountRecoveredResponse($request, $user);
}

if (! $this->hasValidRecoveryCode($request, $user)) {
$this->incrementRateLimitingCounter($request);
$this->emitAccountRecoveryFailedEvent($request, $user);
Expand Down Expand Up @@ -161,12 +173,20 @@ protected function authenticate(Request $request, Authenticatable $user): void
Auth::login($user);
}

/**
* Determine whether the user has recovery codes.
*/
protected function hasRecoveryCodes(Request $request, Authenticatable $user): bool
{
return (bool) $user->recovery_codes;
}

/**
* Determine whether the user has entered a valid confirmation code.
*/
protected function hasValidRecoveryCode(Request $request, Authenticatable $user): bool
{
return RecoveryCodeManager::from($user->recovery_codes ?? [])->contains($request->input('code'));
return RecoveryCodeManager::from($user->recovery_codes)->contains($request->input('code'));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use ClaudioDekker\LaravelAuth\Events\AccountRecoveryFailed;
use ClaudioDekker\LaravelAuth\Events\SudoModeEnabled;
use ClaudioDekker\LaravelAuth\Http\Middleware\EnsureSudoMode;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
Expand Down Expand Up @@ -39,6 +40,32 @@ public function the_user_account_can_be_recovered(): void
Event::assertNotDispatched(SudoModeEnabled::class);
}

/** @test */
public function the_account_recovery_challenge_code_verification_request_accepts_any_code_when_the_users_recovery_codes_are_cleared(): void
{
Carbon::setTestNow(now());
Event::fake([AccountRecovered::class, AccountRecoveryFailed::class, SudoModeEnabled::class]);
$user = $this->generateUser(['recovery_codes' => null]);
$repository = Password::getRepository();
$token = $repository->create($user);
$this->assertTrue($repository->exists($user, $token));

$response = $this->post(route('recover-account.challenge', ['token' => $token]), [
'email' => $user->getEmailForPasswordReset(),
'code' => 'INVLD-CODES',
]);

$response->assertRedirect(route('auth.settings'));
$response->assertSessionMissing(EnsureSudoMode::REQUIRED_AT_KEY);
$response->assertSessionHas(EnsureSudoMode::CONFIRMED_AT_KEY, now()->unix());
$this->assertFullyAuthenticatedAs($response, $user);
$this->assertFalse($repository->exists($user, $token));
Event::assertDispatched(AccountRecovered::class, fn ($event) => $event->user->is($user) && $event->request === request());
Event::assertNotDispatched(AccountRecoveryFailed::class);
Event::assertNotDispatched(SudoModeEnabled::class);
Carbon::setTestNow();
}

/** @test */
public function the_user_account_cannot_be_recovered_when_authenticated(): void
{
Expand Down Expand Up @@ -160,29 +187,4 @@ public function the_user_account_cannot_be_recovered_when_an_invalid_recovery_co
Event::assertNotDispatched(AccountRecovered::class);
Event::assertNotDispatched(SudoModeEnabled::class);
}

/** @test */
public function the_user_account_cannot_be_recovered_when_the_user_has_no_configured_recovery_codes(): void
{
Event::fake([AccountRecovered::class, AccountRecoveryFailed::class, SudoModeEnabled::class]);
$user = $this->generateUser(['recovery_codes' => null]);
$repository = Password::getRepository();
$token = $repository->create($user);

$response = $this->post(route('recover-account.challenge', ['token' => $token]), [
'email' => $user->getEmailForPasswordReset(),
'code' => 'PIPIM-7LTUT',
]);

$this->assertInstanceOf(ValidationException::class, $response->exception);
$this->assertSame(['code' => [__('laravel-auth::auth.challenge.recovery')]], $response->exception->errors());
$this->assertTrue($repository->exists($user, $token));
$this->assertNull($user->fresh()->recovery_codes);
$this->assertGuest();
$response->assertSessionMissing(EnsureSudoMode::REQUIRED_AT_KEY);
$response->assertSessionMissing(EnsureSudoMode::CONFIRMED_AT_KEY);
Event::assertDispatched(AccountRecoveryFailed::class, fn ($event) => $event->user->is($user) && $event->request === request());
Event::assertNotDispatched(AccountRecovered::class);
Event::assertNotDispatched(SudoModeEnabled::class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
namespace ClaudioDekker\LaravelAuth\Testing\Partials\Challenges\Recovery;

use App\Providers\RouteServiceProvider;
use Carbon\Carbon;
use ClaudioDekker\LaravelAuth\Events\AccountRecovered;
use ClaudioDekker\LaravelAuth\Events\AccountRecoveryFailed;
use ClaudioDekker\LaravelAuth\Events\SudoModeEnabled;
use ClaudioDekker\LaravelAuth\Http\Middleware\EnsureSudoMode;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Password;
use Symfony\Component\HttpKernel\Exception\HttpException;

Expand All @@ -12,7 +17,7 @@ trait ViewAccountRecoveryChallengePageTests
/** @test */
public function the_account_recovery_challenge_page_can_be_viewed(): void
{
$user = $this->generateUser();
$user = $this->generateUser(['recovery_codes' => ['H4PFK-ENVZV', 'PIPIM-7LTUT', 'GPP13-AEXMR', 'WGAHD-95VNQ', 'BSFYG-VFG2N', 'AWOPQ-NWYJX', '2PVJM-QHPBM', 'STR7J-5ND0P']]);
$token = Password::getRepository()->create($user);

$response = $this->get(route('recover-account.challenge', [
Expand All @@ -23,10 +28,36 @@ public function the_account_recovery_challenge_page_can_be_viewed(): void
$response->assertOk();
}

/** @test */
public function the_account_recovery_challenge_page_is_skipped_when_the_user_does_not_have_any_recovery_codes(): void
{
Carbon::setTestNow(now());
Event::fake([AccountRecovered::class, AccountRecoveryFailed::class, SudoModeEnabled::class]);
$user = $this->generateUser(['recovery_codes' => null]);
$repository = Password::getRepository();
$token = $repository->create($user);
$this->assertTrue($repository->exists($user, $token));

$response = $this->get(route('recover-account.challenge', [
'token' => $token,
'email' => $user->getEmailForPasswordReset(),
]));

$response->assertRedirect(route('auth.settings'));
$response->assertSessionMissing(EnsureSudoMode::REQUIRED_AT_KEY);
$response->assertSessionHas(EnsureSudoMode::CONFIRMED_AT_KEY, now()->unix());
$this->assertFullyAuthenticatedAs($response, $user);
$this->assertFalse($repository->exists($user, $token));
Event::assertDispatched(AccountRecovered::class, fn ($event) => $event->user->is($user) && $event->request === request());
Event::assertNotDispatched(AccountRecoveryFailed::class);
Event::assertNotDispatched(SudoModeEnabled::class);
Carbon::setTestNow();
}

/** @test */
public function the_account_recovery_challenge_page_cannot_be_viewed_when_authenticated(): void
{
$user = $this->generateUser();
$user = $this->generateUser(['recovery_codes' => ['H4PFK-ENVZV', 'PIPIM-7LTUT', 'GPP13-AEXMR', 'WGAHD-95VNQ', 'BSFYG-VFG2N', 'AWOPQ-NWYJX', '2PVJM-QHPBM', 'STR7J-5ND0P']]);

$response = $this->actingAs($user)
->get(route('recover-account.challenge', ['token' => 'foo']));
Expand All @@ -37,7 +68,7 @@ public function the_account_recovery_challenge_page_cannot_be_viewed_when_authen
/** @test */
public function the_account_recovery_challenge_page_cannot_be_viewed_when_the_provided_email_does_not_resolve_to_an_existing_user(): void
{
$user = $this->generateUser();
$user = $this->generateUser(['recovery_codes' => ['H4PFK-ENVZV', 'PIPIM-7LTUT', 'GPP13-AEXMR', 'WGAHD-95VNQ', 'BSFYG-VFG2N', 'AWOPQ-NWYJX', '2PVJM-QHPBM', 'STR7J-5ND0P']]);
$token = Password::getRepository()->create($user);

$response = $this->get(route('recover-account.challenge', [
Expand All @@ -53,8 +84,8 @@ public function the_account_recovery_challenge_page_cannot_be_viewed_when_the_pr
/** @test */
public function the_account_recovery_challenge_page_cannot_be_viewed_when_the_recovery_token_does_not_belong_to_the_user_that_is_being_recovered(): void
{
$userA = $this->generateUser(['id' => 1, 'email' => '[email protected]']);
$userB = $this->generateUser(['id' => 2, 'email' => '[email protected]', $this->usernameField() => $this->anotherUsername()]);
$userA = $this->generateUser(['id' => 1, 'email' => '[email protected]', 'recovery_codes' => ['H4PFK-ENVZV', 'PIPIM-7LTUT', 'GPP13-AEXMR', 'WGAHD-95VNQ', 'BSFYG-VFG2N', 'AWOPQ-NWYJX', '2PVJM-QHPBM', 'STR7J-5ND0P']]);
$userB = $this->generateUser(['id' => 2, 'email' => '[email protected]', $this->usernameField() => $this->anotherUsername(), 'recovery_codes' => ['H4PFK-ENVZV', 'PIPIM-7LTUT', 'GPP13-AEXMR', 'WGAHD-95VNQ', 'BSFYG-VFG2N', 'AWOPQ-NWYJX', '2PVJM-QHPBM', 'STR7J-5ND0P']]);
$token = Password::getRepository()->create($userA);

$response = $this->get(route('recover-account.challenge', [
Expand All @@ -70,7 +101,7 @@ public function the_account_recovery_challenge_page_cannot_be_viewed_when_the_re
/** @test */
public function the_account_recovery_challenge_page_cannot_be_viewed_when_the_recovery_token_does_not_exist(): void
{
$user = $this->generateUser();
$user = $this->generateUser(['recovery_codes' => ['H4PFK-ENVZV', 'PIPIM-7LTUT', 'GPP13-AEXMR', 'WGAHD-95VNQ', 'BSFYG-VFG2N', 'AWOPQ-NWYJX', '2PVJM-QHPBM', 'STR7J-5ND0P']]);

$response = $this->get(route('recover-account.challenge', [
'token' => 'invalid-token',
Expand All @@ -86,7 +117,7 @@ public function the_account_recovery_challenge_page_cannot_be_viewed_when_the_re
public function the_account_recovery_challenge_page_cannot_be_viewed_when_the_recovery_token_has_expired(): void
{
Carbon::setTestNow('2022-01-01 00:00:00');
$user = $this->generateUser();
$user = $this->generateUser(['recovery_codes' => ['H4PFK-ENVZV', 'PIPIM-7LTUT', 'GPP13-AEXMR', 'WGAHD-95VNQ', 'BSFYG-VFG2N', 'AWOPQ-NWYJX', '2PVJM-QHPBM', 'STR7J-5ND0P']]);
$token = Password::getRepository()->create($user);
Carbon::setTestNow(now()->addHour()->addSecond());

Expand Down

0 comments on commit 72ab21f

Please sign in to comment.