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 +36 -9
- package/assets/landing.js +87 -23
- package/package.json +1 -1
- package/src/admin.js +25 -1
- package/src/crypto.js +72 -0
- package/src/landing.js +14 -1
- package/src/server.js +20 -1
- package/src/store.js +56 -10
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
|
|
29
|
+
return localStorage.getItem('odant_work_session')
|
|
22
30
|
|| localStorage.getItem(TOKEN_KEY)
|
|
23
|
-
||
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
/* ================= Вход / Регистрация
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
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
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="
|
|
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
|
-
|
|
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)
|
|
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
|
|
140
|
-
if (
|
|
145
|
+
const userFromTok = this._findUser(clean);
|
|
146
|
+
if (userFromTok && userFromTok.isAdmin) {
|
|
141
147
|
return {
|
|
142
148
|
authenticated: true,
|
|
143
|
-
login:
|
|
144
|
-
email:
|
|
145
|
-
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
|
-
|
|
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 =
|
|
1833
|
+
const user = this._findUser(key);
|
|
1788
1834
|
if (!user)
|
|
1789
1835
|
return null;
|
|
1790
1836
|
return this._formatUserResponse(user);
|