/
threed
/
claud-web
Обзор
Документация
Войти
/
threed
/
claud-web
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
lib/session.js
139 строк
6 KB
Alexander Zelenski
Уведомления о завершении работы Claude, стабильный вход и PWA
04 авг 2026, 14:26
04 авг 2026, 14:26
b6ac18e
Код
Авторство
О чём код?
/** * Долгоживущая сессия поверх Basic Auth. * * Проблема: Safari (особенно iOS и standalone-PWA) держит введённые Basic * Auth-креды недолго и сбрасывает их при перезапуске приложения или смене * сети — снова всплывает системный диалог логина. Basic Auth не имеет * состояния, «запомнить» вход браузер не обязан. * * Решение: после первой успешной проверки Basic выдаём подписанную cookie * (HMAC-SHA256, без хранения состояния на сервере). Дальше запросы проходят * по ней, а Basic остаётся способом первого входа и фолбэком. * * Секрет подписи лежит в data/.session-secret и переживает рестарт процесса — * иначе перезапуск сервиса разлогинивал бы всех. * * В подпись входит хеш логина+пароля: смена учётки в .env автоматически * инвалидирует все ранее выданные cookie. */ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const DATA_DIR = path.join(__dirname, '..', 'data'); const SECRET_FILE = path.join(DATA_DIR, '.session-secret'); const COOKIE_NAME = 'cw_session'; const MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000; // 180 дней // Ближе к концу срока продлеваем cookie на лету: активный пользователь // не разлогинивается никогда (скользящее окно). const REFRESH_AFTER_MS = 7 * 24 * 60 * 60 * 1000; let secret = null; function getSecret() { if (secret) return secret; try { const saved = fs.readFileSync(SECRET_FILE, 'utf8').trim(); if (saved.length >= 32) { secret = saved; return secret; } } catch { /* нет файла — создадим ниже */ } secret = crypto.randomBytes(48).toString('hex'); try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 }); } catch (err) { // Не смогли сохранить — работаем с секретом в памяти: сессии проживут // до рестарта, но сервис останется рабочим console.warn('⚠️ Не удалось сохранить .session-secret:', err.message); } return secret; } /** Отпечаток текущей учётки: меняется вместе с логином/паролем. */ function credentialsFingerprint() { return crypto .createHash('sha256') .update(`${process.env.AUTH_USERNAME} ${process.env.AUTH_PASSWORD}`) .digest('hex') .slice(0, 16); } function sign(payload) { return crypto.createHmac('sha256', getSecret()).update(payload).digest('base64url'); } /** Значение cookie: "<issuedAt>.<fingerprint>.<hmac>" */ function issue(issuedAt = Date.now()) { const payload = `${issuedAt}.${credentialsFingerprint()}`; return `${payload}.${sign(payload)}`; } /** * @returns {{ valid: boolean, stale?: boolean }} * stale — подпись верна, но пора продлить cookie. */ function verify(value) { if (typeof value !== 'string') return { valid: false }; const parts = value.split('.'); if (parts.length !== 3) return { valid: false }; const [issuedAtRaw, fingerprint, mac] = parts; const expected = sign(`${issuedAtRaw}.${fingerprint}`); const macBuf = Buffer.from(mac); const expectedBuf = Buffer.from(expected); if (macBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(macBuf, expectedBuf)) { return { valid: false }; } // Учётку сменили в .env — старые cookie больше не действуют if (fingerprint !== credentialsFingerprint()) return { valid: false }; const issuedAt = Number(issuedAtRaw); if (!Number.isFinite(issuedAt)) return { valid: false }; const age = Date.now() - issuedAt; if (age < 0 || age > MAX_AGE_MS) return { valid: false }; return { valid: true, stale: age > REFRESH_AFTER_MS }; } /** Разбор заголовка Cookie в объект (без зависимости cookie-parser). */ function parseCookies(header) { const out = {}; if (typeof header !== 'string') return out; for (const chunk of header.split(';')) { const eq = chunk.indexOf('='); if (eq < 0) continue; const name = chunk.slice(0, eq).trim(); if (!name) continue; out[name] = decodeURIComponent(chunk.slice(eq + 1).trim()); } return out; } /** * Ставит cookie сессии в ответ. * Secure — только на HTTPS: на голом http браузер такую cookie не сохранит * и авторизация «слетала» бы на каждом запросе. * Path=/ — сервис может жить на подпути за прокси, но cookie на корень * работает в любом случае. */ function setCookie(req, res) { const https = req.secure || req.headers['x-forwarded-proto'] === 'https'; const attrs = [ `${COOKIE_NAME}=${issue()}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(MAX_AGE_MS / 1000)}` ]; if (https) attrs.push('Secure'); res.setHeader('Set-Cookie', attrs.join('; ')); } module.exports = { COOKIE_NAME, issue, verify, parseCookies, setCookie };