- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services) - Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết - Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ - Đánh dấu mẫu email dịch vụ là non-marketing Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
301 lines
9.6 KiB
PHP
301 lines
9.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Api\Client;
|
|
|
|
use App\Mail\Customer\TwoFactorCode;
|
|
use App\Models\Customer;
|
|
use App\Models\CustomerTrustedDevice;
|
|
use App\Support\AutoEmailGate;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class ClientTwoFactorService
|
|
{
|
|
public const CODE_TTL_MINUTES = 5;
|
|
|
|
public const RESEND_COOLDOWN_SECONDS = self::CODE_TTL_MINUTES * 60;
|
|
|
|
private const ENTRY_TTL_MINUTES = 15;
|
|
|
|
public const TRUSTED_DEVICE_DAYS = 90;
|
|
|
|
private const MAX_ATTEMPTS = 5;
|
|
|
|
private const PURPOSE_LOGIN = 'đăng nhập tài khoản';
|
|
|
|
private const PURPOSE_SETUP = 'bật xác thực 2 lớp';
|
|
|
|
public function requiresChallenge(Customer $customer, ?string $deviceToken): bool
|
|
{
|
|
return $customer->two_factor_enabled && !$this->touchTrustedDevice($customer, $deviceToken);
|
|
}
|
|
|
|
/**
|
|
* @return array{challenge: string, email: string, expires_in: int, resend_in: int}
|
|
*/
|
|
public function startLoginChallenge(Customer $customer, ?string $ip, ?string $userAgent): array
|
|
{
|
|
$pointerKey = $this->clientPointerKey($customer, $ip, $userAgent);
|
|
|
|
return Cache::lock($pointerKey . ':lock', 10)->block(5, function () use ($customer, $pointerKey) {
|
|
$challenge = $this->activeChallenge($pointerKey);
|
|
|
|
if (!$challenge) {
|
|
$challenge = Str::random(64);
|
|
$this->issueCode($this->challengeKey($challenge), $customer, self::PURPOSE_LOGIN);
|
|
Cache::put($pointerKey, Crypt::encryptString($challenge), now()->addMinutes(self::ENTRY_TTL_MINUTES));
|
|
}
|
|
|
|
$remaining = max(Cache::get($this->challengeKey($challenge))['expires_at'] - now()->getTimestamp(), 0);
|
|
|
|
return [
|
|
'challenge' => $challenge,
|
|
'email' => $this->maskEmail($customer->email),
|
|
'expires_in' => $remaining,
|
|
'resend_in' => $remaining,
|
|
];
|
|
});
|
|
}
|
|
|
|
private function activeChallenge(string $pointerKey): ?string
|
|
{
|
|
$stored = Cache::get($pointerKey);
|
|
$challenge = $stored ? Crypt::decryptString($stored) : null;
|
|
$entry = $challenge ? Cache::get($this->challengeKey($challenge)) : null;
|
|
|
|
if ($entry && $entry['expires_at'] > now()->getTimestamp()) {
|
|
return $challenge;
|
|
}
|
|
|
|
if ($challenge) {
|
|
Cache::forget($this->challengeKey($challenge));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function clientPointerKey(Customer $customer, ?string $ip, ?string $userAgent): string
|
|
{
|
|
return 'client-2fa:client:' . hash('sha256', $customer->id . '|' . $ip . '|' . $userAgent);
|
|
}
|
|
|
|
public function resendLoginChallenge(string $challenge): void
|
|
{
|
|
$key = $this->challengeKey($challenge);
|
|
$entry = Cache::get($key);
|
|
$customer = $entry ? Customer::find($entry['customer_id']) : null;
|
|
|
|
if (!$customer) {
|
|
throw $this->invalidChallenge();
|
|
}
|
|
|
|
$this->ensureCooldownPassed($entry);
|
|
$this->issueCode($key, $customer, self::PURPOSE_LOGIN);
|
|
}
|
|
|
|
public function verifyLoginChallenge(string $challenge, string $code): Customer
|
|
{
|
|
$key = $this->challengeKey($challenge);
|
|
$entry = Cache::get($key);
|
|
|
|
if (!$entry) {
|
|
throw $this->invalidChallenge();
|
|
}
|
|
|
|
$this->checkCode($key, $entry, $code);
|
|
|
|
$customer = Customer::find($entry['customer_id']);
|
|
if (!$customer || (int) $customer->status !== 1) {
|
|
throw $this->invalidChallenge();
|
|
}
|
|
|
|
return $customer;
|
|
}
|
|
|
|
public function sendSetupCode(Customer $customer): void
|
|
{
|
|
if ($customer->two_factor_enabled) {
|
|
throw ValidationException::withMessages(['code' => ['Xác thực 2 lớp đang được bật.']]);
|
|
}
|
|
|
|
$key = $this->setupKey($customer);
|
|
if ($entry = Cache::get($key)) {
|
|
$this->ensureCooldownPassed($entry);
|
|
}
|
|
|
|
$this->issueCode($key, $customer, self::PURPOSE_SETUP);
|
|
}
|
|
|
|
public function enable(Customer $customer, string $code): void
|
|
{
|
|
$key = $this->setupKey($customer);
|
|
$entry = Cache::get($key);
|
|
|
|
if (!$entry) {
|
|
throw ValidationException::withMessages(['code' => ['Mã xác thực đã hết hạn, vui lòng gửi lại mã.']]);
|
|
}
|
|
|
|
$this->checkCode($key, $entry, $code);
|
|
|
|
$customer->two_factor_enabled = true;
|
|
$customer->save();
|
|
}
|
|
|
|
public function disable(Customer $customer, string $password): void
|
|
{
|
|
if (!Hash::check($password, $customer->password)) {
|
|
throw ValidationException::withMessages(['password' => ['Mật khẩu không chính xác.']]);
|
|
}
|
|
|
|
$customer->two_factor_enabled = false;
|
|
$customer->save();
|
|
$this->revokeDevices($customer);
|
|
}
|
|
|
|
public function rememberDevice(Customer $customer, ?string $userAgent, ?string $ip): string
|
|
{
|
|
$token = Str::random(64);
|
|
|
|
CustomerTrustedDevice::create([
|
|
'customer_id' => $customer->id,
|
|
'token_hash' => hash('sha256', $token),
|
|
'user_agent' => $userAgent ? Str::limit($userAgent, 250, '') : null,
|
|
'ip_address' => $ip,
|
|
'last_used_at' => now(),
|
|
'expires_at' => now()->addDays(self::TRUSTED_DEVICE_DAYS),
|
|
]);
|
|
|
|
return $token;
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, CustomerTrustedDevice>
|
|
*/
|
|
public function devices(Customer $customer): Collection
|
|
{
|
|
return CustomerTrustedDevice::where('customer_id', $customer->id)->active()->latest('last_used_at')->get();
|
|
}
|
|
|
|
public function revokeDevices(Customer $customer, ?int $deviceId = null): void
|
|
{
|
|
CustomerTrustedDevice::where('customer_id', $customer->id)
|
|
->when($deviceId, fn ($query) => $query->whereKey($deviceId))
|
|
->delete();
|
|
}
|
|
|
|
private function touchTrustedDevice(Customer $customer, ?string $deviceToken): bool
|
|
{
|
|
if (!$deviceToken) {
|
|
return false;
|
|
}
|
|
|
|
$device = CustomerTrustedDevice::where('customer_id', $customer->id)
|
|
->where('token_hash', hash('sha256', $deviceToken))
|
|
->active()
|
|
->first();
|
|
|
|
$device?->update(['last_used_at' => now()]);
|
|
|
|
return $device !== null;
|
|
}
|
|
|
|
private function issueCode(string $key, Customer $customer, string $purpose): void
|
|
{
|
|
if (!AutoEmailGate::mailEnabled()) {
|
|
throw ValidationException::withMessages([
|
|
'code' => ['Hệ thống tạm thời không gửi được email xác thực, vui lòng liên hệ hỗ trợ.'],
|
|
]);
|
|
}
|
|
|
|
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
|
|
|
Cache::put($key, [
|
|
'customer_id' => $customer->id,
|
|
'code_hash' => hash('sha256', $key . $code),
|
|
'attempts' => 0,
|
|
'sent_at' => now()->getTimestamp(),
|
|
'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES)->getTimestamp(),
|
|
], now()->addMinutes(self::ENTRY_TTL_MINUTES));
|
|
|
|
AutoEmailGate::markManual();
|
|
Mail::to($customer->email)->queue(new TwoFactorCode([
|
|
'fullname' => $customer->fullname,
|
|
'code' => $code,
|
|
'purpose' => $purpose,
|
|
'expire_minutes' => self::CODE_TTL_MINUTES,
|
|
]));
|
|
}
|
|
|
|
private function checkCode(string $key, array $entry, string $code): void
|
|
{
|
|
if ($entry['expires_at'] <= now()->getTimestamp()) {
|
|
throw ValidationException::withMessages([
|
|
'code' => ['Mã xác thực đã hết hạn, vui lòng gửi lại mã.'],
|
|
]);
|
|
}
|
|
|
|
if (hash_equals($entry['code_hash'], hash('sha256', $key . $code))) {
|
|
Cache::forget($key);
|
|
|
|
return;
|
|
}
|
|
|
|
$entry['attempts']++;
|
|
if ($entry['attempts'] >= self::MAX_ATTEMPTS) {
|
|
Cache::forget($key);
|
|
|
|
throw ValidationException::withMessages([
|
|
'code' => ['Nhập sai quá nhiều lần, vui lòng gửi lại mã xác thực.'],
|
|
]);
|
|
}
|
|
|
|
Cache::put($key, $entry, now()->addMinutes(self::ENTRY_TTL_MINUTES));
|
|
|
|
throw ValidationException::withMessages([
|
|
'code' => ['Mã xác thực không chính xác.'],
|
|
]);
|
|
}
|
|
|
|
private function ensureCooldownPassed(array $entry): void
|
|
{
|
|
$wait = $entry['sent_at'] + self::RESEND_COOLDOWN_SECONDS - now()->getTimestamp();
|
|
|
|
if ($wait > 0) {
|
|
throw ValidationException::withMessages([
|
|
'code' => ["Vui lòng đợi {$wait} giây trước khi gửi lại mã."],
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function invalidChallenge(): ValidationException
|
|
{
|
|
return ValidationException::withMessages([
|
|
'code' => ['Phiên xác thực đã hết hạn, vui lòng đăng nhập lại.'],
|
|
]);
|
|
}
|
|
|
|
private function maskEmail(string $email): string
|
|
{
|
|
[$local, $domain] = explode('@', $email, 2) + [1 => ''];
|
|
|
|
return Str::mask($local, '*', min(2, max(strlen($local) - 1, 0))) . '@' . $domain;
|
|
}
|
|
|
|
private function challengeKey(string $challenge): string
|
|
{
|
|
return 'client-2fa:login:' . hash('sha256', $challenge);
|
|
}
|
|
|
|
private function setupKey(Customer $customer): string
|
|
{
|
|
return 'client-2fa:setup:' . $customer->id;
|
|
}
|
|
}
|