work-license-server 1.0.16 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/admin.js CHANGED
@@ -16,28 +16,44 @@
16
16
  let activeTab = 'servers';
17
17
  let admCountdownTimer = null;
18
18
 
19
+ function readCookieSession() {
20
+ try {
21
+ const m = String(document.cookie || '').match(/(?:^|;\s*)odant_work_session=([^;]+)/);
22
+ return m?.[1] ? decodeURIComponent(m[1].trim()) : '';
23
+ }
24
+ catch { return ''; }
25
+ }
26
+
19
27
  function getStoredToken() {
20
28
  try {
21
- return sessionStorage.getItem(TOKEN_KEY)
29
+ return localStorage.getItem('odant_work_session')
22
30
  || localStorage.getItem(TOKEN_KEY)
23
- || localStorage.getItem('odant_work_session')
31
+ || sessionStorage.getItem(TOKEN_KEY)
32
+ || sessionStorage.getItem('odant_work_session')
33
+ || readCookieSession()
24
34
  || '';
25
35
  }
26
- catch { return ''; }
36
+ catch { return readCookieSession(); }
27
37
  }
28
- function setStoredToken(tok) {
38
+ function setStoredToken(tok, email = '') {
29
39
  adminToken = tok || '';
30
40
  try {
31
41
  if (tok) {
32
42
  sessionStorage.setItem(TOKEN_KEY, tok);
33
43
  localStorage.setItem(TOKEN_KEY, tok);
34
- if (tok.startsWith('wks_')) {
35
- localStorage.setItem('odant_work_session', tok);
36
- }
44
+ localStorage.setItem('odant_work_session', tok);
45
+ sessionStorage.setItem('odant_work_session', tok);
46
+ document.cookie = `odant_work_session=${encodeURIComponent(tok)}; path=/; max-age=31536000; SameSite=Lax`;
37
47
  }
38
48
  else {
39
49
  sessionStorage.removeItem(TOKEN_KEY);
40
50
  localStorage.removeItem(TOKEN_KEY);
51
+ localStorage.removeItem('odant_work_session');
52
+ sessionStorage.removeItem('odant_work_session');
53
+ document.cookie = 'odant_work_session=; path=/; max-age=0; SameSite=Lax';
54
+ }
55
+ if (email) {
56
+ localStorage.setItem('odant_work_email', email);
41
57
  }
42
58
  }
43
59
  catch { /* приватный режим */ }
@@ -78,6 +94,13 @@
78
94
  $('#adminLoginScreen').style.display = '';
79
95
  $('#adminAppShell').style.display = 'none';
80
96
  if ($('#admLoginError')) $('#admLoginError').textContent = errMsg;
97
+ try {
98
+ const savedEmail = localStorage.getItem('odant_work_email') || '';
99
+ if (savedEmail && $('#admLoginEmail') && !$('#admLoginEmail').value) {
100
+ $('#admLoginEmail').value = savedEmail;
101
+ }
102
+ }
103
+ catch { /* ignore */ }
81
104
  setTimeout(() => $('#admLoginEmail')?.focus(), 50);
82
105
  }
83
106
 
@@ -102,6 +125,7 @@
102
125
  $('#adminLoginScreen').style.display = 'none';
103
126
  $('#adminAppShell').style.display = '';
104
127
  if ($('#admUserLabel')) $('#admUserLabel').textContent = login;
128
+ if ($('#admProfileEmail')) $('#admProfileEmail').textContent = login;
105
129
  requestAnimationFrame(moveIndicator);
106
130
  }
107
131
 
@@ -165,7 +189,7 @@
165
189
  if (!user.isAdmin) {
166
190
  throw new Error(`Аккаунт ${user.email} не обладает правами администратора (BOSS) сервера лицензирования.`);
167
191
  }
168
- setStoredToken(user.sessionKey);
192
+ setStoredToken(user.sessionKey, user.email);
169
193
  resetAdmStep();
170
194
  showAdminApp(user.email);
