feat: popup quảng cáo, đặt lại mật khẩu bằng token 80 ký tự, 2FA email, vá lỗ hổng bảo mật

- Popup/banner quảng cáo cấu hình từ admin (loại, link, lịch hiển thị)
- Đặt lại mật khẩu: token 80 ký tự kèm check digit Luhn thay cho token+email lộ trong URL, chỉ lưu bản băm, một lần dùng, hết hạn 60 phút
- Xác thực 2 lớp qua email khi đăng nhập (mã 6 số, ghi nhớ thiết bị 90 ngày)
- ApiAuthCheck chặn ngay tài khoản bị khoá dù JWT còn hạn (trước đây dùng được tới hết TTL)
- Handler trả 404 thay vì 500 khi ModelNotFoundException không bọc HttpException
- Test ClientTamperingTest: xác nhận IDOR/giả mạo dữ liệu từ client đều bị chặn

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-13 13:23:33 +07:00
co-authored by Claude Sonnet 5
parent e888a6ed5d
commit 12d5b4951c
33 changed files with 1499 additions and 51 deletions
@@ -8,10 +8,10 @@ use App\Mail\Customer\PasswordResetLink;
use App\Models\Customer;
use App\Models\RefreshToken;
use App\Services\Api\Client\ClientAuthService;
use App\Support\PasswordResetToken;
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;
@@ -66,27 +66,80 @@ class ClientAuthSecurityTest extends TestCase
$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.
$token = null;
Mail::assertQueued(PasswordResetLink::class, function (PasswordResetLink $mail) use ($customer, &$token) {
$html = $mail->render();
preg_match('#/dat-lai-mat-khau\?t=([0-9a-z]+)#', $html, $match);
$token = $match[1] ?? null;
return $mail->hasTo($customer->email)
&& str_contains($html, '/dat-lai-mat-khau?token=')
&& !str_contains($html, 'email=')
&& str_contains($html, '60 phút');
});
$token = Password::broker('users')->createToken($customer);
$this->assertNotNull($token);
$this->assertSame(PasswordResetToken::LENGTH, strlen($token));
$this->assertTrue(PasswordResetToken::isWellFormed($token));
$this->api()->getJson('/v1/client/reset-password/' . $token)
->assertOk()
->assertJsonPath('data.email', $customer->email);
$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',
'token' => $token, 'password' => 'NewPass1234', 'password_confirmation' => 'NewPass1234',
])->assertOk();
$this->assertTrue(Hash::check('NewPass123', $customer->fresh()->password));
$this->assertTrue(Hash::check('NewPass1234', $customer->fresh()->password));
$this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count());
$this->api()->getJson('/v1/client/reset-password/' . $token)->assertNotFound();
$this->api()->postJson('/v1/client/reset-password', [
'token' => $token, 'password' => 'Other12345', 'password_confirmation' => 'Other12345',
])->assertStatus(422);
}
public function test_reset_token_rejects_tampered_unknown_expired_and_superseded_tokens(): void
{
$customer = Customer::factory()->create(['status' => 1]);
$token = PasswordResetToken::issue($customer);
$lastChar = $token[PasswordResetToken::LENGTH - 1];
$tampered = substr($token, 0, -1) . ($lastChar === 'a' ? 'b' : 'a');
$this->assertFalse(PasswordResetToken::isWellFormed($tampered));
$this->assertFalse(PasswordResetToken::isWellFormed(substr($token, 0, -1)));
$this->assertFalse(PasswordResetToken::isWellFormed(strtoupper($token)));
$this->api()->getJson('/v1/client/reset-password/' . $tampered)->assertNotFound();
$this->assertNull(PasswordResetToken::issue($customer), 'throttled within 60s');
$this->travel(61)->seconds();
$newer = PasswordResetToken::issue($customer);
$this->assertNotNull($newer);
$this->assertNull(PasswordResetToken::findCustomer($token), 'old link revoked when a new one is issued');
$this->assertSame($customer->id, PasswordResetToken::findCustomer($newer)?->id);
$other = Customer::factory()->create(['status' => 1]);
$otherToken = PasswordResetToken::issue($other);
$this->assertSame($other->id, PasswordResetToken::findCustomer($otherToken)?->id);
$this->travel(61)->minutes();
$this->assertNull(PasswordResetToken::findCustomer($newer), 'expired after 60 minutes');
$this->api()->postJson('/v1/client/reset-password', [
'token' => $newer, 'password' => 'NewPass1234', 'password_confirmation' => 'NewPass1234',
])->assertStatus(422);
}
public function test_locked_account_loses_api_access_even_with_unexpired_jwt(): void
{
$customer = Customer::factory()->create(['status' => 1]);
$token = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer);
$this->api()->withHeaders(['Authorization' => "Bearer {$token}"])->getJson('/v1/client/me')->assertOk();
$customer->update(['status' => 3]);
$this->app['auth']->forgetGuards();
$this->api()->withHeaders(['Authorization' => "Bearer {$token}"])->getJson('/v1/client/me')->assertUnauthorized();
}
public function test_cccd_images_are_private_and_served_only_via_signed_url(): void