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
+37 -32
View File
@@ -8,14 +8,16 @@ use App\Mail\Customer\PasswordResetLink;
use App\Mail\Customer\VerifyAccount as VerifyAccountMail;
use App\Mail\Customer\Welcome as WelcomeMail;
use App\Models\Customer;
use App\Models\CustomerTrustedDevice;
use App\Models\RefreshToken;
use App\Models\Setting;
use App\Repositories\Api\Client\ClientRepositoryInterface;
use App\Support\PasswordResetToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -39,15 +41,17 @@ class ClientAuthService
/**
* Xác thực khách hàng và tạo access + refresh token
*/
public function authenticateClient(array $credentials): array
public function verifyCredentials(array $credentials): Customer
{
if (!$token = Auth::guard('client')->attempt($credentials)) {
$guard = Auth::guard('client');
if (!$guard->validate($credentials)) {
throw ValidationException::withMessages([
'email' => ['Thông tin đăng nhập không chính xác.'],
]);
}
$client = Auth::guard('client')->user();
$client = $guard->getLastAttempted();
if ((int)$client->status !== 1) {
$statusLabel = match ((int)$client->status) {
@@ -68,12 +72,18 @@ class ClientAuthService
]);
}
$refreshToken = $this->createRefreshToken($client->id);
return $client;
}
/**
* @return array{client: Customer, token: string, refresh_token: string}
*/
public function issueTokens(Customer $client): array
{
return [
'client' => $client,
'token' => $token,
'refresh_token' => $refreshToken,
'token' => JWTAuth::fromUser($client),
'refresh_token' => $this->createRefreshToken($client->id),
];
}
@@ -357,21 +367,14 @@ class ClientAuthService
return null;
}
// Broker tự throttle theo email (auth.passwords.users.throttle).
Password::broker('users')->sendResetLink(
['email' => $customer->email],
function (Customer $customer, string $token) {
$url = rtrim(config('app.frontend_url'), '/') . '/dat-lai-mat-khau?' . http_build_query([
'token' => $token,
'email' => $customer->email,
]);
$token = PasswordResetToken::issue($customer);
Mail::to($customer->email)->queue(new PasswordResetLink([
'fullname' => $customer->fullname,
'reset_url' => $url,
]));
},
);
if ($token !== null) {
Mail::to($customer->email)->queue(new PasswordResetLink([
'fullname' => $customer->fullname,
'reset_url' => rtrim(config('app.frontend_url'), '/') . '/dat-lai-mat-khau?t=' . $token,
]));
}
return $customer;
}
@@ -380,24 +383,25 @@ class ClientAuthService
* Đặt lại mật khẩu bằng token trong email. Thành công thì thu hồi toàn bộ refresh token
* để các phiên cũ (có thể của kẻ gian) phải đăng nhập lại.
*/
public function resetPassword(array $data): ?Customer
public function resetPassword(string $token, string $password): Customer
{
$resetCustomer = null;
$customer = PasswordResetToken::findCustomer($token);
$status = Password::broker('users')->reset($data, function (Customer $customer, string $password) use (&$resetCustomer) {
$customer->password = Hash::make($password);
$customer->save();
RefreshToken::where('customer_id', $customer->id)->delete();
$resetCustomer = $customer;
});
if ($status !== Password::PASSWORD_RESET) {
if (!$customer) {
throw ValidationException::withMessages([
'token' => ['Link đặt lại mật khẩu không hợp lệ hoặc đã hết hạn.'],
]);
}
return $resetCustomer;
DB::transaction(function () use ($customer, $password) {
$customer->password = Hash::make($password);
$customer->save();
PasswordResetToken::revokeFor($customer);
RefreshToken::where('customer_id', $customer->id)->delete();
CustomerTrustedDevice::where('customer_id', $customer->id)->delete();
});
return $customer;
}
/**
@@ -446,5 +450,6 @@ class ClientAuthService
// Access token cũ tự vô hiệu nhờ claim `pv`; thu hồi luôn refresh token để mọi phiên phải đăng nhập lại.
RefreshToken::where('customer_id', $client->id)->delete();
CustomerTrustedDevice::where('customer_id', $client->id)->delete();
}
}