Session 與 Token 的抉擇:Stateful vs Stateless
Session 和 Token 是兩種主流的認證機制。它們代表了不同的設計哲學:有狀態與無狀態。
一、 核心差異
1.1 Session 方式(Stateful)
1.2 Token 方式(Stateless)
1.3 比較表
| 面向 | Session | Token (JWT) |
|---|---|---|
| 狀態儲存 | 伺服器端 | 客戶端 |
| 擴展性 | 需要共享狀態 | 天生可擴展 |
| 登出 | 刪除 Session 即可 | 需要額外機制 |
| 安全性 | 較好(伺服器控制) | Token 可能被盜用 |
| 行動端 | 需處理 Cookie | 原生支援 |
| 跨域 | 需要 CORS + Cookie | 簡單 |
二、 Session 深入解析
2.1 基本實作
javascript
const express = require("express");
const session = require("express-session");
app.use(
session({
secret: "my-secret-key",
resave: false,
saveUninitialized: false,
cookie: {
secure: true,
httpOnly: true,
maxAge: 3600000,
},
})
);
// 登入
app.post("/login", async (req, res) => {
const user = await validateCredentials(req.body);
if (user) {
req.session.userId = user.id;
req.session.role = user.role;
res.json({ success: true });
} else {
res.status(401).json({ error: "Invalid credentials" });
}
});
// 認證
app.get("/profile", (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: "Not authenticated" });
}
res.json({ userId: req.session.userId });
});
// 登出
app.post("/logout", (req, res) => {
req.session.destroy();
res.json({ success: true });
});2.2 Session 儲存
javascript
// 記憶體(預設,不適合生產)
app.use(session({ store: new session.MemoryStore() }));
// Redis(推薦)
const RedisStore = require("connect-redis").default;
const redis = require("redis");
const client = redis.createClient();
app.use(
session({
store: new RedisStore({ client }),
secret: "secret",
resave: false,
saveUninitialized: false,
})
);
// MongoDB
const MongoStore = require("connect-mongo");
app.use(
session({
store: MongoStore.create({ mongoUrl: "mongodb://..." }),
})
);2.3 擴展問題
解決方案:
- Sticky Session:同一用戶總是連到同一伺服器
- 共享儲存:所有伺服器使用同一個 Redis
- 改用 Token:無狀態,不需共享
三、 Token 深入解析
3.1 基本實作
javascript
const jwt = require("jsonwebtoken");
const SECRET = process.env.JWT_SECRET;
// 登入
app.post("/login", async (req, res) => {
const user = await validateCredentials(req.body);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const token = jwt.sign({ userId: user.id, role: user.role }, SECRET, {
expiresIn: "1h",
});
res.json({ token });
});
// 認證中介軟體
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
} catch (err) {
res.status(401).json({ error: "Invalid token" });
}
}
// 受保護路由
app.get("/profile", authenticate, (req, res) => {
res.json({ user: req.user });
});3.2 Token 儲存位置
| 位置 | 優點 | 缺點 |
|---|---|---|
| localStorage | 簡單、持久 | 易受 XSS |
| sessionStorage | 關閉即失效 | 易受 XSS |
| Cookie (HttpOnly) | 防 XSS | 需處理 CSRF |
| 記憶體 | 最安全 | 重新整理失效 |
javascript
// 最佳實踐:HttpOnly Cookie
app.post("/login", async (req, res) => {
const token = jwt.sign({ userId: user.id }, SECRET);
res.cookie("token", token, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 3600000,
});
res.json({ success: true });
});
// 從 Cookie 讀取
function authenticate(req, res, next) {
const token = req.cookies.token;
// ...
}3.3 登出問題
Token 無狀態,伺服器無法主動使其失效:
javascript
// 解決方案 1:短期 Token + 拒絕清單
const blacklist = new Set();
app.post("/logout", (req, res) => {
const token = req.cookies.token;
blacklist.add(token); // 加入拒絕清單
res.clearCookie("token");
res.json({ success: true });
});
function authenticate(req, res, next) {
const token = req.cookies.token;
if (blacklist.has(token)) {
return res.status(401).json({ error: "Token revoked" });
}
// ...
}
// 解決方案 2:Token 版本號
// 在 DB 儲存 tokenVersion
// 登出時 +1,驗證時比對四、 Refresh Token 模式
4.1 雙 Token 機制
4.2 實作
javascript
// 登入
app.post("/login", async (req, res) => {
const user = await validateCredentials(req.body);
const accessToken = jwt.sign({ userId: user.id }, ACCESS_SECRET, {
expiresIn: "15m",
});
const refreshToken = jwt.sign(
{ userId: user.id, version: user.tokenVersion },
REFRESH_SECRET,
{ expiresIn: "7d" }
);
// Refresh Token 存 HttpOnly Cookie
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/api/refresh", // 只在刷新端點發送
});
res.json({ accessToken });
});
// 刷新
app.post("/api/refresh", async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: "No refresh token" });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
const user = await User.findById(decoded.userId);
// 檢查版本號(登出會使版本號增加)
if (decoded.version !== user.tokenVersion) {
throw new Error("Token revoked");
}
const accessToken = jwt.sign({ userId: user.id }, ACCESS_SECRET, {
expiresIn: "15m",
});
res.json({ accessToken });
} catch (err) {
res.clearCookie("refreshToken");
res.status(401).json({ error: "Invalid refresh token" });
}
});
// 登出
app.post("/logout", async (req, res) => {
const decoded = jwt.verify(req.cookies.refreshToken, REFRESH_SECRET);
// 增加版本號,使所有 Refresh Token 失效
await User.updateOne({ _id: decoded.userId }, { $inc: { tokenVersion: 1 } });
res.clearCookie("refreshToken");
res.json({ success: true });
});4.3 前端處理
javascript
let accessToken = null;
// API 請求
async function apiRequest(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
if (response.status === 401) {
// Token 過期,嘗試刷新
const refreshed = await refreshAccessToken();
if (refreshed) {
return apiRequest(url, options); // 重試
} else {
window.location.href = "/login";
}
}
return response;
}
// 刷新 Token
async function refreshAccessToken() {
try {
const response = await fetch("/api/refresh", {
method: "POST",
credentials: "include",
});
if (response.ok) {
const data = await response.json();
accessToken = data.accessToken;
return true;
}
} catch (err) {}
return false;
}五、 選擇指南
5.1 選擇 Session 的場景
- 傳統 Web 應用(同域)
- 需要嚴格控制登出
- 需要伺服器端管理狀態
- 單一伺服器或可以共享狀態
5.2 選擇 Token 的場景
- 行動 App
- 跨域 API
- 微服務架構
- 無法共享狀態的分散式系統
- 第三方 API 整合
5.3 混合方案
javascript
// 最佳實踐:Token 存在 HttpOnly Cookie
// 結合兩者優點
// ✅ 防 XSS(HttpOnly)
// ✅ 無狀態(驗證只需密鑰)
// ✅ 容易登出(清除 Cookie + 拒絕清單)
// ✅ 支援跨域(正確設置 CORS)六、 安全比較
總結
| 面向 | Session | Token |
|---|---|---|
| 最適合 | 傳統 Web 應用 | 行動/API |
| 擴展性 | 需要共享儲存 | 天生可擴展 |
| 登出控制 | 簡單 | 需要額外機制 |
| 跨域 | 複雜 | 簡單 |
| 伺服器負擔 | 每次查詢 | 只需驗證簽名 |
> **現代最佳實踐**:
- 使用 JWT Token
- 存放在 HttpOnly Cookie
- 搭配 Refresh Token 機制
- 設置合理的過期時間
進階挑戰
- 實作一個支援「登出所有裝置」的認證系統。
- 設計一個 Token 輪換機制,每次使用 Refresh Token 就發放新的。
- 研究 OAuth 2.0 的 Token 管理機制,與自建系統比較。