171
195
  await refreshRegistry();
@@ -179,9 +203,9 @@
179
203
  }
180
204
 
181
205
  async function logoutAdmin() {
206
+ $('#admProfileModal')?.classList.remove('open');
182
207
  try { await api('/api/admin/logout', {}); } catch { /* ignore */ }
183
208
  setStoredToken('');
184
- try { localStorage.removeItem('odant_work_session'); } catch { /* ignore */ }
185
209
  resetAdmStep();
186
210
  showLoginGate('');
187
211
  }
@@ -196,6 +220,7 @@
196
220
  try {
197
221
  const me = await api('/api/admin/me');
198
222
  if (me.authenticated) {
223
+ setStoredToken(saved, me.email || me.login || '');
199
224
  showAdminApp(me.login || 'admin');
200
225
  await refreshRegistry();
201
226
  }
@@ -900,6 +925,8 @@
900
925
  case 'adm-send-code': sendAdminOtpCode(); break;
901
926
  case 'adm-verify-code': verifyAdminOtpCode(); break;
902
927
  case 'adm-reset-step': resetAdmStep(); break;
928
+ case 'open-adm-profile': $('#admProfileModal')?.classList.add('open'); break;
929
+ case 'close-adm-profile': $('#admProfileModal')?.classList.remove('open'); break;
903
930
  case 'admin-logout': logoutAdmin(); break;
904
931
  case 'registry': refreshRegistry(); break;
905
932
  case 'open-create-server-modal': $('#admCreateServerModal')?.classList.add('open'); break;
package/assets/landing.js CHANGED
@@ -129,12 +129,47 @@
129
129
  card.style.setProperty('--my', (e.clientY - r.top) + 'px');
130
130
  }, { passive: true });
131
131
 
