iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
佛心分享-IT 人自學之術

出發吧!後端菜鳥:30 天的後端學習紀錄系列 第 20 篇

Day 20|從登入到身分驗證:認識 JWT

  • 分享至 

  • xImage
  •  

前言

前一天用 bcrypt 完成了登入前很重要的一步:確認使用者輸入的密碼是否正確。現在我們已經可以做到「使用者送出帳號密碼 → 查詢資料庫 → 用 bcrypt 比對 → 確認身分」,但登入成功後,還有一件事情要處理。

假設使用者登入成功,接著要取得自己的筆記:

GET /notes

這時候後端還是需要知道「這是哪一位使用者的請求」。因為每一次 API 請求都是獨立的,使用者剛剛登入成功這件事,不會自動延續到下一次請求。

所以登入成功後,我們還需要一種方法,讓使用者之後每次發送請求時,都能證明自己的身分。JWT 就是在這個情境下常見的一種做法。

登入成功後,身分要跟著走

常見的方式可以分成 Session 和 Token。

Session 是由後端保存登入狀態,使用者登入成功後,後端建立一筆 Session,裡面記錄使用者的 userId,再給前端一個 Session ID。

之後前端每次請求都帶著這個 ID 回來,後端再找到對應的 Session。

登入
 ↓
後端建立 Session
 ↓
前端保存 Session ID
 ↓
下一次請求帶上 Session ID
 ↓
後端找到 Session
 ↓
取得 userId

Session 很適合用在傳統網站,也可以使用在前後端分離的網站,所以它並沒有因為 JWT 出現就消失。

Token 則是登入成功後,由後端產生一組 Token 給前端保存,之後前端每次請求都帶著這組 Token,後端收到後再確認 Token 是否有效。

登入
 ↓
後端產生 Token
 ↓
前端保存 Token
 ↓
下一次請求帶上 Token
 ↓
後端確認 Token
 ↓
取得 userId

JWT 就是一種常見的 Token 格式。

當 API 開始變多,或系統需要前後端分開開發時,Token 會變得很方便。因為 Session 的登入狀態放在後端,如果今天只有一台 Server,處理起來很簡單:

Frontend
   ↓
Node.js
   ↓
Session

但如果之後變成多台 Server:

             Frontend
                 ↓
          Load Balancer
          ↙     ↓       ↘
    Server A  Server B   Server C

使用者登入時可能被分到 Server A,Session 也就存在 Server A。下一次請求如果到了 Server B,Server B 就找不到原本的 Session。

這時就需要另外準備一個大家都能存取的地方,例如 Redis:

Server A ─┐
Server B ─┼→ Redis
Server C ─┘

Token 的方式就比較簡單。Token 本身就帶著需要的資料,後端收到後可以直接確認它是不是有效,不需要每次去找某一台 Server 上的 Session。

             Frontend
                 ↓
          Load Balancer
          ↙      ↓      ↘
      Server A Server B Server C
          ↘      ↓      ↙
           驗證 Token

所以在前後端分離、API 很多,或未來可能增加多台 Server 的情況下,Token 通常會比較方便。

不過這不代表 Session 比較舊,或 JWT 一定比較好。Session 一樣可以用在 API,只是當系統變大時,需要另外處理多台 Server 共用登入狀態的問題。Token 則可以讓每台 Server 自己確認 Token,因此在這類架構中常常比較容易使用。

JWT 裡面有三個部分

JWT 通常會長得像一長串文字:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJ1c2VySWQiOjEyMywiZXhwIjoxNzU5NzAwMDAwfQ
.
abc123...

中間會用 . 分成三段:

Header.Payload.Signature

這三段各自有不同的用途。

Header

Header 主要用來記錄這個 JWT 使用哪一種方式,以及它是哪一種 Token。

例如:

{
  "alg": "HS256",
  "typ": "JWT"
}

現在先記住 alg 和 typ 就好,不需要先深入了解這些設定。

Payload

Payload 是放資料的地方,例如使用者 ID 和 Token 的使用期限:

{
  "userId": 123,
  "exp": 1759700000
}

這些資料可以被讀取,所以不要把密碼或其他重要的私人資料直接放進去。

JWT 的 Payload 只是換成另一種格式保存,並沒有把內容藏起來。因此看到 JWT 裡面有一串看不懂的文字,也不要把它當成加密後的密碼。

Signature

Signature 是用來確認 JWT 有沒有被改過。

可以簡單想成,後端會拿 Header、Payload 和一組只有自己知道的 Secret,算出一個 Signature:

Header + Payload + Secret
        ↓
    Signature

之後 JWT 回到後端時,後端會再算一次,看看結果是不是一樣。

如果有人偷偷修改了 Payload:

userId: 123
↓
userId: 456

算出來的 Signature 就會不同,後端也就能知道這個 Token 有問題。

所以目前可以先把 JWT 記成:

JWT
├── Header      → 記錄基本設定
├── Payload     → 放使用者相關資料
└── Signature   → 確認內容有沒有被改過

Secret 是後端自己的秘密

JWT 的 Signature 會用到 Secret。

可以把 Secret 想成後端自己保管的一把鑰匙,只有後端知道。

例如:

JWT_SECRET=your-super-secret-key

程式再從環境變數取得:

const secret = process.env.JWT_SECRET;

不要直接把 Secret 寫在程式碼裡:

const secret = "your-super-secret-key";

也不要把 .env 放進 Git:

