iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Modern Web

現在就學Node.js系列 第 24

JWT Refresh Token 自動延長機制 - Day24

  • 分享至 

  • xImage
  •  

在上一篇文章中,我們使用 JWT (JSON Web Token) 讓使用者能登入並通過驗證。

但問題來了:

若 Access Token 只有效 10 分鐘,用戶是不是得一直重新登入?

這樣體驗會很差,所以通常會搭配:

  • Access Token → 短期使用(例如 10 分鐘)
  • Refresh Token → 長期使用(例如 7 天)

讓使用者在 Access Token 過期時,自動用 Refresh Token 延長Token時效,而不必重登。

雙 Token 機制簡介

Token 類型 有效時間 儲存位置 用途
Access Token 短(5–15 分鐘) LocalStorage / Cookie 用來呼叫 API的驗證機制
Refresh Token 長(7–30 天) HttpOnly Cookie 用來換新的 Access Token

運作流程如下 :

[使用者登入] → Server 發 Access Token + Refresh Token
                   |
                   ↓
Access 過期 → 前端自動呼叫 /refresh
                   |
                   ↓
Server 驗證 Refresh Token → 回傳新的 Access Token
                   |
                   ↓
Refresh Token 過期 → 要重新登入

了解運作的概念與機制後,我們就來動手實作開發看看!

Step 1:初始化環境

建立一個新專案:

mkdir jwt-refresh-demo && cd jwt-refresh-demo
npm init -y
npm install express mongoose bcrypt jsonwebtoken cookie-parser cors

Step 2:建立伺服器與 MongoDB 連線

// server.js
import express from "express";
import mongoose from "mongoose";
import cors from "cors";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(cors({ origin: "http://localhost:5173", credentials: true }));

await mongoose.connect("mongodb://localhost:27017/jwt_demo");
console.log("✅ MongoDB Connected");

Step 3:建立使用者 Model

// models/User.js
import mongoose from "mongoose";
import bcrypt from "bcrypt";

const userSchema = new mongoose.Schema({
  email: { type: String, unique: true, required: true },
  password: { type: String, required: true },
});

// 儲存前自動加密密碼
userSchema.pre("save", async function (next) {
  if (this.isModified("password")) {
    this.password = await bcrypt.hash(this.password, 10);
  }
  next();
});

export const User = mongoose.model("User", userSchema);

Step 4:建立 Refresh Token Model

這裡我們只追蹤目前有效的 Refresh Token,過期後由 Mongo 自動清除:

// models/RefreshToken.js
import mongoose from "mongoose";

const refreshTokenSchema = new mongoose.Schema({
  userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
  tokenId: { type: String, required: true },
  token: { type: String, required: true },
  expiresAt: { type: Date, required: true },
  isValid: { type: Boolean, default: true },
});

// TTL 索引:到期自動刪除
refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

export const RefreshToken = mongoose.model("RefreshToken", refreshTokenSchema);

Step 5:登入與簽發 Token

登入成功後,同時發出 Access Token(短期)與 Refresh Token(長期)。

import { User } from "./models/User.js";
import { RefreshToken } from "./models/RefreshToken.js";

const ACCESS_SECRET = "ACCESS_SECRET_KEY";
const REFRESH_SECRET = "REFRESH_SECRET_KEY";

function createAccessToken(userId, tokenId) {
  return jwt.sign({ id: userId, tokenId }, ACCESS_SECRET, { expiresIn: "10m" });
}

function createRefreshToken(userId, tokenId) {
  return jwt.sign({ id: userId, tokenId }, REFRESH_SECRET, { expiresIn: "7d" });
}

//登入
app.post("/login", async (req, res) => {
  const { email, password } = req.body;
  const user = await User.findOne({ email });
  if (!user) return res.status(404).json({ error: "使用者不存在" });

  const valid = await bcrypt.compare(password, user.password);
  if (!valid) return res.status(401).json({ error: "密碼錯誤" });

  const tokenId = uuidv4();
  const accessToken = createAccessToken(user._id, tokenId);
  const refreshToken = createRefreshToken(user._id, tokenId);

  await RefreshToken.create({
    userId: user._id,
    tokenId,
    token: refreshToken,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // TTL 清除依據
  });

  res.cookie("refreshToken", refreshToken, {
    httpOnly: true,
    sameSite: "strict",
  });

  res.json({ message: "登入成功", accessToken });
});

Step 6:自動續期機制(/refresh)

當 Access Token 過期時,前端會自動發送 /refresh 來換新的。