132
- /* ================= Вход / Регистрация (Email, Мессенджеры, OAuth) ================= */
132
+ /* ================= Вход / Регистрация и постоянная сессия в браузере ================= */
133
+ function readCookieSession() {
134
+ try {
135
+ const m = String(document.cookie || '').match(/(?:^|;\s*)odant_work_session=([^;]+)/);
136
+ return m?.[1] ? decodeURIComponent(m[1].trim()) : '';
137
+ }
138
+ catch { return ''; }
139
+ }
140
+ function persistSessionInBrowser(sessionKey = '', email = '', isAdmin = false) {
141
+ try {
142
+ if (sessionKey) {
143
+ localStorage.setItem('odant_work_session', sessionKey);
144
+ sessionStorage.setItem('odant_work_session', sessionKey);
145
+ if (isAdmin) {
146
+ localStorage.setItem('odant_work_admin_token', sessionKey);
147
+ sessionStorage.setItem('odant_work_admin_token', sessionKey);
148
+ }
149
+ document.cookie = `odant_work_session=${encodeURIComponent(sessionKey)}; path=/; max-age=31536000; SameSite=Lax`;
150
+ }
151
+ if (email) {
152
+ localStorage.setItem('odant_work_email', email);
153
+ }
154
+ }
155
+ catch { /* приватный режим */ }
156
+ }
157
+ function clearSessionFromBrowser() {
158
+ try {
159
+ localStorage.removeItem('odant_work_session');
160
+ localStorage.removeItem('odant_work_admin_token');
161
+ sessionStorage.removeItem('odant_work_session');
162
+ sessionStorage.removeItem('odant_work_admin_token');
163
+ document.cookie = 'odant_work_session=; path=/; max-age=0; SameSite=Lax';
164
+ }
165
+ catch { /* ignore */ }
166
+ }
167
+
133
168
  function openAuth(hintMsg = '', intentTitle = '') {
134
169
  if ($('#authErrorMsg')) $('#authErrorMsg').textContent = hintMsg || '';
135
170
  const intentBox = $('#authIntentNotice');
136
171
  if (intentBox) {
137
- if (intentTitle) {
172
+ if (intentTitle && !currentUser) {
138
173
  intentBox.style.display = '';
139
174
  intentBox.innerHTML = `Выбранная позиция: <b>${esc(intentTitle)}</b>.<br>После входа она автоматически появится как заказ в вашем Личном кабинете.`;
140
175
  }
@@ -143,9 +178,36 @@
143
178
  intentBox.textContent = '';
144
179
  }
145
180
  }
146
- if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = currentUser ? '' : 'none';
181
+ if (currentUser) {
182
+ if ($('#authTitle')) $('#authTitle').textContent = 'Ваш аккаунт';
183
+ if ($('#authSub')) $('#authSub').textContent = 'Сессия сохранена в браузере';
184
+ if ($('#authStepLoggedIn')) $('#authStepLoggedIn').style.display = '';
185
+ if ($('#authStepEmail')) $('#authStepEmail').style.display = 'none';
186
+ if ($('#authStepCode')) $('#authStepCode').style.display = 'none';
187
+ if ($('#authLoggedEmail')) $('#authLoggedEmail').textContent = currentUser.email || '';
188
+ if ($('#authLoggedRoleBadge')) {
189
+ $('#authLoggedRoleBadge').innerHTML = currentUser.isAdmin
190
+ ? '<span class="tag ok">BOSS · Главный администратор</span>'
191
+ : '<span class="tag">Клиент ODANT-WORK</span>';
192
+ }
193
+ if ($('#btnGoAdminModal')) $('#btnGoAdminModal').style.display = currentUser.isAdmin ? '' : 'none';
194
+ }
195
+ else {
196
+ if ($('#authTitle')) $('#authTitle').textContent = 'Вход / Регистрация';
197
+ if ($('#authSub')) $('#authSub').textContent = 'На ваш Email придёт одноразовый 6-значный код подтверждения';
198
+ if ($('#authStepLoggedIn')) $('#authStepLoggedIn').style.display = 'none';
199
+ if ($('#authStepEmail')) $('#authStepEmail').style.display = '';
200
+ if ($('#authStepCode')) $('#authStepCode').style.display = 'none';
201
+ try {
202
+ const savedEmail = localStorage.getItem('odant_work_email') || '';
203
+ if (savedEmail && $('#authEmail') && !$('#authEmail').value) {
204
+ $('#authEmail').value = savedEmail;
205
+ }
206
+ }
207
+ catch { /* ignore */ }
208
+ setTimeout(() => $('#authEmail')?.focus(), 50);
209
+ }
147
210
  $('#authModal')?.classList.add('open');
148
- setTimeout(() => $('#authEmail')?.focus(), 50);
149
211
  }
150
212
  function closeAuth() {
151
213
  $('#authModal')?.classList.remove('open');
@@ -224,7 +286,7 @@
224
286
  try {
225
287
  const data = await api('/api/auth/verify-code', { email, code });
226
288
  currentUser = data;
227
- try { localStorage.setItem('odant_work_session', data.sessionKey); } catch { /* приватный режим */ }
289
+ persistSessionInBrowser(data.sessionKey, data.email, data.isAdmin);
228
290
  resetAuthStep();
229
291
  onAuthenticated();
230
292
  closeAuth();
@@ -245,18 +307,14 @@
245
307
  }
246
308
  }
247
309
 
