clientRepository = $clientRepository; } /** * Xác thực khách hàng và tạo access + refresh token */ public function verifyCredentials(array $credentials): Customer { $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 = $guard->getLastAttempted(); if ((int)$client->status !== 1) { $statusLabel = match ((int)$client->status) { 0 => 'PENDING_VERIFY', 2 => 'tạm ngưng hoạt động', 3 => 'bị khoá', default => 'vô hiệu hoá', }; if ($statusLabel === 'PENDING_VERIFY') { throw ValidationException::withMessages([ 'email' => ['PENDING_VERIFY'], ]); } throw ValidationException::withMessages([ 'email' => ["Tài khoản của bạn đã {$statusLabel}, vui lòng liên hệ hotline."], ]); } $guard->getProvider()->rehashPasswordIfRequired($client, $credentials); return $client; } /** * @return array{client: Customer, token: string, refresh_token: string} */ public function issueTokens(Customer $client): array { return [ 'client' => $client, 'token' => JWTAuth::fromUser($client), 'refresh_token' => $this->createRefreshToken($client->id), ]; } /** * Đăng ký khách hàng mới — status=0 (chờ xác thực OTP), chưa cấp token. */ public function registerClient(array $data): array { $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); $client = $this->clientRepository->create([ 'fullname' => $data['fullname'], 'phone' => $data['phone'] ?? null, 'email' => $data['email'], 'password' => Hash::make($data['password']), 'status' => 0, 'email_verify_code' => $code, 'email_verify_code_expired_at' => now()->addMinutes(5), ]); $client->forceFill(['two_factor_enabled' => true])->save(); $this->sendVerifyEmail($client, $code); if (!empty($data['trial_config'])) { $this->notifyAdminTrialRegistration($client, $data['trial_config']); } return ['client' => $client]; } /** * Báo admin (email + Telegram) khi khách hàng đăng ký dùng thử Cloud VPS. */ protected function notifyAdminTrialRegistration($client, array $trialConfig): void { try { $setting = Setting::where('key', 'setting')->first(); $settingValue = $setting ? json_decode((string) $setting->value, true) : []; $adminEmail = $settingValue['admin_email'] ?? null; if ($adminEmail) { Mail::to($adminEmail)->send(new TrialRegistrationRequestMail([ 'company_name' => $settingValue['company'] ?? config('app.name', 'Portal Admin'), 'customer_name' => $client->fullname, 'customer_email' => $client->email, 'customer_phone' => $client->phone, 'cpu' => $trialConfig['cpu'] ?? null, 'ram' => $trialConfig['ram'] ?? null, 'ssd' => $trialConfig['ssd'] ?? null, 'os' => $trialConfig['os'] ?? null, 'other' => $trialConfig['other'] ?? null, 'created_at' => now()->format('d/m/Y H:i:s'), ])); } else { Log::warning('Trial registration admin email not sent: admin_email not configured.', [ 'customer_id' => $client->id, ]); } } catch (\Throwable $e) { Log::warning('Send trial registration admin email failed: ' . $e->getMessage(), [ 'customer_id' => $client->id, ]); } (new Telegram())->SendTrialRegistration($client, $trialConfig); } /** * Xác thực OTP — kích hoạt tài khoản, trả về token. */ public function verifyEmail(string $email, string $code): array { $client = \App\Models\Customer::whereRaw('LOWER(email) = ?', [strtolower(trim($email))])->first(); // Chống brute-force mã 6 số: sai quá VERIFY_MAX_ATTEMPTS lần thì huỷ mã, buộc gửi lại. $attemptKey = 'verify-email:' . strtolower(trim($email)); if (! $client || ! $client->email_verify_code || ! hash_equals($client->email_verify_code, $code)) { if ($client?->email_verify_code && RateLimiter::hit($attemptKey, 600) >= self::VERIFY_MAX_ATTEMPTS) { RateLimiter::clear($attemptKey); $client->email_verify_code = null; $client->email_verify_code_expired_at = null; $client->save(); throw ValidationException::withMessages([ 'code' => ['Nhập sai quá nhiều lần, vui lòng yêu cầu gửi lại mã xác thực.'], ]); } throw ValidationException::withMessages([ 'code' => ['Mã xác thực không chính xác.'], ]); } RateLimiter::clear($attemptKey); if (! $client->email_verify_code_expired_at || now()->gt($client->email_verify_code_expired_at)) { throw ValidationException::withMessages([ 'code' => ['Mã xác thực đã hết hạn, vui lòng yêu cầu gửi lại.'], ]); } $client->status = 1; $client->email_verify_code = null; $client->email_verify_code_expired_at = null; $client->email_verified_at = now(); $client->save(); $token = JWTAuth::fromUser($client); $refreshToken = $this->createRefreshToken($client->id); $this->sendWelcomeEmail($client); return [ 'client' => $client, 'token' => $token, 'refresh_token' => $refreshToken, ]; } /** * Gửi lại OTP xác thực (rate limit bởi caller nếu cần). */ public function resendVerifyCode(string $email): void { $client = \App\Models\Customer::whereRaw('LOWER(email) = ?', [strtolower(trim($email))])->first(); if (! $client || $client->status !== 0) { return; } // Nếu mã cũ còn hạn thì không gửi lại — phải đợi hết hạn if ($client->email_verify_code_expired_at && now()->lt($client->email_verify_code_expired_at)) { throw ValidationException::withMessages([ 'email' => ['Mã xác thực vẫn còn hiệu lực, vui lòng kiểm tra email của bạn.'], ]); } $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); $client->email_verify_code = $code; $client->email_verify_code_expired_at = now()->addMinutes(5); $client->save(); $this->sendVerifyEmail($client, $code); } protected function sendVerifyEmail($client, string $code): void { try { Mail::to($client->email)->later(now()->addSeconds(5), new VerifyAccountMail([ 'fullname' => $client->fullname, 'email' => $client->email, 'verify_code' => $code, ])); } catch (\Throwable $e) { Log::warning('Send verify email failed: ' . $e->getMessage(), [ 'customer_id' => $client->id, 'email' => $client->email, ]); } } /** * Gửi email chào mừng sau khi xác thực tài khoản thành công. */ protected function sendWelcomeEmail($client): void { try { Mail::to($client->email)->send(new WelcomeMail([ 'fullname' => $client->fullname, 'email' => $client->email, ])); } catch (\Throwable $e) { Log::warning('Send welcome email failed: ' . $e->getMessage(), [ 'customer_id' => $client->id, 'email' => $client->email, ]); } } /** * Tạo refresh token mới, lưu hash vào DB, trả về raw token. */ public function createRefreshToken(int $customerId): string { $raw = Str::random(64); $hash = hash('sha256', $raw); RefreshToken::create([ 'customer_id' => $customerId, 'token' => $hash, 'expires_at' => now()->addDays(self::REFRESH_TOKEN_TTL_DAYS), ]); return $raw; } /** * Xác minh refresh token, xoay vòng (rotation), trả về access token mới + refresh token mới. * Ném ValidationException nếu token không hợp lệ hoặc đã hết hạn. */ public function rotateRefreshToken(string $rawToken): array { $hash = hash('sha256', $rawToken); $record = RefreshToken::where('token', $hash)->first(); if (!$record) { throw ValidationException::withMessages([ 'refresh_token' => ['Refresh token không hợp lệ hoặc đã hết hạn.'], ]); } $isConcurrentRefresh = $record->revoked_at !== null && $record->revoked_at->gt(now()->subSeconds(self::REFRESH_TOKEN_REUSE_GRACE_SECONDS)); if ($record->revoked_at !== null && !$isConcurrentRefresh) { // Token đã rotate rồi mà còn bị dùng lại: có thể đã bị đánh cắp, // thu hồi toàn bộ refresh token của khách này để buộc đăng nhập lại. RefreshToken::where('customer_id', $record->customer_id)->delete(); throw ValidationException::withMessages([ 'refresh_token' => ['Refresh token đã được sử dụng, mọi phiên đã bị thu hồi.'], ]); } if ($record->isExpired()) { $record->delete(); throw ValidationException::withMessages([ 'refresh_token' => ['Refresh token không hợp lệ hoặc đã hết hạn.'], ]); } $customer = $record->customer; if (!$customer || (int)$customer->status !== 1) { $record->delete(); throw ValidationException::withMessages([ 'refresh_token' => ['Tài khoản không hợp lệ.'], ]); } // Rotation: đánh dấu đã dùng thay vì xoá, để phát hiện reuse nếu bị dùng lại if (!$isConcurrentRefresh) { RefreshToken::whereKey($record->getKey())->whereNull('revoked_at')->update(['revoked_at' => now()]); } // Tạo access token mới $newAccessToken = JWTAuth::fromUser($customer); // Tạo refresh token mới $newRefreshToken = $this->createRefreshToken($customer->id); return [ 'client' => $customer, 'token' => $newAccessToken, 'refresh_token' => $newRefreshToken, ]; } /** * Huỷ refresh token khi đăng xuất. */ public function revokeRefreshToken(string $rawToken): void { $hash = hash('sha256', $rawToken); RefreshToken::where('token', $hash)->delete(); } /** * Gửi link đặt lại mật khẩu (token một lần, hết hạn theo auth.passwords.users.expire). * Không đổi mật khẩu ngay — chỉ chủ hộp thư mới đổi được. Trả về null nếu không có * tài khoản; caller không được tiết lộ kết quả cho client. */ public function sendPasswordResetLink(string $email): ?Customer { $customer = Customer::whereRaw('LOWER(email) = ?', [strtolower(trim($email))])->first(); if (!$customer) { return null; } $token = PasswordResetToken::issue($customer); 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; } /** * Đặ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(string $token, string $password): Customer { $customer = PasswordResetToken::findCustomer($token); 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.'], ]); } 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; } /** * Cập nhật thông tin cá nhân. Trả về old/dirty values để caller ghi activity log. */ public function updateProfile(Customer $client, array $data): array { $oldValues = array_intersect_key($client->toArray(), $data); $client->fill($data); $dirty = $client->getDirty(); $client->save(); return ['client' => $client, 'old' => array_intersect_key($oldValues, $dirty), 'dirty' => $dirty]; } /** * Cập nhật CMT/CCCD (số + 2 ảnh đã được lưu sẵn trên storage bởi caller). */ public function updateCccd(Customer $client, string $number, string $frontUrl, string $backUrl): array { $oldValues = $client->only(['identity_number', 'front_identity_number', 'back_identity_number']); $client->identity_number = $number; $client->front_identity_number = $frontUrl; $client->back_identity_number = $backUrl; $dirty = $client->getDirty(); $client->save(); return ['client' => $client, 'old' => array_intersect_key($oldValues, $dirty), 'dirty' => $dirty]; } /** * Đổi mật khẩu. Ném ValidationException nếu mật khẩu hiện tại không đúng. */ public function changePassword(Customer $client, string $currentPassword, string $newPassword): void { if (!Hash::check($currentPassword, $client->password)) { throw ValidationException::withMessages([ 'current_password' => ['Mật khẩu hiện tại không chính xác'], ]); } $client->password = Hash::make($newPassword); $client->save(); // 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(); } }