跳至主要內容
Skip to content

Session 與 Token 的抉擇:Stateful vs Stateless

Session 和 Token 是兩種主流的認證機制。它們代表了不同的設計哲學:有狀態無狀態


一、 核心差異

1.1 Session 方式(Stateful)

1.2 Token 方式(Stateless)

1.3 比較表

面向SessionToken (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 擴展問題

解決方案

  1. Sticky Session:同一用戶總是連到同一伺服器
  2. 共享儲存:所有伺服器使用同一個 Redis
  3. 改用 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)

六、 安全比較


總結

面向SessionToken
最適合傳統 Web 應用行動/API
擴展性需要共享儲存天生可擴展
登出控制簡單需要額外機制
跨域複雜簡單
伺服器負擔每次查詢只需驗證簽名

> **現代最佳實踐**:

  • 使用 JWT Token
  • 存放在 HttpOnly Cookie
  • 搭配 Refresh Token 機制
  • 設置合理的過期時間

進階挑戰

  1. 實作一個支援「登出所有裝置」的認證系統。
  2. 設計一個 Token 輪換機制,每次使用 Refresh Token 就發放新的。
  3. 研究 OAuth 2.0 的 Token 管理機制,與自建系統比較。

延伸閱讀與資源