248
- function performLogout() {
310
+ async function performLogout() {
311
+ try { await api('/api/auth/logout', {}); } catch { /* ignore */ }
249
312
  currentUser = null;
250
313
  pendingAuthAction = null;
251
- try {
252
- localStorage.removeItem('odant_work_session');
253
- localStorage.removeItem('odant_work_admin_token');
254
- sessionStorage.removeItem('odant_work_admin_token');
255
- } catch { /* ignore */ }
314
+ clearSessionFromBrowser();
256
315
  if ($('#tileAddons')) $('#tileAddons').style.display = 'none';
257
316
  if ($('#tileAdmin')) $('#tileAdmin').style.display = 'none';
258
317
  if ($('#btnAuthText')) $('#btnAuthText').textContent = 'Войти / Регистрация';
259
- if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = 'none';
260
318
  closeAuth();
261
319
  if (currentStage === 'addons') switchStage('product');
262
320
  moveIndicator();
@@ -264,17 +322,25 @@
264
322
 
265
323
  async function restoreSession() {
266
324
  let key = '';
267
- try { key = localStorage.getItem('odant_work_session') || ''; } catch { /* приватный режим */ }
325
+ try {
326
+ key = localStorage.getItem('odant_work_session')
327
+ || sessionStorage.getItem('odant_work_session')
328
+ || localStorage.getItem('odant_work_admin_token')
329
+ || readCookieSession()
330
+ || '';
331
+ }
332
+ catch { key = readCookieSession(); }
268
333
  if (!key) return;
269
334
  try {
270
335
  const data = await api('/api/auth/me?sessionKey=' + encodeURIComponent(key));
271
336
  if (data.authenticated && data.user) {
272
- currentUser = { ...data.user, sessionKey: key };
337
+ currentUser = { ...data.user, sessionKey: data.user.sessionKey || key };
338
+ persistSessionInBrowser(currentUser.sessionKey, currentUser.email, currentUser.isAdmin);
273
339
  onAuthenticated();
274
340
  if (location.hash === '#addons' && isUserReadyForOrders()) switchStage('addons', { scroll: false });
275
341
  }
276
342
  }
277
- catch { /* сессия устарела */ }
343
+ catch { /* сессия отозвана */ }
278
344
  }
279
345
 
280
346
  let franchiseCountdownTimer = null;
@@ -1688,14 +1754,12 @@
1688
1754
  if (el.dataset.stage && el !== document.body) { e.preventDefault(); switchStage(el.dataset.stage); return; }
1689
1755
  switch (el.dataset.action) {
1690
1756
  case 'auth':
1691
- if (isUserReadyForOrders() && currentStage !== 'addons') {
1692
- switchStage('addons');
1693
- }
1694
- else {
1695
- pendingAuthAction = null;
1696
- resetAuthStep();
1697
- openAuth();
1698
- }
1757
+ pendingAuthAction = null;
1758
+ openAuth();
1759
+ break;
1760
+ case 'go-cabinet':
1761
+ closeAuth();
1762
+ switchStage('addons');
1699
1763
  break;
1700
1764
  case 'auth-close': closeAuth(); break;
1701
1765
  case 'send-code': sendAuthCode(); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/admin.js CHANGED
@@ -115,7 +115,7 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
115
115
 
116
116
  <div class="top-actions">
117
117
  <button class="icon-btn" id="btnTheme" title="Светлая / тёмная тема" aria-label="Переключить тему">${icon('sun')}</button>
118
- <button class="btn btn-primary btn-sm" data-action="admin-logout" title="Нажмите, чтобы выйти из сессии">${icon('user')}<span id="admUserLabel">BOSS</span></button>
118
+ <button class="btn btn-primary btn-sm" data-action="open-adm-profile" title="Аккаунт администратора">${icon('user')}<span id="admUserLabel">BOSS</span></button>
119
119
  </div>
120
120
  </header>
121
121
 
@@ -402,6 +402,30 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
402
402
  </div>
403
403
  </div>
404
404
 
405
+ <!-- Модальное окно: Аккаунт администратора (защита от случайного выхода) -->
406
+ <div class="modal" id="admProfileModal" role="dialog" aria-modal="true">
407
+ <div class="card" style="max-width:440px">
408
+ <div class="modal-head">
409
+ <img src="/assets/odant-logo.png" alt="">
410
+ <div>
411
+ <h3 class="h3" style="margin:0">Аккаунт администратора</h3>
412
+ <div class="small muted">Сессия активна и сохранена в браузере</div>
413
+ </div>
414
+ <button class="icon-btn" data-action="close-adm-profile" aria-label="Закрыть">${icon('close')}</button>
415
+ </div>
416
+ <div style="padding:14px 16px;border-radius:12px;border:1px solid var(--line-strong);background:var(--field);margin-bottom:14px">
417
+ <div class="small muted">Вы вошли как</div>
418
+ <div style="font-size:16px;font-weight:700;margin-top:2px" id="admProfileEmail">—</div>
419
+ <div style="margin-top:6px"><span class="tag ok">BOSS · Главный администратор</span></div>
420
+ </div>
421
+ <div style="display:flex;flex-direction:column;gap:8px">
422
+ <a class="btn btn-primary" href="/#addons" style="width:100%;text-decoration:none">Перейти в Личный кабинет</a>
423
+ <button type="button" class="btn btn-ghost" style="width:100%;color:var(--danger)" data-action="admin-logout">Выйти из аккаунта</button>
424
+ <button type="button" class="btn btn-ghost btn-sm" style="width:100%" data-action="close-adm-profile">Закрыть</button>
425
+ </div>
426
+ </div>
427
+ </div>
428
+
405
429
  <script>window.__ODANT_ADMIN__ = ${jsonForScript(adminData)};</script>
406
430
  <script src="/assets/admin.js${v}" defer></script>
407
431
  </body>
package/src/crypto.js CHANGED
@@ -381,3 +381,75 @@ export function verifyOwnerAccessToken(tokenStr = '', trustedPublicKeyB64 = '')
381
381
  return false;
382
382
  }
383
383
  }
384
+
385
+ export const SESSION_TOKEN_PREFIX = 'wks_1';
386
+
387
+ /**
388
+ * Сгенерировать бессрочный подписанный токен сессии браузера (wks_1.<payload_b64url>.<sig_b64url>),
389
+ * который не сбрасывается при перезапуске/обновлении контейнера сервера, пока пользователь сам не нажал «Выйти».
390
+ */
391
+ export function createSessionToken({
392
+ userId = '',
393
+ email = '',
394
+ name = '',
395
+ companyName = '',
396
+ inn = '',
397
+ isAdmin = false,
398
+ issuedAt = Date.now(),
399
+ } = {}, authorityKeys) {
400
+ const payloadObj = {
401
+ v: 1,
402
+ userId: String(userId || ''),
403
+ email: String(email || '').toLowerCase(),
404
+ ...(name ? { name: String(name) } : {}),
405
+ ...(companyName ? { companyName: String(companyName) } : {}),
406
+ ...(inn ? { inn: String(inn) } : {}),
407
+ isAdmin: Boolean(isAdmin),
408
+ issuedAt: Number(issuedAt || Date.now()),
409
+ };
410
+ const payloadB64 = Buffer.from(JSON.stringify(payloadObj), 'utf8').toString('base64url');
411
+ let sigB64 = '';
412
+ if (authorityKeys?.privateKey) {
413
+ const priv = crypto.createPrivateKey({
414
+ key: Buffer.from(String(authorityKeys.privateKey), 'base64'),
415
+ format: 'der',
416
+ type: 'pkcs8',
417
+ });
418
+ sigB64 = crypto.sign(null, Buffer.from(`${SESSION_TOKEN_PREFIX}.${payloadB64}`, 'utf8'), priv).toString('base64url');
419
+ }
420
+ return `${SESSION_TOKEN_PREFIX}.${payloadB64}.${sigB64}`;
421
+ }
422
+
423
+ /**
424
+ * Проверить подпись Ed25519 сессионного токена (wks_1.<payload>.<sig>) и вернуть payload.
425
+ */
426
+ export function verifySessionToken(tokenStr = '', trustedPublicKeyB64 = '') {
427
+ try {
428
+ const raw = String(tokenStr || '').trim();
429
+ const parts = raw.split('.');
430
+ if (parts.length !== 3 || parts[0] !== SESSION_TOKEN_PREFIX || !parts[2])
431
+ return null;
432
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
433
+ if (!payload?.email)
434
+ return null;
435
+ const trustedKeys = resolveTrustedAuthorityKeys(trustedPublicKeyB64);
436
+ const signedData = Buffer.from(`${SESSION_TOKEN_PREFIX}.${parts[1]}`, 'utf8');
437
+ const sigBuf = Buffer.from(parts[2], 'base64url');
438
+ for (const keyB64 of trustedKeys) {
439
+ try {
440
+ const pub = crypto.createPublicKey({
441
+ key: Buffer.from(String(keyB64), 'base64'),
442
+ format: 'der',
443
+ type: 'spki',
444
+ });
445
+ if (crypto.verify(null, signedData, pub, sigBuf))
446
+ return payload;
447
+ }
448
+ catch { /* try next */ }
449
+ }
450
+ return null;
451
+ }
452
+ catch {
453
+ return null;
454
+ }
455
+ }
package/src/landing.js CHANGED
@@ -498,6 +498,20 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
498
498
 
499
499
  <div id="authIntentNotice" class="small" style="display:none;padding:10px 12px;border-radius:10px;background:rgba(0,163,224,.12);border:1px solid rgba(0,163,224,.35);margin-bottom:4px"></div>
500
500
 
501
+ <!-- Карточка активной сессии (показывается, когда пользователь уже вошёл) -->
502
+ <div id="authStepLoggedIn" style="display:none">
503
+ <div style="padding:14px 16px;border-radius:12px;border:1px solid var(--line-strong);background:var(--field);margin-bottom:14px">
504
+ <div class="small muted">Активная сессия в браузере</div>
505
+ <div style="font-size:16px;font-weight:700;margin-top:2px" id="authLoggedEmail">—</div>
506
+ <div style="margin-top:6px" id="authLoggedRoleBadge"></div>
507
+ </div>
508
+ <div style="display:flex;flex-direction:column;gap:8px">
509
+ <button type="button" class="btn btn-primary" style="width:100%" data-action="go-cabinet">Перейти в Личный кабинет</button>
510
+ <a class="btn btn-ghost" id="btnGoAdminModal" href="/admin" style="width:100%;display:none;text-decoration:none">Открыть Админ-панель</a>
511
+ <button type="button" class="btn btn-ghost" style="width:100%;color:var(--danger)" data-action="logout">Выйти из аккаунта</button>
512
+ </div>
513
+ </div>
514
+
501
515
  <div id="authStepEmail">
502
516
  <div class="field">
503
517
  <label for="authEmail">Ваш Email *</label>
@@ -531,7 +545,6 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
531
545
 
532
546
  <div class="status" id="authErrorMsg" style="color:var(--danger);min-height:18px;margin-top:6px"></div>
533
547
  <div style="display:flex;gap:10px;margin-top:6px;justify-content:flex-end;flex-wrap:wrap">
534
- <button class="btn btn-ghost btn-sm" id="btnLogoutModal" data-action="logout" style="display:none">Выйти из аккаунта</button>
535
548
  <button class="btn btn-ghost btn-sm" data-action="auth-close">Закрыть</button>
536
549
  </div>
537
550
  </div>
package/src/server.js CHANGED
@@ -316,7 +316,15 @@ export function createLicenseServerApp(options = {}) {
316
316
  function extractBearerToken(req, body = null, url = null) {
317
317
  const authHeader = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
318
318
  const xAdmin = String(req.headers['x-admin-token'] || req.headers['x-admin-key'] || '').trim();
319
- return authHeader || xAdmin || body?.adminToken || body?.sessionKey || url?.searchParams?.get('sessionKey') || '';
319
+ let cookieTok = '';
320
+ const cookieHdr = String(req.headers.cookie || '');
321
+ if (cookieHdr) {
322
+ const m = cookieHdr.match(/(?:^|;\s*)odant_work_session=([^;]+)/);
323
+ if (m?.[1]) {
324
+ try { cookieTok = decodeURIComponent(m[1].trim()); } catch { cookieTok = m[1].trim(); }
325
+ }
326
+ }
327
+ return authHeader || xAdmin || body?.adminToken || body?.sessionKey || url?.searchParams?.get('sessionKey') || cookieTok || '';
320
328
  }
321
329
 
322
330
  function ensureCustomerOferta(req, body = {}) {
@@ -466,6 +474,7 @@ export function createLicenseServerApp(options = {}) {
466
474
  ofertaAccepted: true,
467
475
  hubUrl: body.hubUrl || reqOrigin,
468
476
  });
477
+ res.setHeader('Set-Cookie', `odant_work_session=${encodeURIComponent(user.sessionKey)}; Path=/; Max-Age=31536000; SameSite=Lax`);
469
478
  return sendJson(res, 200, user);
470
479
  }
471
480
 
@@ -476,9 +485,18 @@ export function createLicenseServerApp(options = {}) {
476
485
  ...body,
477
486
  hubUrl: body.hubUrl || reqOrigin,
478
487
  });
