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:
co-authored by
Claude Sonnet 5
parent
e888a6ed5d
commit
12d5b4951c
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user