Files
portal-api/tests/Feature/Api/Client/ClientAuthSecurityTest.php
T
2026-09-27 11:42:40 +07:00

223 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Client;
use App\Mail\Customer\PasswordResetLink;
use App\Models\Customer;
use App\Models\RefreshToken;
use App\Services\Api\Client\ClientAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ClientAuthSecurityTest extends TestCase
{
use RefreshDatabase;
private function api(): static
{
return $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
}
public function test_access_token_older_than_skew_window_is_rejected(): void
{
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken(time() - 3600)])
->getJson('/v1/banks')
->assertStatus(403);
}
public function test_verify_email_code_is_invalidated_after_too_many_wrong_attempts(): void
{
$customer = Customer::factory()->create([
'status' => 0,
'email_verify_code' => '123456',
'email_verify_code_expired_at' => now()->addMinutes(5),
]);
for ($i = 0; $i < 5; $i++) {
$this->api()->postJson('/v1/client/verify-email', ['email' => $customer->email, 'code' => '000000'])
->assertStatus(422);
}
// Lần sai thứ 5 huỷ mã: mã đúng cũng vô dụng, phải gửi lại.
$this->assertNull($customer->fresh()->email_verify_code);
$this->assertSame(0, (int) $customer->fresh()->status);
// throttle:client-auth khoá theo email: lần thứ 6 trong phút bị chặn, email khác vẫn qua.
$this->api()->postJson('/v1/client/verify-email', ['email' => $customer->email, 'code' => '123456'])
->assertStatus(429);
$this->api()->postJson('/v1/client/verify-email', ['email' => 'other@example.com', 'code' => '123456'])
->assertStatus(422);
}
public function test_forgot_password_sends_link_without_changing_password_and_reset_revokes_sessions(): void
{
Mail::fake();
$customer = Customer::factory()->create(['status' => 1]);
$oldHash = $customer->password;
RefreshToken::create(['customer_id' => $customer->id, 'token' => 'x', 'expires_at' => now()->addDay()]);
$this->api()->postJson('/v1/client/forgot-password', ['email' => strtoupper($customer->email)])->assertOk();
$this->assertSame($oldHash, $customer->fresh()->password);
Mail::assertQueued(PasswordResetLink::class, function (PasswordResetLink $mail) use ($customer) {
// Template seed bằng migration phải render được và chứa link reset, không chứa mật khẩu.
$html = $mail->render();
return $mail->hasTo($customer->email)
&& str_contains($html, '/dat-lai-mat-khau?token=')
&& str_contains($html, '60 phút');
});
$token = Password::broker('users')->createToken($customer);
$this->api()->postJson('/v1/client/reset-password', [
'email' => $customer->email, 'token' => 'wrong', 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123',
])->assertStatus(422);
$this->api()->postJson('/v1/client/reset-password', [
'email' => $customer->email, 'token' => $token, 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123',
])->assertOk();
$this->assertTrue(Hash::check('NewPass123', $customer->fresh()->password));
$this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count());
}
public function test_cccd_images_are_private_and_served_only_via_signed_url(): void
{
Storage::fake('local');
Storage::disk('local')->put('cccd/front.jpg', 'img');
$customer = Customer::factory()->create([
'status' => 1,
'identity_number' => '079123456789',
'front_identity_number' => 'cccd/front.jpg',
]);
$me = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')->assertOk();
$data = $me->json('data.client.customer');
$this->assertSame('********6789', $data['cmnd']);
$this->assertStringStartsWith('/v1/client/cccd/' . $customer->id . '/front?expires=', $data['cmnd_before']);
$this->api()->get('/v1/client/cccd/' . $customer->id . '/front')->assertStatus(403);
$this->api()->get($data['cmnd_before'])->assertOk();
}
public function test_access_token_is_revoked_when_password_changes(): void
{
$customer = Customer::factory()->create(['status' => 1, 'password' => Hash::make('OldPass123')]);
$jwt = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer);
$call = fn () => $this->api()->withHeader('Authorization', "Bearer {$jwt}")->getJson('/v1/client/me');
$call()->assertOk();
$this->api()->withHeader('Authorization', "Bearer {$jwt}")->putJson('/v1/client/change-password', [
'current_password' => 'OldPass123', 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123',
])->assertOk();
$this->app['auth']->forgetGuards();
$call()->assertStatus(401);
}
public function test_throttle_uses_client_ip_from_encrypted_payload_not_next_server_ip(): void
{
// Cùng REMOTE_ADDR (Next server) nhưng IP khách khác nhau → không bị đếm chung.
foreach (['203.0.113.1', '203.0.113.2'] as $ip) {
for ($i = 0; $i < 60; $i++) {
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken(null, $ip)])
->getJson('/v1/banks')->assertOk();
}
}
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken(null, '203.0.113.1')])
->getJson('/v1/banks')->assertStatus(429);
}
public function test_logout_revokes_refresh_token_so_it_cannot_be_used_again(): void
{
$customer = Customer::factory()->create(['status' => 1]);
$jwt = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer);
$rawRefreshToken = 'raw-refresh-token';
RefreshToken::create([
'customer_id' => $customer->id,
'token' => hash('sha256', $rawRefreshToken),
'expires_at' => now()->addDays(30),
]);
$this->api()->withHeader('Authorization', "Bearer {$jwt}")
->postJson('/v1/client/logout', ['refresh_token' => $rawRefreshToken])
->assertOk();
$this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count());
$this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])
->assertStatus(401);
}
public function test_reused_rotated_refresh_token_revokes_all_tokens_of_customer(): void
{
$customer = Customer::factory()->create(['status' => 1]);
$rawRefreshToken = 'raw-refresh-token';
RefreshToken::create([
'customer_id' => $customer->id,
'token' => hash('sha256', $rawRefreshToken),
'expires_at' => now()->addDays(30),
]);
// Rotation hợp lệ lần đầu: đánh dấu token cũ đã dùng, cấp token mới.
$first = $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])->assertOk();
$newRawRefreshToken = $first->json('data.refresh_token');
$this->assertNotSame($rawRefreshToken, $newRawRefreshToken);
// Token cũ vẫn còn nhưng đã đánh dấu revoked_at, cộng token mới vừa cấp.
$this->assertSame(2, RefreshToken::where('customer_id', $customer->id)->count());
$this->travel(ClientAuthService::REFRESH_TOKEN_REUSE_GRACE_SECONDS + 1)->seconds();
// Dùng lại token cũ (đã rotate): coi như bị lộ, thu hồi toàn bộ token của khách.
$this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])
->assertStatus(401);
$this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count());
// Token mới (hợp lệ, chưa dùng) cũng đã bị thu hồi theo.
$this->api()->postJson('/v1/client/refresh', ['refresh_token' => $newRawRefreshToken])
->assertStatus(401);
}
public function test_concurrent_refresh_within_grace_window_does_not_revoke_sessions(): void
{
$customer = Customer::factory()->create(['status' => 1]);
$rawRefreshToken = 'raw-refresh-token';
RefreshToken::create([
'customer_id' => $customer->id,
'token' => hash('sha256', $rawRefreshToken),
'expires_at' => now()->addDays(30),
]);
$first = $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])->assertOk();
$second = $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])->assertOk();
foreach ([$first, $second] as $response) {
$this->api()->postJson('/v1/client/refresh', ['refresh_token' => $response->json('data.refresh_token')])
->assertOk();
}
}
public function test_legacy_cccd_path_is_not_exposed(): void
{
$customer = Customer::factory()->create(['status' => 1, 'front_identity_number' => '/uploads/2023/11/06/abc.jpg']);
$url = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')
->json('data.client.customer.cmnd_before');
$this->assertStringNotContainsString('uploads', $url);
$this->api()->get($url)->assertNotFound();
}
}