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 */ 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; } }