Skip to content

Commit e43962b

Browse files
authored
Merge pull request #124 from modx-pro/dev/new-auth
fix(auth): httpOnly cookie token architecture
2 parents ed61019 + eec04f8 commit e43962b

File tree

17 files changed

+601
-187
lines changed

17 files changed

+601
-187
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656

5757
#### 🐛 Исправлено
5858

59+
- **httpOnly cookie token architecture (#124):** единый httpOnly cookie `ms3_token` вместо 4 несинхронизированных хранилищ. Middleware injection для обратной совместимости. Корзина сохраняется при логине/регистрации.
5960
- Исправлены неточности в лексиконах (Issue #21)
6061
- Удалён `action` из конфигурации меню miniShop3 (#94)
6162
- Очистка EAV-опций из формы товара
@@ -66,6 +67,10 @@
6667
- Корректные дефолтные ID статусов заказов с fallback для нулевых значений
6768
- `getIterator` для msProduct/msCategory — добавлен `class_key` в критерии
6869

70+
#### ⚠️ Breaking changes
71+
72+
- **Register.php response format (#124):** поле `token` изменено с объекта `{token, expires_at}` на строку. `expires_at` вынесен на верхний уровень ответа. Кастомные темы, обращающиеся к `result.object.token.token`, потребуют обновления.
73+
6974
#### 🔧 Изменено
7075

7176
- Удалены избыточные проверки прав в `initialize()` процессоров (#95)

assets/components/minishop3/js/web/core/ApiClient.js

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* HTTP client for MiniShop3 REST API
33
*
44
* Simple wrapper over fetch() for backend API interaction.
5-
* Automatically adds authorization token and handles JSON.
5+
* Token is sent automatically via httpOnly cookie (credentials: 'same-origin').
66
* Handles token refresh on 401 errors.
77
*
88
* @example
@@ -38,19 +38,15 @@ class ApiClient {
3838

3939
url.searchParams.set('route', endpoint)
4040

41-
const token = this.tokenManager.getToken()
42-
if (token) {
43-
url.searchParams.set('ms3_token', token)
44-
}
45-
4641
const headers = {
4742
Accept: 'application/json',
4843
'X-Requested-With': 'XMLHttpRequest'
4944
}
5045

5146
const options = {
5247
method,
53-
headers
48+
headers,
49+
credentials: 'same-origin'
5450
}
5551

5652
if (data && (method === 'POST' || method === 'PATCH' || method === 'PUT')) {
@@ -66,10 +62,9 @@ class ApiClient {
6662
const response = await fetch(url.toString(), options)
6763
const result = await response.json()
6864

69-
// Handle token errors: clear invalid token, get new one, and retry
65+
// Handle token errors: request new token from server and retry
7066
if (!isRetry && response.status === 401 && this.isTokenError(result)) {
7167
console.log('[ApiClient] Token invalid, refreshing and retrying request')
72-
this.tokenManager.removeToken()
7368
await this.tokenManager.fetchNewToken()
7469
return this.request(method, endpoint, data, true)
7570
}
Lines changed: 41 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
11
/**
2-
* Customer token manager
2+
* Customer token manager (httpOnly cookie mode)
33
*
4-
* Manages customer authorization token:
5-
* - Storage in localStorage
6-
* - Expiry validation
7-
* - Automatic token retrieval when missing/expired
4+
* Token is stored in httpOnly cookie by the server.
5+
* JS cannot read it directly — it's sent automatically with every request.
6+
* This class handles initialization and legacy localStorage cleanup.
87
*
98
* @example
109
* const tokenManager = new TokenManager({ tokenName: 'ms3_token' })
1110
* await tokenManager.ensureToken()
12-
* const token = tokenManager.getToken()
1311
*/
1412
class TokenManager {
1513
/**
1614
* @param {Object} config - Configuration
17-
* @param {string} config.tokenName - Key for storing token in localStorage
15+
* @param {string} config.tokenName - Legacy key (for localStorage cleanup)
1816
*/
1917
constructor (config) {
2018
this.tokenName = config.tokenName || 'ms3_token'
2119
this.apiClient = null
20+
this.tokenInitialized = false
21+
22+
// Clean up legacy localStorage on construction
23+
this.cleanupLegacyStorage()
2224
}
2325

2426
/**
@@ -31,78 +33,55 @@ class TokenManager {
3133
}
3234

3335
/**
34-
* Get token from localStorage
36+
* Get token — always returns null (httpOnly cookie, not accessible from JS)
3537
*
36-
* @returns {string|null} - Token or null if missing/expired
38+
* @returns {null}
3739
*/
3840
getToken () {
39-
const tokenData = this.getTokenData()
40-
return tokenData ? tokenData.token : null
41+
return null
4142
}
4243

4344
/**
44-
* Get full token data (token + expiry)
45+
* Get full token data — always returns null (httpOnly cookie)
4546
*
46-
* @returns {Object|null} - { token: string, expiry: number } or null
47+
* @returns {null}
4748
*/
4849
getTokenData () {
49-
const stored = localStorage.getItem(this.tokenName)
50-
if (!stored) {
51-
return null
52-
}
53-
54-
try {
55-
const data = JSON.parse(stored)
56-
const now = Date.now()
57-
58-
if (now > data.expiry) {
59-
this.removeToken()
60-
return null
61-
}
62-
63-
return data
64-
} catch (e) {
65-
this.removeToken()
66-
return null
67-
}
50+
return null
6851
}
6952

7053
/**
71-
* Save token to localStorage
54+
* Set token — no-op (token is managed by server via httpOnly cookie)
7255
*
73-
* @param {string} token - Token
74-
* @param {number} lifetime - Token lifetime in seconds
56+
* @param {string} _token - Unused
57+
* @param {number} _lifetime - Unused
7558
*/
76-
setToken (token, lifetime) {
77-
const data = {
78-
token,
79-
expiry: Date.now() + (lifetime * 1000)
80-
}
81-
localStorage.setItem(this.tokenName, JSON.stringify(data))
59+
setToken (_token, _lifetime) {
60+
// No-op: token is in httpOnly cookie, managed by server
8261
}
8362

8463
/**
85-
* Remove token from localStorage
64+
* Remove token — cleans up legacy localStorage only
8665
*/
8766
removeToken () {
88-
localStorage.removeItem(this.tokenName)
67+
this.cleanupLegacyStorage()
8968
}
9069

9170
/**
92-
* Check for valid token, fetch new if needed
71+
* Ensure token cookie exists by requesting from server if needed
9372
*
9473
* @returns {Promise<void>}
9574
*/
9675
async ensureToken () {
97-
if (this.getToken()) {
76+
if (this.tokenInitialized) {
9877
return
9978
}
10079

10180
await this.fetchNewToken()
10281
}
10382

10483
/**
105-
* Fetch new token from server
84+
* Fetch new token from server (server sets httpOnly cookie)
10685
*
10786
* @returns {Promise<void>}
10887
*/
@@ -118,6 +97,7 @@ class TokenManager {
11897

11998
const response = await fetch(url.toString(), {
12099
method: 'GET',
100+
credentials: 'same-origin',
121101
headers: {
122102
Accept: 'application/json',
123103
'X-Requested-With': 'XMLHttpRequest'
@@ -126,8 +106,8 @@ class TokenManager {
126106

127107
const result = await response.json()
128108

129-
if (result.success && result.data) {
130-
this.setToken(result.data.token, result.data.lifetime)
109+
if (result.success) {
110+
this.tokenInitialized = true
131111
} else {
132112
console.error('TokenManager: Failed to get token', result)
133113
}
@@ -150,11 +130,22 @@ class TokenManager {
150130
try {
151131
const response = await this.apiClient.post('/customer/token/update')
152132

153-
if (response.success && response.data) {
154-
this.setToken(response.data.token, response.data.lifetime)
133+
if (response.success) {
134+
this.tokenInitialized = true
155135
}
156136
} catch (error) {
157137
console.error('TokenManager: Error refreshing token', error)
158138
}
159139
}
140+
141+
/**
142+
* Remove legacy localStorage data
143+
*/
144+
cleanupLegacyStorage () {
145+
try {
146+
localStorage.removeItem(this.tokenName)
147+
} catch (e) {
148+
// Ignore storage errors
149+
}
150+
}
160151
}

assets/components/minishop3/js/web/modules/auth-forms.js

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,6 @@ class AuthForms {
9898
if (result.success) {
9999
this.showMessage('login-messages', this.getLexicon('ms3_customer_login_success'), 'success')
100100

101-
if (result.object && result.object.token) {
102-
this.saveToken(result.object.token)
103-
}
104-
105101
setTimeout(() => {
106102
this.handleRedirect(result.object)
107103
}, 1000)
@@ -163,7 +159,7 @@ class AuthForms {
163159
)
164160

165161
if (result.object && result.object.token) {
166-
this.saveToken(result.object.token)
162+
// Auto-login: redirect to account page
167163
setTimeout(() => {
168164
this.handleRedirect(result.object)
169165
}, 1500)
@@ -208,39 +204,37 @@ class AuthForms {
208204
}
209205

210206
/**
211-
* Save authorization token
207+
* Save authorization token — no-op (httpOnly cookie managed by server)
208+
* Cleans up legacy localStorage.
212209
*
213-
* @param {string} token - API token
210+
* @param {string} _token - Unused
214211
*/
215-
saveToken (token) {
216-
if (!token) return
217-
218-
localStorage.setItem('ms3_token', token)
219-
220-
if (window.ms3 && window.ms3.config) {
221-
window.ms3.config.token = token
212+
saveToken (_token) {
213+
// Clean up legacy localStorage
214+
try {
215+
localStorage.removeItem('ms3_token')
216+
} catch (e) {
217+
// Ignore
222218
}
223-
224-
console.log('[AuthForms] Token saved, length:', token.length)
225219
}
226220

227221
/**
228-
* Get saved token
222+
* Get saved token — returns null (httpOnly cookie, not accessible from JS)
229223
*
230-
* @returns {string|null} - Token or null
224+
* @returns {null}
231225
*/
232226
getToken () {
233-
return localStorage.getItem('ms3_token')
227+
return null
234228
}
235229

236230
/**
237-
* Remove token (on logout)
231+
* Remove token (on logout) — cleans up legacy localStorage
238232
*/
239233
clearToken () {
240-
localStorage.removeItem('ms3_token')
241-
242-
if (window.ms3 && window.ms3.config) {
243-
window.ms3.config.token = null
234+
try {
235+
localStorage.removeItem('ms3_token')
236+
} catch (e) {
237+
// Ignore
244238
}
245239
}
246240

@@ -257,6 +251,7 @@ class AuthForms {
257251

258252
const response = await fetch(url.toString(), {
259253
method: 'POST',
254+
credentials: 'same-origin',
260255
headers: {
261256
'Content-Type': 'application/json',
262257
Accept: 'application/json'

core/components/minishop3/elements/snippets/ms3_cart.php

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,11 @@
2727
// Load lexicons for template
2828
$modx->lexicon->load('minishop3:cart');
2929

30-
if (!empty($scriptProperties['customer_token'])) {
31-
$token = $scriptProperties['customer_token'];
32-
} elseif (!empty($_SESSION['ms3']) && !empty($_SESSION['ms3']['customer_token'])) {
33-
$token = $_SESSION['ms3']['customer_token'];
34-
} else {
35-
$response = $ms3->customer->generateToken();
36-
$token = $response['data']['token'];
37-
}
30+
/** @var \MiniShop3\Services\TokenService $tokenService */
31+
$tokenService = $modx->services->get('ms3_token_service');
32+
$token = !empty($scriptProperties['customer_token'])
33+
? $scriptProperties['customer_token']
34+
: $tokenService->resolveOrCreateToken();
3835
if (!empty($_GET['msorder'])) {
3936
return '';
4037
}

core/components/minishop3/elements/snippets/ms3_customer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use MiniShop3\Services\Customer\OrdersPageService;
77
use ModxPro\PdoTools\Fetch;
88

9-
/** @var modX $modx */
9+
/** @var \MODX\Revolution\modX $modx */
1010
/** @var array $scriptProperties */
1111
/** @var MiniShop3 $ms3 */
1212

core/components/minishop3/elements/snippets/ms3_get_order.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@
5656
return $modx->lexicon('ms3_err_order_nf');
5757
}
5858
$customerId = null;
59-
if (!empty($_SESSION['ms3']) && !empty($_SESSION['ms3']['customer_token'])) {
60-
$token = $_SESSION['ms3']['customer_token'];
59+
/** @var \MiniShop3\Services\TokenService $tokenService */
60+
$tokenService = $modx->services->get('ms3_token_service');
61+
$token = $tokenService->resolveOrCreateToken();
62+
if (!empty($token)) {
6163
$customer = $ms3->customer->getByToken($token);
6264
if (!empty($customer)) {
6365
$customerId = $customer->get('id');

core/components/minishop3/elements/snippets/ms3_order.php

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,9 @@
2222
$ms3 = $modx->services->get('ms3');
2323
$ms3->initialize($modx->context->key);
2424

25-
if (!empty($_SESSION['ms3']) && !empty($_SESSION['ms3']['customer_token'])) {
26-
$token = $_SESSION['ms3']['customer_token'];
27-
} else {
28-
$response = $ms3->customer->generateToken();
29-
$token = $response['data']['token'];
30-
}
25+
/** @var \MiniShop3\Services\TokenService $tokenService */
26+
$tokenService = $modx->services->get('ms3_token_service');
27+
$token = $tokenService->resolveOrCreateToken();
3128

3229
/** @var Fetch $pdoFetch */
3330
$pdoFetch = $modx->services->get(Fetch::class);

core/components/minishop3/elements/snippets/ms3_order_total.php

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,9 @@
1616
$ms3->initialize($modx->context->key);
1717
$ms3->registerSnippet($scriptProperties, 'msOrderTotal');
1818

19-
if (!empty($_SESSION['ms3']) && !empty($_SESSION['ms3']['customer_token'])) {
20-
$token = $_SESSION['ms3']['customer_token'];
21-
} else {
22-
$response = $ms3->customer->generateToken();
23-
$token = $response['data']['token'];
24-
}
19+
/** @var \MiniShop3\Services\TokenService $tokenService */
20+
$tokenService = $modx->services->get('ms3_token_service');
21+
$token = $tokenService->resolveOrCreateToken();
2522

2623
/** @var Fetch $pdoFetch */
2724
$pdoFetch = $modx->services->get(Fetch::class);

0 commit comments

Comments
 (0)