app.post("/refresh", async (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ error: "沒有 Refresh Token" });

  try {
    const decoded = jwt.verify(token, REFRESH_SECRET);
    const dbToken = await RefreshToken.findOne({ token });

    if (!dbToken || !dbToken.isValid)
      return res.status(403).json({ error: "Refresh Token 無效或過期" });

    const newTokenId = uuidv4();
    const newAccess = createAccessToken(decoded.id, newTokenId);

    res.json({ accessToken: newAccess });
  } catch {
    res.status(403).json({ error: "Refresh Token 過期或無效" });
  }
});

Step 7:登出與清除 Token

app.post("/logout", async (req, res) => {
  const token = req.cookies.refreshToken;
  if (token) {
    await RefreshToken.deleteOne({ token });
    res.clearCookie("refreshToken");
  }
  res.json({ message: "登出成功" });
});

Step 8:受保護的 API 驗證 Access Token

function verifyAccessToken(req, res, next) {
  const auth = req.headers.authorization;
  if (!auth) return res.status(401).json({ error: "缺少授權標頭" });

  const token = auth.split(" ")[1];
  jwt.verify(token, ACCESS_SECRET, (err, decoded) => {
    if (err) return res.status(403).json({ error: "Access Token 無效或過期" });
    req.user = decoded;
    next();
  });
}

app.get("/profile", verifyAccessToken, async (req, res) => {
  const user = await User.findById(req.user.id).select("-password");
  res.json({ user });
});

Step 9:運作流程圖總覽

┌────────────────────────────┐
│         使用者登入          │
│ POST /login (email, pwd)   │
└──────────────┬─────────────┘
               │
               ▼
┌──────────────────────────────────────────────┐
│ Server 驗證帳密                              │
│ - 建立 tokenId(UUID)                         │
│ - 產生 Access Token (10m)                    │
│ - 產生 Refresh Token (7d)                    │
│ - 存入 MongoDB(含 tokenId, expiresAt)      │
└──────────────┬───────────────────────────────┘
               │
               ▼
┌────────────────────────────┐
│ Client 儲存 Token           │
│ - Access Token  → Header    │
│ - Refresh Token → Cookie    │
└──────────────┬─────────────┘
               │
               ▼
┌────────────────────────────┐
│ 呼叫受保護 API             │
│ Authorization: Bearer ...  │
└──────────────┬─────────────┘
               │
               ▼
┌──────────────────────────────────────────────┐
│ Server 驗證 Access Token                     │
│ - 驗證簽章與 exp                             │
│ → 通過:允許存取                             │
│ → 失敗:回傳 403 (過期)                      │
└──────────────┬───────────────────────────────┘
               │
         ┌─────┴──────┐
         │ Token 過期? │
         └─────┬──────┘
               │ 是
               ▼
┌──────────────────────────────────────────────┐
│ Client 呼叫 /refresh                         │
│ - 附上 Cookie(refreshToken)                  │
│ Server 驗證 Refresh Token                    │
│ - 驗證簽章 + 查 DB 是否有效                   │
│ - 產生新 tokenId、Access Token               │
└──────────────┬───────────────────────────────┘
               │
               ▼
┌────────────────────────────┐
│ Client 更新 Token           │
│ Access Token ← 新值         │
│ Refresh Token ← 視情況換新  │
└──────────────┬─────────────┘
               │
               ▼
┌────────────────────────────┐
│ 登出 /logout                │
│ - 刪除 DB Refresh Token     │
│ - 清除 Cookie               │
└────────────────────────────┘

小結

今天我們實作了一套 JWT 雙Token驗證系統

雙 Token 機制

  • Access Token: 短期有效(10分鐘),用於 API 請求驗證
  • Refresh Token: 長期有效(7天),儲存在 HttpOnly Cookie 中,用於自動換發新 Access Token

安全防護

  • bcrypt 加密: 使用者密碼經過雜湊加密,資料庫不存明文
  • HttpOnly Cookie: Refresh Token 無法被 JavaScript 讀取,防止 XSS 攻擊
  • SameSite 屬性: 設定為 strict,防止 CSRF 跨站請求偽造
  • tokenId 綁定: 每組 Token 擁有唯一 ID,可追蹤與撤銷

資料庫管理

  • MongoDB 儲存: Refresh Token 存入資料庫,可隨時撤銷
  • TTL Index: 設定 expiresAt 自動刪除過期資料,無需手動清理
  • isValid 欄位: 支援手動標記 Token 失效(如登出)

完整流程

  1. 登入 → 驗證帳密 → 發放雙 Token → Refresh Token 存 Cookie 與 DB
  2. 呼叫 API → 攜帶 Access Token → 中介層驗證
  3. Token 過期 → 前端自動呼叫 /refresh → 換發新 Access Token
  4. 登出 → 刪除 DB 記錄 → 清除 Cookie → 徹底失效

上一篇
Session vs JWT + Token 儲存安全 - Day23
系列文
現在就學Node.js24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言