diff --git a/README.md b/README.md index 343cede..7545ed0 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,22 @@ public function createChallenge(AssertedRequest $request) } ``` +You can also use the `login()` method will callbacks, which will be passed to the [`attemptWhen()`](https://laravel.com/docs/11.x/authentication#specifying-additional-conditions) method of the Session Guard. + +```php +// app\Http\Controllers\WebAuthn\WebAuthnLoginController.php +use Laragear\WebAuthn\Http\Requests\AssertedRequest; + +public function createChallenge(AssertedRequest $request) +{ + $user = $request->login(callbacks: fn ($user) => $user->isNotBanned()); + + return $user + ? response("Welcome back, $user->name!"); + : response('Something went wrong, try again!'); +} +``` + If you need greater control on the Assertion procedure, you may want to [Assert manually](#manually-attesting-and-asserting). ### Assertion User Verification @@ -449,7 +465,7 @@ The following events are fired by this package, which you can [listen to in your ## Manually Attesting and Asserting -If you want to manually Attest and Assert users, for example to create users at the same time they register (attest) a device, you may instance their respective pipelines used for both WebAuthn Ceremonies: +If you want to manually Attest and Assert users, for example to create users at the same time they register (attest) a device, you may instance their respective pipelines used for both WebAuthn Ceremonies: | Pipeline | Description | |------------------------|------------------------------------------------------| @@ -462,7 +478,7 @@ If you want to manually Attest and Assert users, for example to create users at > > The `AttestationValidator` instances a storable credential, it doesn't save it. This way you have the chance to alter the model with additional data before persisting. -Compared to prior versions, the validation data to pass through `AttestationValidator` and `AssertionValidator` no longer require the current Request instance. Instead, these only need the JSON array. +Compared to prior versions, the validation data to pass through `AttestationValidator` and `AssertionValidator` no longer require the current Request instance. Instead, these only need the JSON array of data. If you prefer, you can still use the `fromRequest()` helper, which will extract the required WebAuthn data from the current or issued Request instance, or manually instance a `Laragear\WebAuthn\JsonTransport` with the required data. diff --git a/src/Http/Requests/AssertedRequest.php b/src/Http/Requests/AssertedRequest.php index 67c8150..caf2b02 100644 --- a/src/Http/Requests/AssertedRequest.php +++ b/src/Http/Requests/AssertedRequest.php @@ -4,8 +4,11 @@ use Illuminate\Foundation\Http\FormRequest; use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; +use UnexpectedValueException; use function auth; +use function config; +use function method_exists; class AssertedRequest extends FormRequest { @@ -49,12 +52,34 @@ public function hasRemember(): bool public function login( string $guard = null, bool $remember = null, - bool $destroySession = false + bool $destroySession = false, + callable|array $callbacks = null ): ?WebAuthnAuthenticatable { /** @var \Illuminate\Contracts\Auth\StatefulGuard $auth */ $auth = auth()->guard($guard); - if ($auth->attempt($this->validated(), $remember ?? $this->hasRemember())) { + $remember ??= $this->hasRemember(); + + // If the developer is using a callback or an array of callbacks, we will try to use + // the "attemptWhen" method of the Session Guard. Since these callback are expected + // to run, we will fail miserably if the guard does not support attempt callbacks. + if ($callbacks !== null) { + if (! method_exists($auth, 'attemptWhen')) { + $guard ??= config('auth.defaults.guard'); + throw new UnexpectedValueException("The [$guard] guard does not support attempt callbacks."); + } + + if ($auth->attemptWhen($this->validated(), $callbacks, $remember)) { + $this->session()->regenerate($destroySession); + + // @phpstan-ignore-next-line + return $auth->user(); + } + + return null; + } + + if ($auth->attempt($this->validated(), $remember)) { $this->session()->regenerate($destroySession); // @phpstan-ignore-next-line diff --git a/tests/Http/Requests/AssertedRequestTest.php b/tests/Http/Requests/AssertedRequestTest.php index 2207dc8..e4d4704 100644 --- a/tests/Http/Requests/AssertedRequestTest.php +++ b/tests/Http/Requests/AssertedRequestTest.php @@ -3,6 +3,9 @@ namespace Tests\Http\Requests; use Illuminate\Auth\Events\Login; +use Illuminate\Contracts\Auth\Factory as AuthFactory; +use Illuminate\Contracts\Auth\Guard; +use Illuminate\Contracts\Session\Session as SessionContract; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -262,7 +265,7 @@ public function test_destroy_session_on_regeneration(): void $request->login(destroySession: true); }); - $session = Mockery::mock(\Illuminate\Contracts\Session\Session::class); + $session = Mockery::mock(SessionContract::class); $session->expects('regenerate')->with(true)->andReturn(); @@ -276,4 +279,62 @@ public function test_destroy_session_on_regeneration(): void $this->postJson('custom', FakeAuthenticator::assertionResponse())->assertOk(); } + + public function test_logins_with_callbacks(): void + { + Route::middleware('web')->post('custom-false', function (AssertedRequest $request) { + $request->login(callbacks: function ($user): bool { + static::assertInstanceOf(WebAuthnAuthenticatableUser::class, $user); + + return false; + }); + }); + + Route::middleware('web')->post('custom-true', function (AssertedRequest $request) { + $request->login(callbacks: function ($user): bool { + static::assertInstanceOf(WebAuthnAuthenticatableUser::class, $user); + + return true; + }); + }); + + $session = Mockery::mock(SessionContract::class); + + // Expect it only once. The second callback doesn't reach a second execution since it fails. + $session->expects('regenerate')->with(false)->andReturn(); + + $this->app->resolving(AssertedRequest::class, function (AssertedRequest $request) use ($session): void { + $request->setLaravelSession($session); + }); + + $this->mock(AssertionValidator::class) + ->expects('send->thenReturn') + ->twice() + ->andReturn(); + + $this->postJson('custom-false', FakeAuthenticator::assertionResponse())->assertOk(); + + $this->assertGuest(); + + $this->postJson('custom-true', FakeAuthenticator::assertionResponse())->assertOk(); + + $this->assertAuthenticated(); + } + + public function test_login_callback_fails_if_session_guard_does_not_supports_callbacks(): void + { + Route::middleware('web')->post('custom', function (AssertedRequest $request) { + $request->login(callbacks: fn (): bool => true); + }); + + $guard = Mockery::mock(Guard::class); + $guard->expects('attempt')->never(); + $guard->expects('attemptWhen')->never(); + + $this->mock(AuthFactory::class)->expects('guard')->with(null)->andReturn($guard); + + $this->postJson('custom', FakeAuthenticator::assertionResponse()) + ->assertJsonPath('message', 'The [web] guard does not support attempt callbacks.') + ->assertServerError(); + } }