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 @@
+