withHeaders(['x-access-token' => $this->buildClientAccessToken()]); } private function customer(bool $twoFactor = false): Customer { return Customer::factory()->create([ 'status' => 1, 'password' => Hash::make(self::PASSWORD), 'two_factor_enabled' => $twoFactor, ]); } private function login(Customer $customer, ?string $deviceToken = null) { return $this->api()->postJson('/v1/client/login', array_filter([ 'email' => $customer->email, 'password' => self::PASSWORD, 'device_token' => $deviceToken, ])); } private function lastCode(): string { $code = null; Mail::assertQueued(TwoFactorCode::class, function (TwoFactorCode $mail) use (&$code) { $code = $mail->data['code']; return true; }); return $code; } public function test_login_without_two_factor_returns_tokens_directly(): void { $this->login($this->customer()) ->assertOk() ->assertJsonStructure(['data' => ['token', 'refresh_token']]) ->assertJsonMissingPath('data.two_factor'); } public function test_customer_enables_two_factor_with_emailed_code(): void { Mail::fake(); $customer = $this->customer(); $this->actingAs($customer, 'client')->api()->postJson('/v1/client/two-factor/setup-code')->assertOk(); $code = $this->lastCode(); $this->actingAs($customer, 'client')->api() ->postJson('/v1/client/two-factor/setup-code') ->assertStatus(422); $this->actingAs($customer, 'client')->api() ->postJson('/v1/client/two-factor/enable', ['code' => $code === '000000' ? '111111' : '000000']) ->assertStatus(422); $this->assertFalse($customer->fresh()->two_factor_enabled); $this->actingAs($customer, 'client')->api() ->postJson('/v1/client/two-factor/enable', ['code' => $code]) ->assertOk(); $this->assertTrue($customer->fresh()->two_factor_enabled); $this->actingAs($customer->fresh(), 'client')->api() ->getJson('/v1/client/me') ->assertJsonPath('data.client.customer.active_2fa', true); } public function test_login_requires_emailed_code_and_can_remember_device(): void { Mail::fake(); $customer = $this->customer(twoFactor: true); $challenge = $this->login($customer) ->assertOk() ->assertJsonMissingPath('data.token') ->json('data.two_factor.challenge'); $this->assertSame(64, strlen($challenge)); $code = $this->lastCode(); $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code === '000000' ? '111111' : '000000']) ->assertStatus(422); $response = $this->api()->postJson('/v1/client/login/two-factor', [ 'challenge' => $challenge, 'code' => $code, 'remember_device' => true, ])->assertOk()->assertJsonStructure(['data' => ['token', 'refresh_token', 'device_token', 'device_expires_in']]); $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code]) ->assertStatus(422); $deviceToken = $response->json('data.device_token'); $device = CustomerTrustedDevice::where('customer_id', $customer->id)->sole(); $this->assertNotSame($deviceToken, $device->token_hash); $this->assertTrue($device->expires_at->between(now()->addDays(89), now()->addDays(91))); $this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['token']]); $this->login($customer, str_repeat('x', 64))->assertOk()->assertJsonPath('data.two_factor.resend_in', 300); $this->travel(91)->days(); $this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['two_factor']]); } public function test_repeated_logins_from_same_client_reuse_code_until_it_expires(): void { Mail::fake(); $customer = $this->customer(twoFactor: true); $first = $this->login($customer)->json('data.two_factor.challenge'); $this->travel(2)->minutes(); $second = $this->login($customer)->assertJsonPath('data.two_factor.resend_in', 180)->json('data.two_factor.challenge'); $this->assertSame($first, $second); Mail::assertQueuedCount(1); $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])->login($customer); Mail::assertQueuedCount(2); $this->travel(301)->seconds(); $third = $this->login($customer)->json('data.two_factor.challenge'); $this->assertNotSame($first, $third); Mail::assertQueuedCount(3); } public function test_challenge_is_destroyed_after_five_wrong_codes(): void { Mail::fake(); $customer = $this->customer(twoFactor: true); $challenge = $this->login($customer)->json('data.two_factor.challenge'); $code = $this->lastCode(); $wrong = $code === '000000' ? '111111' : '000000'; for ($i = 0; $i < 5; $i++) { $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $wrong])->assertStatus(422); } $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code])->assertStatus(429); $this->travel(61)->seconds(); $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code]) ->assertStatus(422) ->assertJsonPath('errors.code.0', 'Phiên xác thực đã hết hạn, vui lòng đăng nhập lại.'); } public function test_disabling_requires_password_and_revokes_devices_password_change_also_revokes(): void { $customer = $this->customer(twoFactor: true); $twoFactor = app(\App\Services\Api\Client\ClientTwoFactorService::class); $twoFactor->rememberDevice($customer, 'UA', '1.1.1.1'); $this->actingAs($customer, 'client')->api()->getJson('/v1/client/two-factor') ->assertOk() ->assertJsonPath('data.enabled', true) ->assertJsonCount(1, 'data.devices') ->assertJsonMissingPath('data.devices.0.token_hash'); $this->actingAs($customer, 'client')->api() ->postJson('/v1/client/two-factor/disable', ['password' => 'wrong-password']) ->assertStatus(422); $this->assertTrue($customer->fresh()->two_factor_enabled); $this->actingAs($customer, 'client')->api() ->postJson('/v1/client/two-factor/disable', ['password' => self::PASSWORD]) ->assertOk(); $this->assertFalse($customer->fresh()->two_factor_enabled); $this->assertSame(0, CustomerTrustedDevice::count()); $twoFactor->rememberDevice($customer, 'UA', '1.1.1.1'); $this->actingAs($customer, 'client')->api()->putJson('/v1/client/change-password', [ 'current_password' => self::PASSWORD, 'password' => 'NewSecret123', 'password_confirmation' => 'NewSecret123', ])->assertOk(); $this->assertSame(0, CustomerTrustedDevice::count()); } }