diff --git a/assets/components/minishop3/js/web/core/ApiClient.js b/assets/components/minishop3/js/web/core/ApiClient.js index 1bbad2b8..a654cc26 100644 --- a/assets/components/minishop3/js/web/core/ApiClient.js +++ b/assets/components/minishop3/js/web/core/ApiClient.js @@ -89,6 +89,19 @@ class ApiClient { return tokenErrors.includes(result.message) } + /** + * Web API returns payload in `data`; legacy processors used `object`. + * + * @param {Object|null|undefined} result - API response + * @returns {Object|null} + */ + static getPayload (result) { + if (!result) { + return null + } + return result.data ?? result.object ?? null + } + /** * GET request * diff --git a/assets/components/minishop3/js/web/ui/AuthUI.js b/assets/components/minishop3/js/web/ui/AuthUI.js index a4810100..07cd42db 100644 --- a/assets/components/minishop3/js/web/ui/AuthUI.js +++ b/assets/components/minishop3/js/web/ui/AuthUI.js @@ -109,7 +109,7 @@ class AuthUI { if (result.success) { this.showMessage('login-messages', this.t('ms3_customer_login_success'), 'success') setTimeout(() => { - this.handleRedirect(result.object) + this.handleRedirect(ApiClient.getPayload(result)) }, 1000) } else { this.showMessage('login-messages', result.message || this.t('ms3_err_unknown'), 'danger') @@ -177,9 +177,10 @@ class AuthUI { 'success' ) - if (result.object && result.object.token) { + const payload = ApiClient.getPayload(result) + if (payload && payload.token) { setTimeout(() => { - this.handleRedirect(result.object) + this.handleRedirect(payload) }, 1500) } else { setTimeout(() => { diff --git a/core/components/minishop3/config/routes/web.php b/core/components/minishop3/config/routes/web.php index a3381528..42bb9933 100644 --- a/core/components/minishop3/config/routes/web.php +++ b/core/components/minishop3/config/routes/web.php @@ -155,6 +155,18 @@ return Response::success($response->getObject(), $response->getMessage()); }); + $router->post('/logout', function($params) use ($modx) { + $response = $modx->runProcessor( + 'MiniShop3\Processors\Api\Customer\Logout', + [] + ); + + if ($response->isError()) { + return Response::error($response->getMessage(), HttpStatus::BAD_REQUEST); + } + + return Response::success($response->getObject() ?: [], $response->getMessage()); + }, [$tokenMiddleware]); $router->post('/register', function($params) use ($modx) { $input = file_get_contents('php://input'); $data = json_decode($input, true) ?: []; diff --git a/core/components/minishop3/elements/chunks/ms3_customer_unauthorized.tpl b/core/components/minishop3/elements/chunks/ms3_customer_unauthorized.tpl index 81c233a8..392e35e4 100644 --- a/core/components/minishop3/elements/chunks/ms3_customer_unauthorized.tpl +++ b/core/components/minishop3/elements/chunks/ms3_customer_unauthorized.tpl @@ -35,6 +35,7 @@ {'ms3_customer_email' | lexicon} * @@ -43,16 +44,10 @@ {'ms3_customer_password' | lexicon} * -
- - -
- @@ -79,6 +74,7 @@ {'ms3_customer_email' | lexicon} * @@ -112,6 +108,7 @@ {'ms3_customer_password' | lexicon} * {'ms3_customer_password_hint' | lexicon} @@ -123,6 +120,7 @@ {'ms3_customer_password_confirm' | lexicon} * @@ -151,4 +149,5 @@ window.ms3Lexicon.ms3_customer_err_password_mismatch = '{'ms3_customer_err_passw window.ms3Lexicon.ms3_customer_err_privacy_required = '{'ms3_customer_err_privacy_required' | lexicon}'; window.ms3Lexicon.ms3_customer_register_success = '{'ms3_customer_register_success' | lexicon}'; window.ms3Lexicon.ms3_err_unknown = '{'ms3_err_unknown' | lexicon}'; +window.ms3Lexicon.ms3_customer_password_recovery_not_available = '{'ms3_customer_password_recovery_not_available' | lexicon}'; diff --git a/core/components/minishop3/elements/snippets/ms3_customer.php b/core/components/minishop3/elements/snippets/ms3_customer.php index 591b8f7e..5820972c 100644 --- a/core/components/minishop3/elements/snippets/ms3_customer.php +++ b/core/components/minishop3/elements/snippets/ms3_customer.php @@ -26,20 +26,20 @@ $modx->lexicon->load('minishop3:cart'); // For order details template if (isset($_GET['action']) && $_GET['action'] === 'logout') { - if (!empty($_SESSION['ms3']['customer_token'])) { - $token = $_SESSION['ms3']['customer_token']; - $tokenObj = $modx->getObject(\MiniShop3\Model\msCustomerToken::class, ['token' => $token]); - if ($tokenObj) { - $tokenObj->remove(); + /** @var \MiniShop3\Services\Customer\AuthManager $authManager */ + $authManager = $modx->services->get('ms3_auth_manager'); + if (!$authManager->logoutCurrentCustomer()) { + $modx->log( + \MODX\Revolution\modX::LOG_LEVEL_ERROR, + '[ms3_customer] logoutCurrentCustomer failed; forcing guest token mint' + ); + if ($modx->services->has('ms3_token_service')) { + /** @var \MiniShop3\Services\TokenService $tokenService */ + $tokenService = $modx->services->get('ms3_token_service'); + $tokenService->persistApiToken(0); } } - if (isset($_SESSION['ms3'])) { - unset($_SESSION['ms3']['customer_id']); - unset($_SESSION['ms3']['customer_token']); - unset($_SESSION['ms3']['customer_token_expires']); - } - $loginPageId = $modx->getOption('ms3_customer_login_page_id', null, 1); $modx->sendRedirect($modx->makeUrl($loginPageId)); exit; diff --git a/core/components/minishop3/lexicon/en/customer.inc.php b/core/components/minishop3/lexicon/en/customer.inc.php index 4bf69e7e..14a93af0 100644 --- a/core/components/minishop3/lexicon/en/customer.inc.php +++ b/core/components/minishop3/lexicon/en/customer.inc.php @@ -35,7 +35,9 @@ $_lang['ms3_customer_password'] = 'Password'; $_lang['ms3_customer_password_confirm'] = 'Confirm Password'; $_lang['ms3_customer_register_success'] = 'Registration successful'; +$_lang['ms3_customer_register_success_login_required'] = 'Registration successful. Please sign in with your email and password.'; $_lang['ms3_customer_login_success'] = 'You have successfully logged in'; +$_lang['ms3_customer_password_recovery_not_available'] = 'Password recovery is not available yet'; $_lang['ms3_customer_logout'] = 'Logout'; $_lang['ms3_customer_logout_success'] = 'You have been logged out'; $_lang['ms3_customer_logout_confirm'] = 'Are you sure you want to logout?'; @@ -43,6 +45,8 @@ // Errors - Authentication $_lang['ms3_customer_err_login_required'] = 'Please provide email and password'; $_lang['ms3_customer_err_login_invalid'] = 'Invalid email or password'; +$_lang['ms3_customer_err_login_blocked'] = 'This account is temporarily blocked. Try again later.'; +$_lang['ms3_customer_err_login_inactive'] = 'This account is inactive. Contact the store administrator.'; $_lang['ms3_customer_err_login_rate_limit'] = 'Too many login attempts ({attempts}/{max}). Try again in {minutes} minutes.'; $_lang['ms3_customer_err_email_required'] = 'Email is required'; $_lang['ms3_customer_err_email_invalid'] = 'Invalid email format'; diff --git a/core/components/minishop3/lexicon/ru/customer.inc.php b/core/components/minishop3/lexicon/ru/customer.inc.php index bcc67993..a9111b0f 100644 --- a/core/components/minishop3/lexicon/ru/customer.inc.php +++ b/core/components/minishop3/lexicon/ru/customer.inc.php @@ -35,7 +35,9 @@ $_lang['ms3_customer_password'] = 'Пароль'; $_lang['ms3_customer_password_confirm'] = 'Подтверждение пароля'; $_lang['ms3_customer_register_success'] = 'Регистрация прошла успешно'; +$_lang['ms3_customer_register_success_login_required'] = 'Регистрация прошла успешно. Войдите с email и паролем.'; $_lang['ms3_customer_login_success'] = 'Вы успешно вошли в систему'; +$_lang['ms3_customer_password_recovery_not_available'] = 'Восстановление пароля пока недоступно'; $_lang['ms3_customer_logout'] = 'Выход'; $_lang['ms3_customer_logout_success'] = 'Вы вышли из системы'; $_lang['ms3_customer_logout_confirm'] = 'Вы действительно хотите выйти?'; @@ -43,6 +45,8 @@ // Errors - Authentication $_lang['ms3_customer_err_login_required'] = 'Укажите email и пароль'; $_lang['ms3_customer_err_login_invalid'] = 'Неверный email или пароль'; +$_lang['ms3_customer_err_login_blocked'] = 'Аккаунт временно заблокирован. Попробуйте позже.'; +$_lang['ms3_customer_err_login_inactive'] = 'Аккаунт неактивен. Обратитесь к администратору магазина.'; $_lang['ms3_customer_err_login_rate_limit'] = 'Превышен лимит попыток входа ({attempts}/{max}). Попробуйте через {minutes} минут.'; $_lang['ms3_customer_err_email_required'] = 'Email обязателен для заполнения'; $_lang['ms3_customer_err_email_invalid'] = 'Указан некорректный email'; diff --git a/core/components/minishop3/src/Controllers/Api/Manager/CustomersController.php b/core/components/minishop3/src/Controllers/Api/Manager/CustomersController.php index bf009ff2..49207a0f 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/CustomersController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/CustomersController.php @@ -2,9 +2,11 @@ namespace MiniShop3\Controllers\Api\Manager; +use MiniShop3\Controllers\Auth\PasswordAuthProvider; use MiniShop3\Model\msCustomer; use MiniShop3\Router\HttpStatus; use MiniShop3\Router\Response; +use MiniShop3\Services\Customer\AuthManager; use MODX\Revolution\modX; /** @@ -177,13 +179,19 @@ public function update(array $data = []): array foreach ($allowedFields as $field) { if (isset($data[$field])) { - $customer->set($field, $data[$field]); + $value = $data[$field]; + if ($field === 'email' && is_string($value)) { + $value = AuthManager::normalizeEmail($value); + } + $customer->set($field, $value); } } - if (!empty($data['password'])) { - $hashedPassword = password_hash($data['password'], PASSWORD_DEFAULT); - $customer->set('password', $hashedPassword); + if (isset($data['password'])) { + $password = trim((string)$data['password']); + if ($password !== '') { + $customer->set('password', PasswordAuthProvider::hashPassword($password)); + } } if (!$customer->save()) { diff --git a/core/components/minishop3/src/Controllers/Auth/PasswordAuthProvider.php b/core/components/minishop3/src/Controllers/Auth/PasswordAuthProvider.php index 513d1b72..806691ef 100644 --- a/core/components/minishop3/src/Controllers/Auth/PasswordAuthProvider.php +++ b/core/components/minishop3/src/Controllers/Auth/PasswordAuthProvider.php @@ -3,6 +3,7 @@ namespace MiniShop3\Controllers\Auth; use MiniShop3\Model\msCustomer; +use MiniShop3\Services\Customer\AuthManager; use MODX\Revolution\modX; /** @@ -40,6 +41,14 @@ public function __construct(modX $modx) $this->modx = $modx; } + /** + * Normalize email for lookup and storage (delegates to AuthManager). + */ + public static function normalizeEmail(string $email): string + { + return AuthManager::normalizeEmail($email); + } + /** * Authenticate by email and password * @@ -48,7 +57,7 @@ public function __construct(modX $modx) */ public function authenticate(array $credentials): ?msCustomer { - $email = trim($credentials['email'] ?? ''); + $email = self::normalizeEmail($credentials['email'] ?? ''); $password = $credentials['password'] ?? ''; if (empty($email) || empty($password)) { @@ -59,11 +68,19 @@ public function authenticate(array $credentials): ?msCustomer return null; } - // Find customer by email - /** @var msCustomer $customer */ + // Normalized lookup, then legacy mixed-case exact match (utf8mb4_bin / old rows). + /** @var msCustomer|null $customer */ $customer = $this->modx->getObject(msCustomer::class, [ 'email' => $email, ]); + if (!$customer) { + $raw = trim((string)($credentials['email'] ?? '')); + if ($raw !== '' && $raw !== $email) { + $customer = $this->modx->getObject(msCustomer::class, [ + 'email' => $raw, + ]); + } + } if (!$customer) { $this->modx->log( @@ -92,16 +109,32 @@ public function authenticate(array $credentials): ?msCustomer return null; } + // Soft-migrate legacy mixed-case emails to canonical lowercase. + if ((string)$customer->get('email') !== $email) { + $customer->set('email', $email); + if (!$customer->save()) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + "[PasswordAuthProvider] Failed to normalize email for customer #{$customer->id}" + ); + } + } + // Check if password hash needs to be updated (if bcrypt settings changed) if (password_needs_rehash($hashedPassword, PASSWORD_BCRYPT)) { $newHash = password_hash($password, PASSWORD_BCRYPT); $customer->set('password', $newHash); - $customer->save(); - - $this->modx->log( - modX::LOG_LEVEL_INFO, - "[PasswordAuthProvider] Password rehashed for customer #{$customer->id}" - ); + if ($customer->save()) { + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[PasswordAuthProvider] Password rehashed for customer #{$customer->id}" + ); + } else { + $this->modx->log( + modX::LOG_LEVEL_WARN, + "[PasswordAuthProvider] Failed to persist rehashed password for customer #{$customer->id}" + ); + } } $this->modx->log( diff --git a/core/components/minishop3/src/Controllers/Customer/Customer.php b/core/components/minishop3/src/Controllers/Customer/Customer.php index 1157ee57..96e00884 100644 --- a/core/components/minishop3/src/Controllers/Customer/Customer.php +++ b/core/components/minishop3/src/Controllers/Customer/Customer.php @@ -8,9 +8,8 @@ use MiniShop3\MiniShop3; use MiniShop3\Model\msCustomer; -use MiniShop3\Model\msCustomerToken; +use MiniShop3\Services\Customer\AuthManager; use MiniShop3\Services\Customer\CustomerAddressManager; -use MiniShop3\Utils\CookieHelper; use MODX\Revolution\modX; use Rakit\Validation\Validator; @@ -66,6 +65,9 @@ public function generateToken(): array $tokenService = $this->modx->services->get('ms3_token_service'); $result = $tokenService->generateCustomerToken(); + if ($result['token'] === '') { + return $this->error('ms3_err_token'); + } return $this->success('', [ 'token' => $result['token'], @@ -83,6 +85,9 @@ public function updateToken(string $token = ''): array $tokenService = $this->modx->services->get('ms3_token_service'); $result = $tokenService->updateCustomerToken($token); + if ($result['token'] === '') { + return $this->error('ms3_err_token'); + } return $this->success('', [ 'token' => $result['token'], @@ -456,43 +461,6 @@ public function getOrCreate(?array $orderData = null): int return 0; } - /** - * Auto-login customer after order creation - * - * Binds existing msCustomerToken to customer and sets session + cookie. - * - * @param msCustomer $msCustomer Customer to login - */ - protected function autoLoginCustomer(msCustomer $msCustomer): void - { - if (!isset($_SESSION['ms3'])) { - $_SESSION['ms3'] = []; - } - $_SESSION['ms3']['customer_id'] = $msCustomer->id; - - // Resolve current token: cookie → session → controller token - // ($this->token comes from $_REQUEST via middleware cookie injection) - $currentToken = CookieHelper::getTokenFromCookie(); - if (empty($currentToken)) { - $currentToken = $_SESSION['ms3']['customer_token'] ?? $this->token; - } - - if (!empty($currentToken)) { - $tokenObj = $this->modx->getObject(msCustomerToken::class, [ - 'token' => $currentToken, - 'type' => msCustomerToken::TYPE_API, - ]); - - if ($tokenObj) { - $tokenObj->set('customer_id', $msCustomer->id); - $tokenObj->save(); - } - - $_SESSION['ms3']['customer_token'] = $currentToken; - CookieHelper::setTokenCookie($this->modx, $currentToken); - } - } - /** * Find customer by email * @@ -501,11 +469,23 @@ protected function autoLoginCustomer(msCustomer $msCustomer): void */ protected function findByEmail(string $email): ?msCustomer { - if (empty($email)) { + $normalized = AuthManager::normalizeEmail($email); + if ($normalized === '') { return null; } - return $this->modx->getObject(msCustomer::class, ['email' => $email]); + /** @var msCustomer|null $customer */ + $customer = $this->modx->getObject(msCustomer::class, ['email' => $normalized]); + if ($customer) { + return $customer; + } + + $raw = trim($email); + if ($raw !== '' && $raw !== $normalized) { + return $this->modx->getObject(msCustomer::class, ['email' => $raw]) ?: null; + } + + return null; } /** @@ -550,19 +530,16 @@ protected function createFromOrderData(array $orderData): ?msCustomer if ($registerResult['success']) { $msCustomer = $registerResult['customer']; - - if ($autoLogin) { - $this->autoLoginCustomer($msCustomer); - } } else { $msCustomer = $this->findByEmail($email); if ($msCustomer) { $msCustomer->set('token', $this->token); - $msCustomer->save(); - - if ($autoLogin) { - $this->autoLoginCustomer($msCustomer); + if (!$msCustomer->save()) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + "[Customer] Failed to update token for customer #{$msCustomer->id}" + ); } } } @@ -579,9 +556,16 @@ protected function createFromOrderData(array $orderData): ?msCustomer ]; $msCustomer = $this->create($customerData); + } - if ($msCustomer && $autoLogin) { - $this->autoLoginCustomer($msCustomer); + if ($msCustomer && $autoLogin) { + /** @var AuthManager $authManager */ + $authManager = $this->modx->services->get('ms3_auth_manager'); + if (!$authManager->establishCustomerSession($msCustomer)) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + "[Customer] establishCustomerSession failed for customer #{$msCustomer->id}" + ); } } diff --git a/core/components/minishop3/src/Middleware/TokenMiddleware.php b/core/components/minishop3/src/Middleware/TokenMiddleware.php index 46a0c6cd..0d02193a 100644 --- a/core/components/minishop3/src/Middleware/TokenMiddleware.php +++ b/core/components/minishop3/src/Middleware/TokenMiddleware.php @@ -7,6 +7,7 @@ use MiniShop3\Router\Response; use MiniShop3\Services\TokenService; use MiniShop3\Utils\CookieHelper; +use MiniShop3\Utils\SessionHelper; use MODX\Revolution\modX; /** @@ -71,17 +72,27 @@ public function handle(array $params) $uri = $_SERVER['REQUEST_URI'] ?? '/'; $isPublic = $this->isPublicRoute($uri); - // Ensure session is active - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } + SessionHelper::ensureActive(); - // For non-public routes: check session first + // For non-public routes: session shortcut only when token still belongs to customer if (!$isPublic && !empty($_SESSION['ms3']['customer_id'])) { - $customer = $this->modx->getObject(\MiniShop3\Model\msCustomer::class, $_SESSION['ms3']['customer_id']); - if ($customer) { + $customerId = (int)$_SESSION['ms3']['customer_id']; + $customer = $this->modx->getObject(\MiniShop3\Model\msCustomer::class, $customerId); + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->get('ms3_token_service'); + if ( + $customer + && $this->isCustomerSessionAllowed($customer) + && $tokenService->sessionTokenBelongsToCustomer($customerId) + ) { return null; } + + unset( + $_SESSION['ms3']['customer_id'], + $_SESSION['ms3']['customer_token'], + $_SESSION['ms3']['customer_token_expires'] + ); } // Resolve token from multiple sources @@ -95,28 +106,27 @@ public function handle(array $params) ]); if ($tokenObj) { - // Auto-renew expired token - if ($tokenObj->isExpired()) { - $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); - $newExpiresAt = date('Y-m-d H:i:s', time() + $ttl); - $tokenObj->set('expires_at', $newExpiresAt); - $tokenObj->save(); - } - - // Save to session - if (!isset($_SESSION['ms3'])) { - $_SESSION['ms3'] = []; - } - $_SESSION['ms3']['customer_token'] = $token; - $_SESSION['ms3']['customer_id'] = $tokenObj->get('customer_id'); - $_SESSION['ms3']['customer_token_expires'] = strtotime($tokenObj->get('expires_at')); - - // Refresh cookie - CookieHelper::setTokenCookie($this->modx, $token); + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->get('ms3_token_service'); + $tokenService->syncSessionFromToken($tokenObj); // Ensure $_REQUEST has the token for controllers $_REQUEST['ms3_token'] = $token; + $tokenCustomerId = (int)$tokenObj->get('customer_id'); + if ($tokenCustomerId > 0) { + $customer = $this->modx->getObject( + \MiniShop3\Model\msCustomer::class, + $tokenCustomerId + ); + if (!$customer || !$this->isCustomerSessionAllowed($customer)) { + unset($_SESSION['ms3']['customer_id']); + if (!$isPublic) { + return Response::error('ms3_err_token_invalid', HttpStatus::UNAUTHORIZED); + } + } + } + return null; } @@ -174,6 +184,27 @@ private function resolveToken(): string return $_REQUEST['ms3_token'] ?? $_REQUEST['token'] ?? ''; } + /** + * Session shortcut is valid only for active, non-blocked customers. + */ + private function isCustomerSessionAllowed(\MiniShop3\Model\msCustomer $customer): bool + { + if (!$customer->get('is_active')) { + return false; + } + + if (!$customer->get('is_blocked')) { + return true; + } + + $blockedUntil = $customer->get('blocked_until'); + if ($blockedUntil && strtotime((string)$blockedUntil) > time()) { + return false; + } + + return true; + } + /** * Check if route is public * diff --git a/core/components/minishop3/src/Processors/Api/Customer/Login.php b/core/components/minishop3/src/Processors/Api/Customer/Login.php index a602f80e..6e8545a5 100644 --- a/core/components/minishop3/src/Processors/Api/Customer/Login.php +++ b/core/components/minishop3/src/Processors/Api/Customer/Login.php @@ -2,18 +2,15 @@ namespace MiniShop3\Processors\Api\Customer; -use MiniShop3\Model\msCustomerToken; use MiniShop3\Services\Customer\AuthManager; use MiniShop3\Services\Customer\RateLimiter; -use MiniShop3\Services\Order\OrderDraftManager; -use MiniShop3\Utils\CookieHelper; use MODX\Revolution\Processors\Processor; /** * Login - customer login processor * - * Authenticates customer and binds existing session token to customer. - * Token does NOT change on login — guest cart is preserved. + * Authenticates customer and establishes a rotated API session token. + * Guest cart may transfer from the previous token; the previous token is revoked. * Protected from brute-force via RateLimiter. * * @package MiniShop3\Processors\Api\Customer @@ -27,10 +24,11 @@ public function process() { $this->modx->lexicon->load('minishop3:customer'); - $email = trim($this->getProperty('email', '')); + $emailRaw = trim((string)$this->getProperty('email', '')); + $email = AuthManager::normalizeEmail($emailRaw); $password = $this->getProperty('password', ''); - if (empty($email) || empty($password)) { + if ($email === '' || $password === '') { return $this->failure($this->modx->lexicon('ms3_customer_err_login_required')); } @@ -55,81 +53,40 @@ public function process() /** @var AuthManager $authManager */ $authManager = $this->modx->services->get('ms3_auth_manager'); + // Pass raw email so PasswordAuthProvider can resolve legacy mixed-case rows. $customer = $authManager->authenticate([ - 'email' => $email, + 'email' => $emailRaw, 'password' => $password, ]); if (!$customer) { + // Only wrong password / unknown email increment lockout counter. + // blocked / inactive keep lastAuthFailure and must not call handleFailedLoginByEmail. + $failure = $authManager->getLastAuthFailure(); + if ($failure === 'invalid_credentials') { + $authManager->handleFailedLoginByEmail($emailRaw); + } + $this->modx->log( \MODX\Revolution\modX::LOG_LEVEL_WARN, - "[Login] Failed login attempt for email: {$email} from IP: {$ip}" + "[Login] Failed login attempt for email: {$email} from IP: {$ip} (reason: {$failure})" ); - return $this->failure($this->modx->lexicon('ms3_customer_err_login_invalid')); - } - - $rateLimiter->reset('login', $ip); - - // Use existing token from cookie/session instead of creating new one - $currentToken = CookieHelper::getTokenFromCookie(); - if (empty($currentToken)) { - $currentToken = $_SESSION['ms3']['customer_token'] ?? ''; - } + $messageKey = match ($failure) { + 'blocked' => 'ms3_customer_err_login_blocked', + 'inactive' => 'ms3_customer_err_login_inactive', + default => 'ms3_customer_err_login_invalid', + }; - $tokenObj = null; - $tokenString = ''; - $expiresAt = ''; - - if (!empty($currentToken)) { - // Find existing token in DB - $tokenObj = $this->modx->getObject(msCustomerToken::class, [ - 'token' => $currentToken, - 'type' => msCustomerToken::TYPE_API, - ]); - - if ($tokenObj) { - // Bind customer to existing token - $tokenObj->set('customer_id', $customer->id); - - // Extend TTL - $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); - $tokenObj->set('expires_at', date('Y-m-d H:i:s', time() + $ttl)); - $tokenObj->save(); - - $tokenString = $tokenObj->get('token'); - $expiresAt = $tokenObj->get('expires_at'); - - // Bind draft order to customer - /** @var OrderDraftManager $draftManager */ - $draftManager = $this->modx->services->get('ms3_order_draft_manager'); - $draftManager->bindDraftToCustomer($tokenString, $customer->id); - } + return $this->failure($this->modx->lexicon($messageKey)); } - // Edge case: no valid existing token — create new one - if (!$tokenObj) { - $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); - $tokenObj = $authManager->createToken($customer, 'api', $ttl); - - if (!$tokenObj) { - return $this->failure($this->modx->lexicon('ms3_customer_err_token_create')); - } - - $tokenString = $tokenObj->get('token'); - $expiresAt = $tokenObj->get('expires_at'); - - // Set cookie for new token - CookieHelper::setTokenCookie($this->modx, $tokenString); - } + $rateLimiter->reset('login', $ip); - // Update session - if (!isset($_SESSION['ms3'])) { - $_SESSION['ms3'] = []; + $session = $authManager->establishCustomerSession($customer); + if (!$session) { + return $this->failure($this->modx->lexicon('ms3_customer_err_token_create')); } - $_SESSION['ms3']['customer_id'] = $customer->id; - $_SESSION['ms3']['customer_token'] = $tokenString; - $_SESSION['ms3']['customer_token_expires'] = strtotime($expiresAt); $redirectPageId = (int)$this->getProperty('redirect_page_id', 0); if (!$redirectPageId) { @@ -150,8 +107,8 @@ public function process() 'phone' => $customer->get('phone'), 'email_verified' => !empty($customer->get('email_verified_at')), ], - 'token' => $tokenString, - 'expires_at' => $expiresAt, + 'token' => $session['token'], + 'expires_at' => $session['expires_at'], 'redirect_url' => $redirectUrl, ]); } diff --git a/core/components/minishop3/src/Processors/Api/Customer/Logout.php b/core/components/minishop3/src/Processors/Api/Customer/Logout.php index cec37eb9..55af67c3 100644 --- a/core/components/minishop3/src/Processors/Api/Customer/Logout.php +++ b/core/components/minishop3/src/Processors/Api/Customer/Logout.php @@ -2,16 +2,13 @@ namespace MiniShop3\Processors\Api\Customer; -use MiniShop3\Model\msCustomer; -use MiniShop3\Model\msCustomerToken; use MiniShop3\Services\Customer\AuthManager; -use MiniShop3\Utils\CookieHelper; use MODX\Revolution\Processors\Processor; /** * Logout - customer logout processor * - * Revokes all customer tokens and creates a fresh anonymous token. + * Revokes all customer API tokens and creates a fresh anonymous token. * New cookie is set — guest cart starts fresh (security: old token invalidated). * * @package MiniShop3\Processors\Api\Customer @@ -23,52 +20,15 @@ class Logout extends Processor */ public function process() { - $customerId = $_SESSION['ms3']['customer_id'] ?? null; + $this->modx->lexicon->load('minishop3:customer'); - if (!$customerId) { - return $this->success($this->modx->lexicon('ms3_customer_logout_success')); - } - - /** @var msCustomer $customer */ - $customer = $this->modx->getObject(msCustomer::class, $customerId); - - if ($customer) { - /** @var AuthManager $authManager */ - $authManager = $this->modx->services->get('ms3_auth_manager'); + /** @var AuthManager $authManager */ + $authManager = $this->modx->services->get('ms3_auth_manager'); - // Revoke ALL api tokens for this customer (including current) - $authManager->revokeTokens($customer, 'api'); - - $this->modx->log( - \MODX\Revolution\modX::LOG_LEVEL_INFO, - "[Logout] Customer #{$customer->id} logged out" - ); + if (!$authManager->logoutCurrentCustomer()) { + return $this->failure($this->modx->lexicon('ms3_customer_err_token_create')); } - // Generate fresh anonymous token - $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); - $newToken = bin2hex(random_bytes(32)); - $expiresAt = date('Y-m-d H:i:s', time() + $ttl); - - $tokenObj = $this->modx->newObject(msCustomerToken::class); - $tokenObj->set('token', $newToken); - $tokenObj->set('type', msCustomerToken::TYPE_API); - $tokenObj->set('customer_id', 0); - $tokenObj->set('expires_at', $expiresAt); - $tokenObj->set('created_at', date('Y-m-d H:i:s')); - $tokenObj->save(); - - // Update session with new anonymous token - if (!isset($_SESSION['ms3'])) { - $_SESSION['ms3'] = []; - } - unset($_SESSION['ms3']['customer_id']); - $_SESSION['ms3']['customer_token'] = $newToken; - $_SESSION['ms3']['customer_token_expires'] = time() + $ttl; - - // Set new cookie - CookieHelper::setTokenCookie($this->modx, $newToken); - return $this->success($this->modx->lexicon('ms3_customer_logout_success')); } } diff --git a/core/components/minishop3/src/Processors/Api/Customer/Register.php b/core/components/minishop3/src/Processors/Api/Customer/Register.php index 76252f16..295b5323 100644 --- a/core/components/minishop3/src/Processors/Api/Customer/Register.php +++ b/core/components/minishop3/src/Processors/Api/Customer/Register.php @@ -2,13 +2,10 @@ namespace MiniShop3\Processors\Api\Customer; -use MiniShop3\Model\msCustomerToken; use MiniShop3\Services\Customer\AuthManager; use MiniShop3\Services\Customer\EmailVerificationService; use MiniShop3\Services\Customer\RateLimiter; use MiniShop3\Services\Customer\RegisterService; -use MiniShop3\Services\Order\OrderDraftManager; -use MiniShop3\Utils\CookieHelper; use MODX\Revolution\Processors\Processor; /** @@ -29,18 +26,19 @@ public function process() { $this->modx->lexicon->load('minishop3:customer'); - $email = trim($this->getProperty('email', '')); + $emailRaw = trim((string)$this->getProperty('email', '')); + $email = AuthManager::normalizeEmail($emailRaw); $password = $this->getProperty('password', ''); $firstName = trim($this->getProperty('first_name', '')); $lastName = trim($this->getProperty('last_name', '')); $phone = trim($this->getProperty('phone', '')); $privacyAccepted = (bool)$this->getProperty('privacy_accepted', false); - if (empty($email)) { + if ($email === '') { return $this->failure($this->modx->lexicon('ms3_customer_err_email_required')); } - if (empty($password)) { + if ($password === '') { return $this->failure($this->modx->lexicon('ms3_customer_err_password_required')); } @@ -65,7 +63,7 @@ public function process() $registerService->setEmailVerification($emailVerification); $result = $registerService->register([ - 'email' => $email, + 'email' => $emailRaw, 'password' => $password, 'first_name' => $firstName, 'last_name' => $lastName, @@ -86,60 +84,16 @@ public function process() $expiresAt = null; if ($autoLogin && !$requireEmailVerification) { - // Use existing token from cookie/session instead of creating new one - $currentToken = CookieHelper::getTokenFromCookie(); - if (empty($currentToken)) { - $currentToken = $_SESSION['ms3']['customer_token'] ?? ''; - } - - $tokenObj = null; - - if (!empty($currentToken)) { - $tokenObj = $this->modx->getObject(msCustomerToken::class, [ - 'token' => $currentToken, - 'type' => msCustomerToken::TYPE_API, - ]); - - if ($tokenObj) { - // Bind customer to existing token - $tokenObj->set('customer_id', $customer->id); - - $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); - $tokenObj->set('expires_at', date('Y-m-d H:i:s', time() + $ttl)); - $tokenObj->save(); + /** @var AuthManager $authManager */ + $authManager = $this->modx->services->get('ms3_auth_manager'); + $session = $authManager->establishCustomerSession($customer); - $tokenString = $tokenObj->get('token'); - $expiresAt = $tokenObj->get('expires_at'); - - // Bind draft order to customer - /** @var OrderDraftManager $draftManager */ - $draftManager = $this->modx->services->get('ms3_order_draft_manager'); - $draftManager->bindDraftToCustomer($tokenString, $customer->id); - } - } - - // Edge case: no valid existing token - if (!$tokenObj) { - /** @var AuthManager $authManager */ - $authManager = $this->modx->services->get('ms3_auth_manager'); - $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); - $tokenObj = $authManager->createToken($customer, 'api', $ttl); - - if ($tokenObj) { - $tokenString = $tokenObj->get('token'); - $expiresAt = $tokenObj->get('expires_at'); - CookieHelper::setTokenCookie($this->modx, $tokenString); - } + if (!$session) { + return $this->failure($this->modx->lexicon('ms3_customer_err_token_create')); } - if ($tokenString) { - if (!isset($_SESSION['ms3'])) { - $_SESSION['ms3'] = []; - } - $_SESSION['ms3']['customer_id'] = $customer->id; - $_SESSION['ms3']['customer_token'] = $tokenString; - $_SESSION['ms3']['customer_token_expires'] = strtotime($expiresAt); - } + $tokenString = $session['token']; + $expiresAt = $session['expires_at']; } $rateLimiter->reset('login', $ip); diff --git a/core/components/minishop3/src/Processors/Api/Customer/ResetPassword.php b/core/components/minishop3/src/Processors/Api/Customer/ResetPassword.php index 34e55fa5..3a175d6c 100644 --- a/core/components/minishop3/src/Processors/Api/Customer/ResetPassword.php +++ b/core/components/minishop3/src/Processors/Api/Customer/ResetPassword.php @@ -68,6 +68,7 @@ public function process() } $authManager->revokeTokens($customer); + $authManager->invalidateLocalSessionForCustomer($customer); $this->modx->log( \MODX\Revolution\modX::LOG_LEVEL_INFO, diff --git a/core/components/minishop3/src/Services/Customer/AuthManager.php b/core/components/minishop3/src/Services/Customer/AuthManager.php index 8a80bbac..0447e315 100644 --- a/core/components/minishop3/src/Services/Customer/AuthManager.php +++ b/core/components/minishop3/src/Services/Customer/AuthManager.php @@ -6,6 +6,10 @@ use MiniShop3\Controllers\Auth\PasswordAuthProvider; use MiniShop3\Model\msCustomer; use MiniShop3\Model\msCustomerToken; +use MiniShop3\Services\Order\OrderDraftManager; +use MiniShop3\Services\TokenService; +use MiniShop3\Utils\CookieHelper; +use MiniShop3\Utils\SessionHelper; use MODX\Revolution\modX; /** @@ -43,6 +47,9 @@ class AuthManager /** @var AuthProviderInterface[] Registered authentication providers */ protected array $providers = []; + /** Last authenticate() failure: invalid_credentials|blocked|inactive|none */ + protected string $lastAuthFailure = 'none'; + /** * @param modX $modx */ @@ -53,6 +60,14 @@ public function __construct(modX $modx) $this->registerProvider(new PasswordAuthProvider($modx)); } + /** + * Reason of the last failed authenticate() call. + */ + public function getLastAuthFailure(): string + { + return $this->lastAuthFailure; + } + /** * Register authentication provider * @@ -102,6 +117,8 @@ public function getProviders(): array */ public function authenticate(array $credentials): ?msCustomer { + $this->lastAuthFailure = 'invalid_credentials'; + foreach ($this->providers as $provider) { if ($provider->supports($credentials)) { $this->modx->log( @@ -115,6 +132,7 @@ public function authenticate(array $credentials): ?msCustomer if ($customer->get('is_blocked')) { $blockedUntil = $customer->get('blocked_until'); if ($blockedUntil && strtotime($blockedUntil) > time()) { + $this->lastAuthFailure = 'blocked'; $this->modx->log( modX::LOG_LEVEL_WARN, "[AuthManager] Customer #{$customer->id} is blocked until {$blockedUntil}" @@ -124,10 +142,16 @@ public function authenticate(array $credentials): ?msCustomer $customer->set('is_blocked', false); $customer->set('blocked_until', null); $customer->set('failed_login_attempts', 0); - $customer->save(); + if (!$customer->save()) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + "[AuthManager] Failed to clear block flags for customer #{$customer->id}" + ); + } } if (!$customer->get('is_active')) { + $this->lastAuthFailure = 'inactive'; $this->modx->log( modX::LOG_LEVEL_WARN, "[AuthManager] Customer #{$customer->id} is not active" @@ -137,8 +161,14 @@ public function authenticate(array $credentials): ?msCustomer $customer->set('last_login_at', date('Y-m-d H:i:s')); $customer->set('failed_login_attempts', 0); - $customer->save(); + if (!$customer->save()) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + "[AuthManager] Failed to persist last_login for customer #{$customer->id}" + ); + } + $this->lastAuthFailure = 'none'; $this->modx->log( modX::LOG_LEVEL_INFO, "[AuthManager] Customer #{$customer->id} authenticated via {$provider->getName()}" @@ -157,6 +187,230 @@ public function authenticate(array $credentials): ?msCustomer return null; } + /** + * Bind authenticated customer to a fresh API token, session, cookie, and draft order. + * + * Always mints a new token (anti session-fixation). Guest/own previous token may + * transfer the draft cart, then the old token row is removed so a planted cookie + * cannot keep access after login. + * + * @return array{token: string, expires_at: string}|null + */ + public function establishCustomerSession(msCustomer $customer): ?array + { + SessionHelper::ensureActive(); + + $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); + + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->get('ms3_token_service'); + $previousToken = $tokenService->getBindableTokenString(); + + $previousTokenObj = null; + if ($previousToken !== '') { + $previousTokenObj = $this->modx->getObject(msCustomerToken::class, [ + 'token' => $previousToken, + 'type' => msCustomerToken::TYPE_API, + ]); + } + + // Always rotate API token on login/register (do not upgrade a planted guest token). + $tokenObj = $tokenService->persistApiToken((int)$customer->id, null, $ttl); + if (!$tokenObj) { + return null; + } + + $tokenString = (string)$tokenObj->get('token'); + + /** @var OrderDraftManager $draftManager */ + $draftManager = $this->modx->services->get('ms3_order_draft_manager'); + + $previousCustomerId = $previousTokenObj + ? (int)$previousTokenObj->get('customer_id') + : -1; + + if ( + $previousToken !== '' + && $previousToken !== $tokenString + && self::canTransferCartFromToken($previousCustomerId, (int)$customer->id) + ) { + if (!$draftManager->transferDraftToToken($previousToken, $tokenString, (int)$customer->id)) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + "[AuthManager] transferDraftToToken failed for customer #{$customer->id}" + ); + } + } elseif (!$draftManager->bindDraftToCustomer($tokenString, (int)$customer->id)) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + "[AuthManager] bindDraftToCustomer failed for customer #{$customer->id}" + ); + } + + // Always drop the previous browser token so a planted/old cookie cannot keep access. + if ($previousTokenObj && $previousToken !== $tokenString) { + $previousTokenObj->remove(); + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + + return [ + 'token' => $tokenString, + 'expires_at' => $tokenObj->get('expires_at'), + ]; + } + + /** + * End storefront session: revoke API tokens, mint guest token, refresh session id. + * + * Used by Web API Logout and snippet `?action=logout` so cookie restore cannot re-auth. + * + * @return bool false when anonymous token could not be persisted + */ + public function logoutCurrentCustomer(): bool + { + SessionHelper::ensureActive(); + + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->get('ms3_token_service'); + $tokenService->restoreSessionFromCookie(); + + $customerId = (int)($_SESSION['ms3']['customer_id'] ?? 0); + if ($customerId > 0) { + /** @var msCustomer|null $customer */ + $customer = $this->modx->getObject(msCustomer::class, $customerId); + if ($customer) { + $this->revokeTokens($customer, msCustomerToken::TYPE_API); + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[AuthManager] Customer #{$customer->id} logged out" + ); + } + } else { + $orphanToken = $tokenService->getBindableTokenString(); + if ($orphanToken !== '') { + $tokenObj = $this->modx->getObject(msCustomerToken::class, [ + 'token' => $orphanToken, + 'type' => msCustomerToken::TYPE_API, + ]); + if ($tokenObj) { + $tokenObj->remove(); + } + } + } + + if (isset($_SESSION['ms3'])) { + unset( + $_SESSION['ms3']['customer_id'], + $_SESSION['ms3']['customer_token'], + $_SESSION['ms3']['customer_token_expires'] + ); + } + CookieHelper::clearTokenCookie($this->modx); + + $guest = $tokenService->persistApiToken(0); + if (!$guest) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + '[AuthManager] logoutCurrentCustomer failed to mint anonymous token' + ); + return false; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + + return true; + } + + /** + * After API tokens were revoked, drop local PHP session/cookie if it belonged to that customer. + */ + public function invalidateLocalSessionForCustomer(msCustomer $customer): void + { + SessionHelper::ensureActive(); + + if ((int)($_SESSION['ms3']['customer_id'] ?? 0) !== (int)$customer->id) { + return; + } + + if (isset($_SESSION['ms3'])) { + unset( + $_SESSION['ms3']['customer_id'], + $_SESSION['ms3']['customer_token'], + $_SESSION['ms3']['customer_token_expires'] + ); + } + CookieHelper::clearTokenCookie($this->modx); + + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->get('ms3_token_service'); + $tokenService->persistApiToken(0); + + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + } + + /** + * Whether cart/draft may be moved from a previous API token onto the new login token. + * + * @param int $tokenCustomerId -1 when no token row exists + */ + public static function canTransferCartFromToken(int $tokenCustomerId, int $loggingInCustomerId): bool + { + if ($tokenCustomerId < 0) { + return false; + } + + return $tokenCustomerId === 0 || $tokenCustomerId === $loggingInCustomerId; + } + + /** + * @deprecated Use canTransferCartFromToken(); kept for call-site compatibility in tests. + */ + public static function canReuseApiToken(int $tokenCustomerId, int $loggingInCustomerId): bool + { + return self::canTransferCartFromToken($tokenCustomerId, $loggingInCustomerId); + } + + /** + * Canonical email normalization for lookup and storage. + */ + public static function normalizeEmail(string $email): string + { + return strtolower(trim($email)); + } + + /** + * Lookup customer by normalized email, then legacy mixed-case exact match. + */ + public function findCustomerByEmail(string $email): ?msCustomer + { + $normalized = self::normalizeEmail($email); + if ($normalized === '') { + return null; + } + + /** @var msCustomer|null $customer */ + $customer = $this->modx->getObject(msCustomer::class, ['email' => $normalized]); + if ($customer) { + return $customer; + } + + $raw = trim($email); + if ($raw !== '' && $raw !== $normalized) { + /** @var msCustomer|null $legacy */ + $legacy = $this->modx->getObject(msCustomer::class, ['email' => $raw]); + return $legacy ?: null; + } + + return null; + } + /** * Create token for customer * @@ -167,10 +421,17 @@ public function authenticate(array $credentials): ?msCustomer */ public function createToken(msCustomer $customer, string $type = 'api', int $ttl = 86400): ?msCustomerToken { + if ($type === msCustomerToken::TYPE_API) { + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->get('ms3_token_service'); + + return $tokenService->persistApiToken((int)$customer->id, null, $ttl); + } + /** @var msCustomerToken $token */ $token = $this->modx->newObject(msCustomerToken::class); - $tokenString = bin2hex(random_bytes(64)); + $tokenString = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', time() + $ttl); $token->set('customer_id', $customer->id); @@ -342,6 +603,22 @@ public function handleFailedLogin(msCustomer $customer): void ); } - $customer->save(); + if (!$customer->save()) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + "[AuthManager] Failed to persist failed_login_attempts for customer #{$customer->id}" + ); + } + } + + /** + * Record failed login by normalized/legacy email when the account exists. + */ + public function handleFailedLoginByEmail(string $email): void + { + $customer = $this->findCustomerByEmail($email); + if ($customer) { + $this->handleFailedLogin($customer); + } } } diff --git a/core/components/minishop3/src/Services/Customer/CustomerPageService.php b/core/components/minishop3/src/Services/Customer/CustomerPageService.php index d7f9792e..38a55518 100644 --- a/core/components/minishop3/src/Services/Customer/CustomerPageService.php +++ b/core/components/minishop3/src/Services/Customer/CustomerPageService.php @@ -4,6 +4,9 @@ use MiniShop3\MiniShop3; use MiniShop3\Model\msCustomer; +use MiniShop3\Services\TokenService; +use MiniShop3\Utils\CookieHelper; +use MiniShop3\Utils\SessionHelper; use MODX\Revolution\modX; use ModxPro\PdoTools\Fetch; @@ -66,6 +69,15 @@ public function __construct(modX $modx, MiniShop3 $ms3, array $scriptProperties */ public function checkAuth(): bool { + if ($this->modx->services->has('ms3_token_service')) { + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->get('ms3_token_service'); + $tokenService->ensureSessionActive(); + $tokenService->restoreSessionFromCookie(); + } else { + SessionHelper::ensureActive(); + } + if (empty($_SESSION['ms3']['customer_id'])) { $this->modx->log( modX::LOG_LEVEL_DEBUG, @@ -76,6 +88,21 @@ public function checkAuth(): bool $this->customerId = (int)$_SESSION['ms3']['customer_id']; + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->has('ms3_token_service') + ? $this->modx->services->get('ms3_token_service') + : null; + + if (!$tokenService || !$tokenService->sessionTokenBelongsToCustomer($this->customerId)) { + $this->clearAuthSession(); + $this->modx->log( + modX::LOG_LEVEL_WARN, + "[CustomerPageService] Session customer #{$this->customerId} does not match API token" + ); + $this->customerId = null; + return false; + } + $this->customer = $this->modx->getObject(msCustomer::class, $this->customerId); if (!$this->customer) { @@ -83,12 +110,44 @@ public function checkAuth(): bool modX::LOG_LEVEL_WARN, "[CustomerPageService] Customer #{$this->customerId} not found in database" ); + $this->clearAuthSession(); + $this->customerId = null; + return false; + } + + if (!$this->customer->get('is_active')) { + $this->clearAuthSession(); + $this->customerId = null; + $this->customer = null; return false; } + if ($this->customer->get('is_blocked')) { + $blockedUntil = $this->customer->get('blocked_until'); + if ($blockedUntil && strtotime((string)$blockedUntil) > time()) { + $this->clearAuthSession(); + $this->customerId = null; + $this->customer = null; + return false; + } + } + return true; } + private function clearAuthSession(): void + { + if (isset($_SESSION['ms3'])) { + unset( + $_SESSION['ms3']['customer_id'], + $_SESSION['ms3']['customer_token'], + $_SESSION['ms3']['customer_token_expires'] + ); + } + + CookieHelper::clearTokenCookie($this->modx); + } + /** * Get authenticated customer ID * diff --git a/core/components/minishop3/src/Services/Customer/RegisterService.php b/core/components/minishop3/src/Services/Customer/RegisterService.php index 546e12f7..1c98c1a9 100644 --- a/core/components/minishop3/src/Services/Customer/RegisterService.php +++ b/core/components/minishop3/src/Services/Customer/RegisterService.php @@ -82,7 +82,7 @@ public function setEmailVerification(EmailVerificationService $service): void */ public function register(array $data): array { - $email = trim($data['email'] ?? ''); + $email = AuthManager::normalizeEmail($data['email'] ?? ''); if (empty($email)) { return [ @@ -100,7 +100,15 @@ public function register(array $data): array ]; } - $existing = $this->modx->getObject(msCustomer::class, ['email' => $email]); + $existing = null; + if ($this->modx->services->has('ms3_auth_manager')) { + /** @var AuthManager $authManager */ + $authManager = $this->modx->services->get('ms3_auth_manager'); + $existing = $authManager->findCustomerByEmail($data['email'] ?? $email); + } + if (!$existing) { + $existing = $this->modx->getObject(msCustomer::class, ['email' => $email]); + } if ($existing) { return [ 'success' => false, diff --git a/core/components/minishop3/src/Services/Order/OrderDraftManager.php b/core/components/minishop3/src/Services/Order/OrderDraftManager.php index f7450608..61f689ca 100644 --- a/core/components/minishop3/src/Services/Order/OrderDraftManager.php +++ b/core/components/minishop3/src/Services/Order/OrderDraftManager.php @@ -506,18 +506,88 @@ public function bindDraftToCustomer(string $token, int $customerId, string $ctx 'context' => $ctx, ]); - if ($draft && empty($draft->get('customer_id'))) { - $draft->set('customer_id', $customerId); - $draft->save(); + if (!$draft) { + return true; + } + if (!empty($draft->get('customer_id'))) { + return true; + } + + $draft->set('customer_id', $customerId); + if (!$draft->save()) { $this->modx->log( - modX::LOG_LEVEL_INFO, - "[OrderDraftManager] Bound draft #{$draft->get('id')} to customer #{$customerId}" + modX::LOG_LEVEL_ERROR, + "[OrderDraftManager] Failed to bind draft #{$draft->get('id')} to customer #{$customerId}" ); + return false; + } + + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[OrderDraftManager] Bound draft #{$draft->get('id')} to customer #{$customerId}" + ); + + return true; + } + + /** + * Move a draft order from one session token to another and bind customer_id. + * + * Used after login token rotation so the cart survives while the old token is revoked. + */ + public function transferDraftToToken( + string $fromToken, + string $toToken, + int $customerId, + string $ctx = 'web' + ): bool { + if ($fromToken === '' || $toToken === '' || $customerId <= 0) { + return false; + } + + if ($fromToken === $toToken) { + return $this->bindDraftToCustomer($toToken, $customerId, $ctx); + } + + $statusDraft = (int)$this->modx->getOption('ms3_status_draft', null, 1) ?: 1; + $draft = $this->modx->getObject(msOrder::class, [ + 'token' => $fromToken, + 'status_id' => $statusDraft, + 'context' => $ctx, + ]); + + if (!$draft) { return true; } - return false; + $existingCustomerId = (int)$draft->get('customer_id'); + if ($existingCustomerId > 0 && $existingCustomerId !== $customerId) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + "[OrderDraftManager] Refused draft transfer #{$draft->get('id')} " + . "(owned by customer #{$existingCustomerId})" + ); + return false; + } + + $draft->set('customer_id', $customerId); + $draft->set('token', $toToken); + if (!$draft->save()) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + "[OrderDraftManager] Failed to transfer draft #{$draft->get('id')} " + . "to token for customer #{$customerId}" + ); + return false; + } + + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[OrderDraftManager] Transferred draft #{$draft->get('id')} to customer #{$customerId}" + ); + + return true; } } diff --git a/core/components/minishop3/src/Services/TokenService.php b/core/components/minishop3/src/Services/TokenService.php index caf850bb..45ed5c7c 100644 --- a/core/components/minishop3/src/Services/TokenService.php +++ b/core/components/minishop3/src/Services/TokenService.php @@ -4,6 +4,7 @@ use MiniShop3\Model\msCustomerToken; use MiniShop3\Utils\CookieHelper; +use MiniShop3\Utils\SessionHelper; use MODX\Revolution\modX; /** @@ -45,12 +46,11 @@ public function __construct(modX $modx) */ public function generateCustomerToken(?int $ttl = null): array { - $existingToken = $this->getCustomerToken(); - if ($existingToken) { - $expires = $_SESSION['ms3']['customer_token_expires'] ?? (time() + 86400); + $existingToken = $this->ensureCustomerTokenLoaded(); + if ($existingToken !== null) { + $expires = (int)($_SESSION['ms3']['customer_token_expires'] ?? (time() + 86400)); $lifetime = max(0, $expires - time()); - // Refresh cookie TTL CookieHelper::setTokenCookie($this->modx, $existingToken); return [ @@ -62,104 +62,146 @@ public function generateCustomerToken(?int $ttl = null): array $customerId = (int)($_SESSION['ms3']['customer_id'] ?? 0); - $token = bin2hex(random_bytes(32)); + $tokenObj = $this->persistApiToken($customerId, null, $ttl); + if (!$tokenObj) { + return ['token' => '', 'expires' => 0, 'lifetime' => 0]; + } + + $expires = (int)strtotime((string)$tokenObj->get('expires_at')); + + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[TokenService] Generated customer token for customer_id={$customerId}, expires: " + . $tokenObj->get('expires_at') + ); + + return [ + 'token' => (string)$tokenObj->get('token'), + 'expires' => $expires, + 'lifetime' => max(0, $expires - time()) * 1000, + ]; + } + + /** + * Persist an API token row and hydrate session/cookie from that DB row. + * + * Single mint/update path for guest, login bind, and logout anonymous token. + * + * @param int $customerId 0 = guest + * @param string|null $reuseToken update this token when present in DB; otherwise mint + */ + public function persistApiToken(int $customerId, ?string $reuseToken = null, ?int $ttl = null): ?msCustomerToken + { + SessionHelper::ensureActive(); if ($ttl === null) { $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); } $expiresAt = date('Y-m-d H:i:s', time() + $ttl); + $tokenObj = null; - $tokenObj = $this->modx->newObject(\MiniShop3\Model\msCustomerToken::class); - $tokenObj->set('customer_id', $customerId); - $tokenObj->set('token', $token); - $tokenObj->set('type', \MiniShop3\Model\msCustomerToken::TYPE_API); - $tokenObj->set('expires_at', $expiresAt); - $tokenObj->set('created_at', date('Y-m-d H:i:s')); + if ($reuseToken !== null && $reuseToken !== '') { + $tokenObj = $this->modx->getObject(msCustomerToken::class, [ + 'token' => $reuseToken, + 'type' => msCustomerToken::TYPE_API, + ]); + } + + if ($tokenObj) { + $tokenObj->set('customer_id', $customerId); + $tokenObj->set('expires_at', $expiresAt); + } else { + $tokenObj = $this->modx->newObject(msCustomerToken::class); + $tokenObj->set('customer_id', $customerId); + $tokenObj->set('token', bin2hex(random_bytes(32))); + $tokenObj->set('type', msCustomerToken::TYPE_API); + $tokenObj->set('expires_at', $expiresAt); + $tokenObj->set('created_at', date('Y-m-d H:i:s')); + } if (!$tokenObj->save()) { $this->modx->log( modX::LOG_LEVEL_ERROR, - "[TokenService] Failed to save token to database" + '[TokenService] Failed to persist API token' ); - return ['token' => '', 'expires' => 0, 'lifetime' => 0]; + return null; } - $_SESSION['ms3']['customer_token'] = $token; - $_SESSION['ms3']['customer_token_expires'] = time() + $ttl; + $this->syncSessionFromToken($tokenObj); - // Set httpOnly cookie - CookieHelper::setTokenCookie($this->modx, $token); - - $this->modx->log( - modX::LOG_LEVEL_INFO, - "[TokenService] Generated customer token for customer_id={$customerId}, expires: " . $expiresAt - ); + return $tokenObj; + } - return [ - 'token' => $token, - 'expires' => time() + $ttl, - 'lifetime' => $ttl * 1000, - ]; + /** + * Ensure PHP session is started before reading or writing $_SESSION. + */ + public function ensureSessionActive(): void + { + SessionHelper::ensureActive(); } /** - * Resolve existing token or create new one - * - * Resolution chain: - * 1. Session token → if valid, return - * 2. Cookie token → verify in DB (msCustomerToken type=api), restore session, return - * 3. Generate new token → set cookie + session, return + * Restore session from httpOnly ms3_token cookie without minting a new token. * - * @return string Token string + * @return bool True when a valid cookie token was found and session was hydrated */ - public function resolveOrCreateToken(): string + public function restoreSessionFromCookie(): bool { - // 1. Check session - $sessionToken = $this->getCustomerToken(); - if ($sessionToken) { - CookieHelper::setTokenCookie($this->modx, $sessionToken); - return $sessionToken; + if ($this->getCustomerToken()) { + return true; } - // 2. Check cookie $cookieToken = CookieHelper::getTokenFromCookie(); - if (!empty($cookieToken)) { - $tokenObj = $this->modx->getObject(msCustomerToken::class, [ - 'token' => $cookieToken, - 'type' => msCustomerToken::TYPE_API, - ]); + if ($cookieToken === '') { + return false; + } - if ($tokenObj) { - // Auto-renew expired token - if ($tokenObj->isExpired()) { - $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); - $tokenObj->set('expires_at', date('Y-m-d H:i:s', time() + $ttl)); - $tokenObj->save(); - } - - // Restore session - if (!isset($_SESSION['ms3'])) { - $_SESSION['ms3'] = []; - } - $_SESSION['ms3']['customer_token'] = $cookieToken; - $_SESSION['ms3']['customer_token_expires'] = strtotime($tokenObj->get('expires_at')); - - $customerId = (int)$tokenObj->get('customer_id'); - if ($customerId > 0) { - $_SESSION['ms3']['customer_id'] = $customerId; - } - - // Refresh cookie TTL - CookieHelper::setTokenCookie($this->modx, $cookieToken); - - return $cookieToken; - } + $tokenObj = $this->modx->getObject(msCustomerToken::class, [ + 'token' => $cookieToken, + 'type' => msCustomerToken::TYPE_API, + ]); + + if (!$tokenObj) { + return false; + } + + $this->syncSessionFromToken($tokenObj); + + return true; + } + + /** + * Resolve existing token or mint one (session → cookie restore → persistApiToken). + */ + public function resolveOrCreateToken(): string + { + return $this->generateCustomerToken()['token']; + } + + /** + * Token string for binding an authenticated customer (cookie → session, no minting). + */ + public function getBindableTokenString(): string + { + SessionHelper::ensureActive(); + + $cookieToken = CookieHelper::getTokenFromCookie(); + if ($cookieToken !== '') { + return $cookieToken; } - // 3. Generate new token - $result = $this->generateCustomerToken(); - return $result['token']; + return (string)($_SESSION['ms3']['customer_token'] ?? ''); + } + + /** + * Renew expired API token TTL and hydrate $_SESSION from DB row. + */ + public function syncSessionFromToken(msCustomerToken $tokenObj): void + { + $this->renewTokenIfExpired($tokenObj); + $this->applyTokenToSession($tokenObj); + CookieHelper::setTokenCookie($this->modx, $tokenObj->get('token')); } /** @@ -179,10 +221,29 @@ public function updateCustomerToken(string $token, ?int $ttl = null): array $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); } + SessionHelper::ensureActive(); + + $tokenObj = $this->modx->getObject(msCustomerToken::class, [ + 'token' => $token, + 'type' => msCustomerToken::TYPE_API, + ]); + + if (!$tokenObj) { + return $this->generateCustomerToken($ttl); + } + $expires = time() + $ttl; + $tokenObj->set('expires_at', date('Y-m-d H:i:s', $expires)); + if (!$tokenObj->save()) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + '[TokenService] Failed to extend token TTL in database' + ); + return ['token' => '', 'expires' => 0, 'lifetime' => 0]; + } - $_SESSION['ms3']['customer_token'] = $token; - $_SESSION['ms3']['customer_token_expires'] = $expires; + $this->applyTokenToSession($tokenObj); + CookieHelper::setTokenCookie($this->modx, $token); $this->modx->log( modX::LOG_LEVEL_DEBUG, @@ -216,8 +277,11 @@ public function getCustomerToken(): ?string "[TokenService] Customer token expired, clearing session" ); - unset($_SESSION['ms3']['customer_token']); - unset($_SESSION['ms3']['customer_token_expires']); + unset( + $_SESSION['ms3']['customer_token'], + $_SESSION['ms3']['customer_token_expires'], + $_SESSION['ms3']['customer_id'] + ); return null; } @@ -376,4 +440,107 @@ public function clearSnippetCache(?string $token = null): bool return $this->modx->cacheManager->clean($options); } } + + /** + * Load valid session/cookie token without minting a new one. + */ + private function ensureCustomerTokenLoaded(): ?string + { + SessionHelper::ensureActive(); + + $token = $this->getCustomerToken(); + if ($token !== null) { + return $token; + } + + $this->restoreSessionFromCookie(); + + return $this->getCustomerToken(); + } + + /** + * Whether session/cookie API token belongs to customer and is not expired. + * Renews TTL in DB when the row is past expires_at. + */ + public function sessionTokenBelongsToCustomer(int $customerId): bool + { + SessionHelper::ensureActive(); + + $token = (string)($_SESSION['ms3']['customer_token'] ?? ''); + if ($token === '') { + $token = CookieHelper::getTokenFromCookie(); + } + if ($token === '' || $customerId <= 0) { + return false; + } + + $tokenObj = $this->modx->getObject(msCustomerToken::class, [ + 'token' => $token, + 'type' => msCustomerToken::TYPE_API, + ]); + + if (!$tokenObj || (int)$tokenObj->get('customer_id') !== $customerId) { + return false; + } + + if ($tokenObj->isExpired() && !$this->renewTokenIfExpired($tokenObj)) { + return false; + } + + $this->applyTokenToSession($tokenObj); + CookieHelper::setTokenCookie($this->modx, (string)$tokenObj->get('token')); + + return true; + } + + /** + * @return bool false when the row is expired and TTL could not be persisted + */ + private function renewTokenIfExpired(msCustomerToken $tokenObj): bool + { + if (!$tokenObj->isExpired()) { + return true; + } + + $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); + $previousExpires = $tokenObj->get('expires_at'); + $tokenObj->set('expires_at', date('Y-m-d H:i:s', time() + $ttl)); + + if (!$tokenObj->save()) { + $tokenObj->set('expires_at', $previousExpires); + $this->modx->log( + modX::LOG_LEVEL_ERROR, + '[TokenService] Failed to renew expired token TTL in database' + ); + return false; + } + + return true; + } + + /** + * Write token row into $_SESSION. Guest tokens clear authenticated customer_id. + */ + private function applyTokenToSession(msCustomerToken $tokenObj): void + { + SessionHelper::ensureActive(); + + if (!isset($_SESSION['ms3'])) { + $_SESSION['ms3'] = []; + } + + $_SESSION['ms3']['customer_token'] = $tokenObj->get('token'); + $_SESSION['ms3']['customer_token_expires'] = strtotime($tokenObj->get('expires_at')); + + $tokenCustomerId = (int)$tokenObj->get('customer_id'); + $_SESSION['ms3']['customer_id'] = self::sessionCustomerIdFromTokenRow($tokenCustomerId); + } + + /** + * Session customer_id derived from an API token row (guest clears auth). + */ + public static function sessionCustomerIdFromTokenRow(int $tokenCustomerId): int + { + return $tokenCustomerId > 0 ? $tokenCustomerId : 0; + } } diff --git a/core/components/minishop3/src/Utils/SessionHelper.php b/core/components/minishop3/src/Utils/SessionHelper.php new file mode 100644 index 00000000..bdd9ee96 --- /dev/null +++ b/core/components/minishop3/src/Utils/SessionHelper.php @@ -0,0 +1,22 @@ +