self::CREDENTIAL_ID_ALT, 'rawId' => self::CREDENTIAL_ID_ALT_RAW, 'type' => 'public-key', 'response' => [ 'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9', 'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==', 'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==', 'userHandle' => self::USER_ID_ALT, ], 'email' => self::EMAIL, ]; const ASSERTION_RESPONSE_NO_HANDLE = [ 'id' => self::CREDENTIAL_ID_ALT, 'rawId' => self::CREDENTIAL_ID_ALT_RAW, 'type' => 'public-key', 'response' => [ 'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9', 'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==', 'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==', 'userHandle' => null, ], 'email' => self::EMAIL, ]; const ASSERTION_RESPONSE_INVALID = [ 'id' => self::CREDENTIAL_ID_ALT, 'rawId' => self::CREDENTIAL_ID_ALT_RAW, 'type' => 'public-key', 'response' => [ 'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9', 'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==', 'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==', 'userHandle' => self::USER_ID_ALT, ], 'email' => self::EMAIL, ]; const ASSERTION_CHALLENGE = 'iXozmynKi+YD2iRvKNbSPA=='; public function setUp() : void { parent::setUp(); DB::table('users')->delete(); $this->user = User::factory()->create(['email' => self::EMAIL]); $this->mock(AssertionValidator::class) ->shouldReceive('send->thenReturn') ->andReturn(); } #[Test] public function test_webauthn_login_returns_success() { $this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT); $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk() ->assertJsonFragment([ 'message' => 'authenticated', 'id' => $this->user->id, 'name' => $this->user->name, 'email' => $this->user->email, 'is_admin' => false, ]) ->assertJsonStructure([ 'preferences', ]); } #[Test] public function test_webauthn_login_of_admin_returns_success_even_with_sso_only_enabled() { Settings::set('useSsoOnly', true); $this->user->promoteToAdministrator(true); $this->user->save(); $this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT); $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk() ->assertJsonFragment([ 'message' => 'authenticated', 'id' => $this->user->id, 'name' => $this->user->name, 'email' => $this->user->email, 'is_admin' => true, ]) ->assertJsonStructure([ 'preferences', ]); $this->user->promoteToAdministrator(false); $this->user->save(); } #[Test] public function test_webauthn_login_sends_new_device_notification_to_existing_user() { Notification::fake(); $this->user['preferences->notifyOnNewAuthDevice'] = 1; $this->user->save(); $this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT); $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk(); $this->actingAs($this->user, self::GUARD) ->json('GET', '/user/logout'); $this->travel(1)->minute(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE, [ 'HTTP_USER_AGENT' => 'another_useragent_to_be_identified_as_new_device', ])->assertOk(); Notification::assertSentTo($this->user, SignedInWithNewDeviceNotification::class); } #[Test] public function test_webauthn_login_does_not_send_new_device_notification_to_new_user() { Notification::fake(); $this->user['preferences->notifyOnNewAuthDevice'] = 1; $this->user->save(); $this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT); $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk(); Notification::assertNothingSentTo($this->user); } #[Test] public function test_webauthn_login_does_not_send_new_device_notification_if_user_disabled_it() { Notification::fake(); $this->user['preferences->notifyOnNewAuthDevice'] = 0; $this->user->save(); $this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT); $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk(); Notification::assertNothingSentTo($this->user); } #[Test] public function test_webauthn_admin_login_returns_admin_role() { DB::table('users')->delete(); $this->user = User::factory()->administrator()->create(['email' => self::EMAIL]); $this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT); $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk() ->assertJsonFragment([ 'is_admin' => true, ]); } #[Test] public function test_webauthn_login_merge_handle_if_missing() { $this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT); $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_NO_HANDLE) ->assertOk() ->assertJsonFragment([ 'message' => 'authenticated', 'name' => $this->user->name, ]) ->assertJsonStructure([ 'message', 'name', 'preferences', ]); } #[Test] public function test_legacy_login_is_rejected_when_webauthn_only_is_enable() { // Set to webauthn only $this->user['preferences->useWebauthnOnly'] = true; $this->user->save(); $response = $this->json('POST', '/user/login', [ 'email' => self::EMAIL, 'password' => 'password', ]) ->assertUnauthorized(); } #[Test] public function test_webauthn_login_already_authenticated_is_rejected() { $this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT); $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertStatus(400) ->assertJsonStructure([ 'message', ]); } #[Test] public function test_webauthn_login_with_missing_data_returns_validation_error() { $data = [ 'id' => '', 'rawId' => '', 'type' => '', 'response' => [ 'authenticatorData' => '', 'clientDataJSON' => '', 'signature' => '', 'userHandle' => null, ], ]; $response = $this->json('POST', '/webauthn/login', $data) ->assertStatus(422) ->assertJsonValidationErrors([ 'id', 'rawId', 'type', 'response.authenticatorData', 'response.clientDataJSON', 'response.signature', ]); } #[Test] public function test_webauthn_invalid_login_returns_unauthorized() { $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID) ->assertUnauthorized(); } #[Test] public function test_too_many_invalid_login_attempts_returns_too_many_request_error() { $throttle = 8; Config::set('auth.throttle.login', $throttle); $this->addWebauthnChallengeToSession(); for ($i = 0; $i < $throttle - 1; $i++) { $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID); } $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID) ->assertUnauthorized(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID) ->assertStatus(429); } #[Test] public function test_get_options_returns_success() { Config::set('webauthn.user_verification', UserVerification::PREFERRED); $this->createWebauthnCredential(self::CREDENTIAL_ID, $this->user->id, self::USER_ID); $response = $this->json('POST', '/webauthn/login/options', [ 'email' => $this->user->email, ]) ->assertOk() ->assertJsonStructure([ 'challenge', 'timeout', ]) ->assertJsonFragment([ 'allowCredentials' => [[ 'id' => self::CREDENTIAL_ID, 'type' => 'public-key', ]], ]); } #[Test] public function test_get_options_for_securelogin_returns_required_userVerification() { Config::set('webauthn.user_verification', UserVerification::REQUIRED); $this->createWebauthnCredential(self::CREDENTIAL_ID, $this->user->id, self::USER_ID); $response = $this->json('POST', '/webauthn/login/options', [ 'email' => $this->user->email, ]) ->assertOk() ->assertJsonStructure([ 'challenge', 'userVerification', 'timeout', ]) ->assertJsonFragment([ 'userVerification' => 'required', 'allowCredentials' => [[ 'id' => self::CREDENTIAL_ID, 'type' => 'public-key', ]], ]); } #[Test] public function test_get_options_for_fastlogin_returns_discouraged_userVerification() { Config::set('webauthn.user_verification', UserVerification::DISCOURAGED); $this->createWebauthnCredential(self::CREDENTIAL_ID, $this->user->id, self::USER_ID); $response = $this->json('POST', '/webauthn/login/options', [ 'email' => $this->user->email, ]) ->assertOk() ->assertJsonStructure([ 'challenge', 'userVerification', 'timeout', ]) ->assertJsonFragment([ 'userVerification' => 'discouraged', 'allowCredentials' => [[ 'id' => self::CREDENTIAL_ID, 'type' => 'public-key', ]], ]); } #[Test] public function test_get_options_with_capitalized_email_returns_success() { $this->json('POST', '/webauthn/login/options', [ 'email' => strtoupper($this->user->email), ]) ->assertOk(); } #[Test] public function test_get_options_with_missing_email_returns_validation_errors() { $this->json('POST', '/webauthn/login/options', [ 'email' => null, ]) ->assertStatus(422) ->assertJsonValidationErrors([ 'email', ]); } #[Test] public function test_get_options_with_invalid_email_returns_validation_errors() { $this->json('POST', '/webauthn/login/options', [ 'email' => 'invalid', ]) ->assertStatus(422) ->assertJsonValidationErrors([ 'email', ]); } #[Test] public function test_get_options_with_unknown_email_returns_validation_errors() { $this->json('POST', '/webauthn/login/options', [ 'email' => 'john@example.com', ]) ->assertStatus(422) ->assertJsonValidationErrors([ 'email', ]); } #[Test] public function test_successful_webauthn_login_is_logged() { $this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT); $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk(); $this->assertDatabaseHas('auth_logs', [ 'authenticatable_id' => $this->user->id, 'login_successful' => true, 'guard' => self::GUARD, 'login_method' => self::AUTH_METHOD, 'logout_at' => null, ]); } #[Test] public function test_failed_webauthn_login_is_not_logged() { $this->addWebauthnChallengeToSession(); $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID) ->assertUnauthorized(); // When webauthn fails, the fireFailedEvent() of the sessionGuard returns // a null $user so nothing should be logged $this->assertDatabaseMissing('auth_logs', [ 'authenticatable_id' => $this->user->id, 'guard' => self::GUARD, 'login_method' => self::AUTH_METHOD, ]); } /** * Set a session */ protected function addWebauthnChallengeToSession() : void { $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge( new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)), 60, false, )]); } /** * Inserts a webauthn credential in database */ protected function createWebauthnCredential(string $credentialId, int $authenticatableId, string $userId) : void { DB::table('webauthn_credentials')->insert([ 'id' => $credentialId, 'authenticatable_type' => \App\Models\User::class, 'authenticatable_id' => $authenticatableId, 'user_id' => $userId, 'counter' => 0, 'rp_id' => 'http://localhost', 'origin' => 'http://localhost', 'aaguid' => '00000000-0000-0000-0000-000000000000', 'attestation_format' => 'none', 'public_key' => self::PUBLIC_KEY, 'updated_at' => now(), 'created_at' => now(), ]); } }