.env

因為 Secret 一旦外洩,別人就可能拿它做出假的 JWT。

Token 有使用期限

JWT 通常會設定使用期限,避免一組 Token 永遠都能使用。

例如:

{
  "userId": 123,
  "exp": 1759700000
}

exp 代表到期時間。

使用 jsonwebtoken 時,可以直接設定:

const token = jwt.sign(
  {
    userId: user.id
  },
  process.env.JWT_SECRET,
  {
    expiresIn: "1h"
  }
);

這裡的 "1h" 就代表這個 Token 可以使用 1 小時,時間到了之後,就算 Token 沒有被改過,後端也會拒絕它。所以登入流程會慢慢變成:

帳號密碼
 ↓
bcrypt 驗證
 ↓
登入成功
 ↓
建立 JWT
 ↓
設定 Secret
 ↓
設定使用期限
 ↓
交給前端保存

JWT 要存在哪裡?

JWT 產生出來之後,還要決定放在哪裡。

前端常見的地方有 localStorage、sessionStorage 和 Cookie,不過如果 JWT 是拿來做登入驗證,就需要特別注意保存的位置。

localStorage 和 sessionStorage 都可以被網頁上的 JavaScript 讀取,如果網站有 XSS 漏洞,攻擊者就可能利用這個漏洞讀取 Token。

例如:

const token = localStorage.getItem("token");

所以登入用的 Token 不建議直接放在這兩個地方。

對瀏覽器登入來說,常見的另一種做法是把 JWT 放進 HttpOnly Cookie。

例如後端登入成功後:

res.cookie("access_token", token, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 60 * 60 * 1000
});

這樣 JWT 會由瀏覽器保存,JavaScript 不能直接讀取這個 Cookie,之後符合條件的請求,瀏覽器會自動把 Cookie 帶回後端。

這裡要注意,Cookie 和 JWT 是兩個不同的東西:

JWT
→ Token 的格式

Cookie
→ 瀏覽器保存資料的方式

所以「把 JWT 放進 Cookie」的意思,就是讓瀏覽器用 Cookie 保存這組 JWT。

Login API:把 bcrypt 和 JWT 接起來

現在可以把前一天學到的 bcrypt 和今天的 JWT 接在一起。

假設前端送出:

POST /auth/login
Content-Type: application/json

Request Body:

{
  "email": "test@example.com",
  "password": "123456"
}

後端的流程會是:

接收 email / password
        ↓
    查詢 User
        ↓
   bcrypt 比對密碼
        ↓
      驗證成功
        ↓
     建立 JWT
        ↓
  放進 HttpOnly Cookie
        ↓
     回傳登入成功

Controller 可以先這樣寫:

import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";

export const login = async (req, res, next) => {
  try {
    const { email, password } = req.body;

    const user = await userRepository.findOne({
      where: { email }
    });

    if (!user) {
      return res.status(401).json({
        message: "帳號或密碼錯誤"
      });
    }

    const isMatch = await bcrypt.compare(password, user.password);

    if (!isMatch) {
      return res.status(401).json({
        message: "帳號或密碼錯誤"
      });
    }

    const token = jwt.sign(
      {
        userId: user.id
      },
      process.env.JWT_SECRET,
      {
        expiresIn: "1h"
      }
    );

    res.cookie("access_token", token, {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 60 * 60 * 1000
    });

    res.status(200).json({
      message: "登入成功"
    });
  } catch (error) {
    next(error);
  }
};

這段程式可以分成三個部分來看。

先用 bcrypt 確認密碼:

const isMatch = await bcrypt.compare(password, user.password);

確認成功後,建立 JWT:

const token = jwt.sign(
  {
    userId: user.id
  },
  process.env.JWT_SECRET,
  {
    expiresIn: "1h"
  }
);

最後放進 Cookie,httpOnly 可以避免 JavaScript 直接讀取 Cookie,secure 限制 Cookie 只能透過 HTTPS 傳送,sameSite 用來控制跨網站請求是否帶上 Cookie,maxAge 則設定 Cookie 的保存時間。

res.cookie("access_token", token, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 60 * 60 * 1000
});

這樣登入成功後,瀏覽器就會保存 JWT,之後符合條件的請求會自動帶上 Cookie。

從登入走到後續 API

到這裡,登入 API 已經可以完成「確認帳號密碼 → 建立 JWT → 保存 JWT」。

接下來使用者請求需要登入的 API,例如:

GET /notes

瀏覽器會把 Cookie 帶給後端,後端再從 Cookie 取得 JWT,檢查它有沒有被修改、是不是已經過期,最後從 Payload 取得 userId。

GET /notes
 ↓
Cookie
 ↓
JWT
 ↓
確認 JWT
 ↓
取得 userId
 ↓
查詢這個使用者的筆記

這樣登入和後續 API 就串起來了。

不過現在還缺少一個重要的部分:每支 API 都自己處理 JWT 驗證會很麻煩,因此通常會把這件事情交給 Middleware 統一處理。

這也會是下一篇要開始做的事情。

小結

到這裡,登入流程又往前走了一步。從帳號密碼驗證,到登入成功後建立 JWT,使用者的身分終於可以延續到後面的 API。

下一篇,開始處理 JWT 的驗證。


上一篇
Day 19|密碼千萬不能直接存!bcrypt 登場
系列文
出發吧!後端菜鳥:30 天的後端學習紀錄 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言