Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як працює управління сесіями в Express.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Управління сесіями в Express.js** зберігає дані користувача на сервері та надсилає клієнту тільки підписаний cookie з ID сесії. При кожному запиті middleware перевіряє цей ID і отримує відповідний запис зі store. ```js app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax' } })); ``` **Ключове:** cookie містить тільки підписаний ID, а не самі дані. Сервер контролює закінчення терміну дії та відкликання, видаляючи запис сесії.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Управління сесіями в Express.js** зберігає дані конкретного користувача на сервері між безстановими HTTP-запитами, використовуючи cookie з ідентифікатором сесії для пошуку відповідного серверного запису. ## Теорія ### TL;DR - Аналогія: гардероб. Здаєш пальто (дані), отримуєш номерок (cookie), повертаєш пальто, показавши номерок. - Cookie містить тільки підписаний ID, а не самі дані. Уся інформація залишається на сервері. - `connect.sid` несе ID сесії; `req.session` дає доступ до збереженого об'єкта при кожному запиті. - Сесії підходять, коли сервер керує станом (логін, відкликання). JWT краще для stateless API. - MemoryStore за замовчуванням працює тільки в розробці. Для продакшену потрібен Redis або Postgres. ### Швидкий приклад ```js const express = require('express'); const session = require('express-session'); const app = express(); app.use(session({ secret: process.env.SESSION_SECRET, // Підписує cookie з ID сесії resave: false, // Не писати в store, якщо нічого не змінилось saveUninitialized: false, // Не створювати сесію для анонімних запитів cookie: { maxAge: 60000, httpOnly: true, sameSite: 'lax' } })); app.post('/login', (req, res) => { req.session.userId = '123'; // Дані зберігаються на сервері res.json({ ok: true }); // Клієнт отримує: connect.sid=s%3Aabc.xyz }); app.get('/profile', (req, res) => { if (!req.session.userId) return res.status(401).json({ error: 'Не авторизовано' }); res.json({ user: req.session.userId }); // Знайдено за ID з cookie }); ``` Middleware прикріплює `req.session` до кожного запиту. Ти пишеш у нього як у звичайний об'єкт. Express-session сам читає, пише та підписує за лаштунками. ### Як session storage працює зсередини Коли приходить запит, `express-session` читає cookie `connect.sid` і перевіряє підпис HMAC-SHA256 відносно твого `secret`. Якщо підпис валідний, бібліотека отримує об'єкт сесії зі store (за замовчуванням це JS-об'єкт у пам'яті). Наприкінці циклу запиту зміни записуються назад. Сам cookie містить тільки підписаний ID. Жодних чутливих даних. Уся інформація залишається на сервері. В цьому і є суть патерну. Завдяки event-driven моделі Node читання й запис у store відбуваються асинхронно і не блокують інші запити. З Redis це працює достатньо швидко навіть при великому навантаженні. ### Головна відмінність від клієнтського зберігання localStorage і клієнтські токени відкриті для JavaScript і вразливі до XSS. Cookie з `httpOnly: true` скрипти взагалі не можуть прочитати. Крім того, сервер контролює закінчення терміну дії та відкликання, просто видаляючи запис сесії. Cookie залишається маленьким (тільки ID), тому розмір запитів не зростає. ### Коли використовувати сесії - Автентифікація: зберігай стан входу та ролі на сервері. Сервер може відкликати доступ миттєво. - Кошик товарів: зберігай товари до оформлення замовлення без витоку даних на клієнт. - A/B тестування: відстежуй групи користувачів на сервері, щоб клієнт не міг підробити своє призначення. - Не підходить для публічних API, що споживаються SPA або мобільними застосунками. Там краще JWT, без зберігання на сервері. - Для кластерних деплойментів або стабільно великого трафіку (більше ~10 req/s) використовуй Redis. MemoryStore не витримає. ### Session stores MemoryStore за замовчуванням витікає в пам'яті в продакшені та ламається в кластерних конфігураціях. Кожен Node-процес має власну пам'ять, тому логін на worker 1 не спрацює на worker 2. Це найпоширеніша проблема з сесіями на Stack Overflow. | Store | Пакет | Коли використовувати | |---|---|---| | MemoryStore | Вбудований | Тільки для розробки | | Redis | `connect-redis` | Продакшен будь-якого масштабу | | PostgreSQL | `connect-pg-simple` | Якщо вже використовуєш Postgres | | MongoDB | `connect-mongo` | Якщо вже використовуєш MongoDB | ```js const RedisStore = require('connect-redis').default; const { createClient } = require('redis'); const redisClient = createClient({ url: process.env.REDIS_URL }); await redisClient.connect(); app.use(session({ store: new RedisStore({ client: redisClient }), secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: true, sameSite: 'lax', httpOnly: true } })); ``` ### Сесії vs JWT | | Сесії | JWT | |---|---|---| | Зберігання | На сервері | На клієнті | | Масштабування | Потрібне спільне сховище | Stateless | | Відкликання | Просто (видалити запис) | Складно (потрібен blacklist) | | Розмір cookie | Малий (тільки ID) | Більший токен | | Найкраще для | Server-rendered додатки, адмінки | API, SPA, мікросервіси | Коротко: якщо треба відкликати доступ миттєво (примусовий logout, бан), сесії виграють. Якщо будуєш розподілену систему або мікросервіси, де спільний store незручний, JWT простіший. ### Типові помилки **Захардкоджений secret у коді** ```js // Неправильно - один Git-коміт це розкриє secret: 'keyboard cat' // Правильно secret: process.env.SESSION_SECRET ``` Якщо це потрапить у публічний репозиторій, зловмисник може перерахувати HMAC-підпис і підробити будь-який session cookie. **`saveUninitialized: true`** Старий дефолт. Такий режим створює запис сесії для кожного анонімного відвідувача і бота. MemoryStore швидко переповнюється, після чого настає OOM crash. Встанови `false`, і сесії будуть створюватись тільки після запису в `req.session`. **Немає спільного store у кластері** ```js // Ламається в pm2 cluster mode або Docker з кількома репліками app.use(session({ /* без опції store */ })); ``` Кожен процес тримає сесії у власній пам'яті. Користувач логіниться на worker 1, наступний запит іде на worker 2 - сесія зникла. Перейди на `connect-redis` або `connect-pg-simple`. **`secure: true` у розробці** Cookie з `secure: true` надсилаються тільки через HTTPS. На localhost вони взагалі не встановлюються. Виправлення: ```js cookie: { secure: process.env.NODE_ENV === 'production' } ``` **Пропущений `req.session.regenerate()` після логіну** Session fixation (фіксація сесії): зловмисник заздалегідь встановлює відомий `connect.sid`, користувач логіниться, і тепер вони ділять ту саму сесію. Потрібно викликати `req.session.regenerate()` одразу після успішної автентифікації - це видає новий ID і копіює дані. ```js app.post('/login', async (req, res) => { const user = await authenticate(req.body); if (!user) return res.status(401).json({ error: 'Невірні дані' }); req.session.regenerate(err => { // Новий ID, захист від fixation if (err) return res.status(500).json({ error: 'Помилка сесії' }); req.session.userId = user.id; req.session.roles = user.roles; res.json({ ok: true }); }); }); ``` ### Де зустрічається в реальних проектах - Passport.js: зберігає тільки ID користувача в `req.session.passport` через `serializeUser`, потім завантажує повний об'єкт через `deserializeUser`. - Socket.io: session middleware передається напряму в `io.use()` для спільного використання сесії з Express. - Strapi / KeystoneJS: Redis-backed сесії для автентифікації в адмін-панелях. - Next.js custom server: session middleware запускається перед `next()` для захисту SSR-маршрутів. ### Follow-up питання **Q:** Що таке session fixation атака? **A:** Зловмисник встановлює відомий `connect.sid` до того, як жертва логіниться. Після входу вони ділять ту саму сесію. Захист: `req.session.regenerate()` при кожній успішній автентифікації, це видає новий ID і копіює дані. **Q:** Коли переходити з MemoryStore на Redis? **A:** Щойно виходиш за межі розробки або одного процесу. У кластері MemoryStore ізолює кожен worker. На одному процесі при реальному трафіку він витікає в пам'яті. Redis вирішує обидві проблеми однією зміною. **Q:** Як `resave: false` впливає на продуктивність? **A:** Пропускає запис у store, якщо дані сесії не змінились за час запиту. У read-heavy застосунках це скорочує кількість записів у Redis або БД на 50-70%. **Q:** Як відкликати сесії на всіх пристроях? **A:** Зберігай Set з ID сесій у Redis для кожного userId. При логіні додавай новий session ID до Set. При logout-all видаляй усі записи та самі сесії. При кожному запиті перевіряй членство. **Q:** `sameSite: 'strict'` чи `'lax'` для захисту від CSRF? **A:** `'strict'` блокує cookie при будь-якому cross-site запиті, включно з переходами за посиланнями. `'lax'` дозволяє при safe top-level навігації, але блокує cross-site POST. Більшість застосунків використовують `'lax'` разом з CSRF-токенами для мутуючих ендпоінтів. ## Приклади ### Базовий потік: логін, захищений маршрут і вихід ```js const express = require('express'); const session = require('express-session'); const app = express(); app.use(express.json()); app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 24 * 60 * 60 * 1000 // 24 години } })); app.post('/login', async (req, res) => { const { email, password } = req.body; const user = await db.users.findOne({ email }); if (!user || !await bcrypt.compare(password, user.passwordHash)) { return res.status(401).json({ error: 'Невірні дані' }); } req.session.regenerate(err => { if (err) return res.status(500).json({ error: 'Помилка сесії' }); req.session.userId = user.id; req.session.roles = user.roles; res.json({ ok: true }); }); }); app.get('/me', (req, res) => { if (!req.session.userId) return res.status(401).json({ error: 'Не авторизовано' }); res.json({ userId: req.session.userId, roles: req.session.roles }); }); app.post('/logout', (req, res) => { req.session.destroy(err => { if (err) return res.status(500).json({ error: 'Помилка виходу' }); res.clearCookie('connect.sid'); res.json({ ok: true }); }); }); ``` Ключова деталь: `req.session.regenerate()` перед записом даних користувача. Якщо пропустити цей крок, session fixation атака спрацює. ### Продакшен налаштування з Redis та auth middleware ```js const RedisStore = require('connect-redis').default; const { createClient } = require('redis'); const redisClient = createClient({ url: process.env.REDIS_URL }); await redisClient.connect(); app.use(session({ store: new RedisStore({ client: redisClient }), secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 86400000 } })); function requireAuth(req, res, next) { if (!req.session.userId) return res.status(401).json({ error: 'Не авторизовано' }); next(); } function requireRole(role) { return (req, res, next) => { if (!req.session.roles?.includes(role)) { return res.status(403).json({ error: 'Доступ заборонено' }); } next(); }; } app.get('/admin', requireAuth, requireRole('admin'), (req, res) => { res.json({ message: 'Адмін-панель' }); }); ``` Я бачив команди, які вписують `if (!req.session.userId)` в кожен маршрут замість окремого middleware. Це спрацьовує до моменту, коли логіку авторизації треба змінити в 40 місцях одночасно. ### Відкликання сесій на всіх пристроях ```js // При логіні: реєструємо session ID в Redis Set для конкретного юзера app.post('/login', async (req, res) => { // ... автентифікація ... req.session.regenerate(async err => { if (err) return res.status(500).json({ error: 'Помилка' }); req.session.userId = user.id; await redisClient.sAdd(`user:${user.id}:sessions`, req.session.id); res.json({ ok: true }); }); }); // Вихід з усіх пристроїв: видаляємо всі сесії юзера app.post('/logout-all', requireAuth, async (req, res) => { const sessionIds = await redisClient.sMembers(`user:${req.session.userId}:sessions`); for (const id of sessionIds) { await redisClient.del(`sess:${id}`); // префікс ключа connect-redis } await redisClient.del(`user:${req.session.userId}:sessions`); res.json({ ok: true }); }); ``` Цей патерн відповідає на класичне senior-питання з інтерв'ю напряму. Cookie засновані на ID, тому очищення cookie на одному пристрої прибирає тільки його ID. Для відкликання на всіх пристроях потрібен серверний трекінг.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.