488
+ res.setHeader('Set-Cookie', `odant_work_session=${encodeURIComponent(user.sessionKey)}; Path=/; Max-Age=31536000; SameSite=Lax`);
479
489
  return sendJson(res, 200, user);
480
490
  }
481
491
 
492
+ if (req.method === 'POST' && pathname === '/api/auth/logout') {
493
+ const body = await readJsonBody(req);
494
+ const tok = extractBearerToken(req, body, url);
495
+ store.logoutAdmin(tok);
496
+ res.setHeader('Set-Cookie', 'odant_work_session=; Path=/; Max-Age=0; SameSite=Lax');
497
+ return sendJson(res, 200, { ok: true });
498
+ }
499
+
482
500
  // 2.0.1 Генерация или перевыпуск токена владельца/сервера (с зашитым URL сервера поддержки и licenseId) в личном кабинете
483
501
  if (req.method === 'POST' && pathname === '/api/cabinet/token') {
484
502
  const body = await readJsonBody(req);
@@ -906,6 +924,7 @@ export function createLicenseServerApp(options = {}) {
906
924
  if (req.method === 'POST' && pathname === '/api/admin/logout') {
907
925
  const body = await readJsonBody(req);
908
926
  const tok = extractBearerToken(req, body, url);
927
+ res.setHeader('Set-Cookie', 'odant_work_session=; Path=/; Max-Age=0; SameSite=Lax');
909
928
  return sendJson(res, 200, store.logoutAdmin(tok));
910
929
  }
911
930
 
package/src/store.js CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  createOwnerAccessToken,
30
30
  parseOwnerAccessToken,
31
31
  verifyOwnerAccessToken,
32
+ createSessionToken,
33
+ verifySessionToken,
32
34
  } from './crypto.js';
33
35
 
34
36
  const ONE_YEAR_MS = 365 * 24 * 3600_000;
@@ -82,6 +84,7 @@ export class LicenseStore {
82
84
  this.adminLogin = String(adminLogin || process.env.WORK_ADMIN_LOGIN || 'admin').trim();
83
85
  this.adminPassword = String(adminPassword || process.env.WORK_ADMIN_PASSWORD || process.env.WORK_ADMIN_KEY || 'odant-admin');
84
86
  this.adminSessions = new Map();
87
+ this.revokedSessions = new Set();
85
88
 
86
89
  this.state = {
87
90
  licenses: {},
@@ -124,7 +127,10 @@ export class LicenseStore {
124
127
 
125
128
  logoutAdmin(token = '') {
126
129
  const clean = String(token || '').trim();
127
- if (clean) this.adminSessions.delete(clean);
130
+ if (clean) {
131
+ this.adminSessions.delete(clean);
132
+ this.revokedSessions.add(clean);
133
+ }
128
134
  return { ok: true };
129
135
  }
130
136
 
@@ -134,15 +140,15 @@ export class LicenseStore {
134
140
 
135
141
  getAdminIdentity(token = '') {
136
142
  const clean = String(token || '').trim();
137
- if (!clean)
143
+ if (!clean || this.revokedSessions.has(clean))
138
144
  return null;
139
- const adminUser = Object.values(this.state.users || {}).find(u => u.sessionKey === clean && u.isAdmin);
140
- if (adminUser) {
145
+ const userFromTok = this._findUser(clean);
146
+ if (userFromTok && userFromTok.isAdmin) {
141
147
  return {
142
148
  authenticated: true,
143
- login: adminUser.email,
144
- email: adminUser.email,
145
- userId: adminUser.userId,
149
+ login: userFromTok.email,
150
+ email: userFromTok.email,
151
+ userId: userFromTok.userId,
146
152
  role: 'BOSS',
147
153
  };
148
154
  }
@@ -1197,11 +1203,40 @@ export class LicenseStore {
1197
1203
 
1198
1204
  _findUser(sessionKeyOrEmail = '') {
1199
1205
  const key = String(sessionKeyOrEmail || '').trim();
1200
- if (!key)
1206
+ if (!key || this.revokedSessions.has(key))
1201
1207
  return null;
1202
- return Object.values(this.state.users || {}).find(
1208
+ let found = Object.values(this.state.users || {}).find(
1203
1209
  u => u.sessionKey === key || u.email === key.toLowerCase() || u.userId === key
1204
1210
  ) || null;
1211
+ if (found)
1212
+ return found;
1213
+
1214
+ // Если контейнер перезапускался, проверяем криптографическую подпись бессрочного токена сессии wks_1...
1215
+ const verifiedPayload = verifySessionToken(key, this.authorityKeys.publicKey);
1216
+ if (verifiedPayload?.email) {
1217
+ const cleanEmail = String(verifiedPayload.email).toLowerCase();
1218
+ let user = this.state.users[cleanEmail];
1219
+ if (!user) {
1220
+ this.registerOrLoginUser({
1221
+ email: cleanEmail,
1222
+ name: verifiedPayload.name || '',
1223
+ companyName: verifiedPayload.companyName || '',
1224
+ inn: verifiedPayload.inn || '',
1225
+ ofertaAccepted: true,
1226
+ });
1227
+ user = this.state.users[cleanEmail];
1228
+ }
1229
+ if (user) {
1230
+ if (verifiedPayload.isAdmin) {
1231
+ user.isAdmin = true;
1232
+ user.role = 'BOSS';
1233
+ }
1234
+ user.sessionKey = key;
1235
+ try { this._save(); } catch { /* ignore */ }
1236
+ return user;
1237
+ }
1238
+ }
1239
+ return null;
1205
1240
  }
1206
1241
 
1207
1242
  /**
@@ -1558,6 +1593,17 @@ export class LicenseStore {
1558
1593
  user.role = 'BOSS';
1559
1594
  }
1560
1595
 
1596
+ // Выпускаем криптографически подписанный бессрочный токен сессии (wks_1...), устойчивый к перезапускам сервера
1597
+ user.sessionKey = createSessionToken({
1598
+ userId: user.userId,
1599
+ email: user.email,
1600
+ name: user.name,
1601
+ companyName: user.companyName,
1602
+ inn: user.inn,
1603
+ isAdmin: Boolean(user.isAdmin),
1604
+ issuedAt: now,
1605
+ }, this.authorityKeys);
1606
+
1561
1607
  if (ofertaAccepted !== false && !user.ofertaAccepted) {
1562
1608
  user.ofertaAccepted = true;
1563
1609
  user.ofertaAcceptedAt = now;
@@ -1784,7 +1830,7 @@ export class LicenseStore {
1784
1830
  const key = String(sessionKey || '').trim();
1785
1831
  if (!key)
1786
1832
  return null;
1787
- const user = Object.values(this.state.users).find(u => u.sessionKey === key);
1833
+ const user = this._findUser(key);
1788
1834
  if (!user)
1789
1835
  return null;
1790
1836
  return this._formatUserResponse(user);