iT邦幫忙

2022 iThome 鐵人賽

DAY 20
0
Modern Web

Nuxt 3 學習筆記系列 第 20

[Day 20] Nuxt 3 Cookie 的設置與 JWT 的搭配

  • 分享至 

  • xImage
  •  

前言

Cookie 在瀏覽網站時多會使用到,不論是用來儲存臨時的資訊或是辨識使用者等,這一個儲存在瀏覽器的一小段文字資料,會在每次發送 HTTP 請求時自動夾帶,所以 Cookie 最常見的用途就包含了登入狀態、驗證身份等。本篇將講述在 Nuxt 3 如何設置 Cookie,並結合 JWT (JSON Web Token) 來做一個實際使用者驗證。

Nuxt 3 Cookie 的設置方式

useCookie

Nuxt 3 提供了一個組合式函數 useCookie() 來讓我們可以讀寫 Cookie,並且對於 SSR 也有支援,在頁面、元件或插件中,都可以使用 useCookie() 來建立一個 cookie 具有響應性的參考。

使用方式:

const cookie = useCookie(name, options)
  • name: 對應的就是 cookie 的 key。
  • options: 傳入一個物件來設置多個 cookie 屬性
    • maxAge: 指定 Max-Age 屬性的值,單位是。如果沒有設置,則這個 cookie 將會是 Session Only,意即網頁關閉後就會消失。
    • expires: 指定一個 Date 物件來作為過期的時間,通常是要相容比較舊的瀏覽器做使用,如果 maxAgeexpires 屬性都有設定,則過期時間應該要設定為一樣。
    • httpOnly: 是一個布林值,預設為 false,當設置為 true 時,表示客戶端的 JavaScript 將無法使用 document.cookie 來查看這個 cookie。通常是比較敏感或機密的訊息,如 Token 或 Session Id 會設定為 true,只讓瀏覽器發出請求時自動夾帶。
    • secure: 是一個布林值,預設為 false,當設置為 true 時瀏覽器得是 HTTPS 的加密傳輸協定的情境下,才會自動夾帶這個 cookie。
    • domain: 指定 cookie 可以適用的 Domain,通常會保持預設,表是適用於自己的 Domain 之下。
    • path: 指定 cookie 適用的路徑。
    • sameSite: 為一個布林值或是字串,用於設定安全策略
    • encode: 由於 cookie 的值只能使用有限的字元集,所以這個設置可以將 cookie 編碼成合法的字串值,預設的編碼是使用 JSON.stringify + encodeURIComponent()
    • decode: cookie 會經過一個解碼的過程,預設的解碼是使用 decodeURIComponent + destr
    • default: 為一個函數,可以用於回傳 cookie 的預設值,也可以是回傳一個 Ref

舉個例子
新增 ./pages/cookie.vue,內容如下:

<template>
  <div class="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
    <div class="w-full max-w-md">
      <div class="flex flex-col items-center">
        <h2 class="mt-2 text-center text-3xl font-bold tracking-tight text-gray-700">Cookie</h2>
      </div>
      <div class="mt-2 flex w-full max-w-md flex-col items-center">
        <button
          type="button"
          class="mt-2 w-fit rounded-sm bg-emerald-500 py-2 px-4 text-sm text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2"
          @click="setNameCookie"
        >
          設置 name
        </button>
        <div class="mt-2 flex">
          <label class="text-lg font-semibold text-emerald-500">name:</label>
          <span class="ml-2 flex text-lg text-slate-700">{{ name }}</span>
        </div>
      </div>
      <div class="mt-2 flex w-full max-w-md flex-col items-center">
        <button
          type="button"
          class="mt-2 w-fit rounded-sm bg-emerald-500 py-2 px-4 text-sm text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2"
          @click="setCounterCookie"
        >
          設置 counter
        </button>
        <div class="mt-2 flex">
          <label class="text-lg font-semibold text-emerald-500">counter:</label>
          <span class="ml-2 flex text-lg text-slate-700">{{ counter }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
const name = useCookie('name')
const counter = useCookie('counter', { maxAge: 60 })

const setNameCookie = () => {
  name.value = 'Ryan'
}

const setCounterCookie = () => {
  counter.value = Math.round(Math.random() * 1000)
}
</script>

我們就可以設置一個只有目前網頁有效的 cookie 名為 name 及一個過期時間為 60 秒後的 counter
https://i.imgur.com/GEcRHbx.gif

伺服器端使用 getCookiesetCookie

你可以在伺服器端使用 getCookie() 來取得前端夾帶過來的 cookie,也可以使用 setCookie 來設置 cookie 回應給前端。

舉個例子
新增 ./server/api/coookie.js,內容如下:

export default defineEventHandler((event) => {
  let counter = getCookie(event, 'counter')

  counter = parseInt(counter, 10) || 0
  counter += 1

  setCookie(event, 'counter', counter)

  return { counter }
})

當前端打 /api/cookie 這隻 Server API 時,就會自動夾帶瀏覽器中的 cookie,伺服器端收到請求解析 cookie 後得到 counter,將其轉為數值或預設為 0 後增加 1,再重新設定回去給前端。
https://i.imgur.com/eWYQs1O.gif

使用 Cookie 做使用者驗證

我們可以將 Cookie 的運作機制應用在會員系統當中,使用者登入成功後,後端產生的 Token 或 Session 回傳並儲存在使用者的瀏覽器中,之後的請求將會自動夾帶可以辨識出使用者的 cookie,我們就可以在後端解析或比對 cookie 來驗證使用者的資訊,並依照策略給予不同的處理邏輯。

我們延續上一篇使用 Google OAuth 登入,我們可以在後端實作產生我們自己系統使用的 Token,並設置在 access_token 這個 cookie 之中,後端可以寫如下程式碼,來設置 httpOnlymaxAge 過期時間等參數。

export default defineEventHandler(async (event) => {
  // ...

  setCookie(event, 'access_token', accessToken, {
    httpOnly: true,
    maxAge,
    expires: new Date(expires * 1000),
    secure: process.env.NODE_ENV === 'production',
    path: '/'
  })

  // ...
})

我們使用 jsonwebtoken 產生 JWT,裡面的 Payload 放置了使用者資訊,其中 jwtSignSecret 作為核發 JWT 的簽署金鑰,我們定義在 nuxt.config.ts 中的 runtimeConfig.jwtSignSecret ,完整的 ./server/api/auth/google.post.js 程式碼如下:

import { OAuth2Client } from 'google-auth-library'
import jwt from 'jsonwebtoken'

const runtimeConfig = useRuntimeConfig()

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const oauth2Client = new OAuth2Client()
  oauth2Client.setCredentials({ access_token: body.accessToken })

  const userInfo = await oauth2Client
    .request({
      url: 'https://www.googleapis.com/oauth2/v3/userinfo'
    })
    .then((response) => response.data)
    .catch(() => null)

  oauth2Client.revokeCredentials()

  if (!userInfo) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Invalid token'
    })
  }

  const jwtTokenPayload = {
    id: userInfo.sub,
    nickname: userInfo.name,
    email: userInfo.email
  }

  const maxAge = 60 * 60 * 24 * 7
  const expires = Math.floor(Date.now() / 1000) + maxAge

  const jwtToken = jwt.sign(
    {
      exp: expires,
      data: jwtTokenPayload
    },
    runtimeConfig.jwtSignSecret
  )

  setCookie(event, 'access_token', jwtToken, {
    httpOnly: true,
    maxAge,
    expires: new Date(expires * 1000),
    secure: process.env.NODE_ENV === 'production',
    path: '/'
  })

  return {
    id: userInfo.id,
    nickname: userInfo.name,
    avatar: userInfo.picture,
    email: userInfo.email
  }
})

JWT 在核發時使用的加密金鑰,建議使用非對稱式的金鑰進行加密,這邊僅是為了範例展示方便而使用相同的 Secret 進行加解密。

接著,我們可以實作一個 Server API,./server/api/whoami.js 來從 cookie 得到 access_token,再用 jwt.verify() 方法,來驗證 JTW 後的到使用者資訊。

import jwt from 'jsonwebtoken'

const runtimeConfig = useRuntimeConfig()

export default defineEventHandler((event) => {
  const jwtToken = getCookie(event, 'access_token')

  try {
    const { data: userInfo } = jwt.verify(jwtToken, runtimeConfig.jwtSignSecret)

    return {
      id: userInfo.id,
      nickname: userInfo.nickname,
      email: userInfo.email
    }
  } catch (e) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }
})

我們在前端,就可以使用 /api/whoami 這隻 API 來得到使用者的資訊囉!
https://i.imgur.com/JTXecxg.gif

上圖的流程如下:

  1. 前往登入頁面,並使用 Google 進行登入
  2. 登入成功後,打 /api/auth/google API,Body 夾帶 Google OAuth回傳的 access_token。
  3. /api/auth/google API 回傳後端產生的時效七天的 JWT,並設置於 cookie 的 access_token,並將前端頁面導航至 /whoami 頁面。
  4. /whoami 頁面,點擊「打 /api/whoami API」按鈕,送出 /api/whoami API 請求,瀏覽器會自動夾帶 cookie 至後端。
  5. 後端 API /api/whoami 收到請求後,從 cookie 中解析出 access_token 的值,並驗證解析出 JWT 內含的使用者資訊,並將其回傳至前端渲染。

至此,我們就實作出一個 Google OAuth 的登入及驗證機制,後續可以再將這些資訊儲存至資料庫之中做後續的使用。

這個範例的完整程式碼可以參考 Nuxt 3 - 使用 Cookie 與 JWT 做使用者驗證

小提醒,如果是在伺服器端打 API,可以使用 useRequestHeaders 就可以從伺服器端訪問和代理 cookie 到 API。

<script setup>
const { data: userInfo } = await useFetch('/api/whoami', {
  headers: useRequestHeaders(['cookie'])
})
</script>

Cookie、Local Storage、Token 與 JWT

在做使用者驗證的時候,Cookie、LocalStorage、Token 與 JWT 是個重要的概念,這邊稍微簡述一下之間的差異與使用情境。

簡介

Cookie

通常採用 Cookie 來驗證使用者時,當使用者登入成功,會由伺服器端產生一組 cookie 來當作後續驗證使用的依據,這個依據會是一個字串形式,瀏覽器每次發送請求時會自動夾帶符合 Domain、路徑設定等的 cookie,伺服器端就可以依據 cookie 來解析驗證這個字串所代表的涵意,例如是不是匹配代表某位使用者字串。

如同前面的例子,我們將一組能夠代表 Ryan 的 JWT 由登入後產生,並在後續請求中夾帶,以達到驗證使用者的效果。

這串字串,不一定是要 JWT,只要伺服器端能回推或從資料庫比對出所代表的使用者或特定資訊,那麼也可以是隨意產生的字串。

Local Storage

Local Storage 是現今瀏覽器基本上都支援的一個儲存空間,同樣具有 Domain 的寫入與讀取的概念,在 cookie 我們最多只能儲存 4KB 左右的字串資料,但是在 LocalStorage 通常有 5MB 所以能夠儲存更多的資訊。

也有網站會把驗證使用者的 Token 或憑證依據儲存在 Local Storage,之後發送請求或需要時再從中拿取出來並手動夾帶出去,同樣也可以實作出驗證使用者的流程。

在瀏覽器的儲存空間,還有一個 Session Storage,儲存在這裡面的資料,當網頁關閉時,就會自動清除。與 Local Storage 不同,除非手動或使用 JS 清除,不然資料永遠存在,也不會有過期時間。

Token

用於提供給使用者後續夾帶給後端的憑據,相當於一個身分證,Token 通常由字串組成,且需要夠長不容易被暴力嘗試破解,Token 字串通常不具有任何意義,直至後端與資料庫或其他方式比對後,會對應出 Token 所代表的意義,例如使用者資訊。

通常請求夾帶的 Token,需要每次往快取或資料庫做比對,才能知道 Token 所代表的使用者。

JWT (JSON Web Token)

JWT 是一種開放的標準 RFC 7519,如同名字 JWT 是基於 JSON Object 所編碼出來的,JWT 是由三個部分 HeaderPayloadSignature 組合而成。

其中最大的特色就是 Payload 這個部分,當後端伺服器收到 JWT 時,可以從 Payload 解析出當初簽發 JWT 所包含的資訊,通常會在 Payload 放置使用者的相關資料,如 Id、姓名或信箱等,因為這個特色我們不需要再比對快取或資料庫,就可以直接解析出使用者資料。

Payload 這個欄位,因為可以被解碼出來,再任意的修改後重新編碼,所以我們就會借助 HeaderSignature 這兩個部分,來確認當初加密的演算法及驗證簽章是否符合,以防止 Payload 被任意的竄改。

Token 或 JWT 選哪一個?

這兩者之間各有優缺點,雖然自產 Token 需要每次比對資料庫,但是能有效的記錄使用者 Token 核發使用的位置及註銷特定的 Token,因為 JWT 在簽發後,一定得等到過期才會失效無法使用,也就導致 JWT 在核發之後是無法註銷的,除非自己在實作一個黑名單或白名單的機制。

可以依據使用的情境來決定 Token 或 JWT,只要記得使用 Token 作為解決方案,要保證 Token 足夠複雜或隨機,不容易被推測或暴力嘗試,如果真的要使用比較短的 Token 也確保過期的時間不要設定太長,要頻繁的更換這組 Token 或添加其他驗證機制。

而 JWT 的利於可以包含 Payload,也切記在後端核發時不要將敏感資料或密鑰夾帶在 Payload 之中,因為 JWT 的 Payload 就算不知道加密的私鑰還是可以被解碼的。而簽發 JWT 選擇加密的方式也盡量採用非對稱式的家姐密,例如使用 ES256 (ECDSA-SHA256) 演算法產生的非對稱金鑰來進行加密。

Token 要放在 Cookie 還是 Local Storage

後端產生的 Token 或 JWT,不管存放在 Cookie 或 Local Storage 只要注意其特性及安全,那麼想存在哪裡,都是可以被接受的。

Cookie

將 Token 存放在 Cookie 的好處,可以用來控制網站的 Domain 或 SubDomain 進行存取,也可以設定過期時間來控制前端是否要重新問後端產生新的 Token,最大的特點就是自動夾帶在每個請求當中,也因為這項特性,有一些資安風險就需要稍微注意一下。

跨站請求偽造 (Cross Site Request Forgery, CSRF) 就是一個 cookie 使用時所需要注意的資安問題,所以當使用 cookie 作為驗證機制時,建議在敏感的操作上多添加驗證的機制,或使用 CSRF Token 來保護你的 cookie 不被隨意偽造請求的夾帶出去。

將 Token 或敏感資料儲存在 cooike 時,還是可以透過 JavaScript 的 document.cookie 做存取,所以如果網站有存在跨網站指令碼 (Cross-site scripting, XSS) 的弱點,你的 cookie 很可能就會直接被偷走,為了防止 XSS 弱點導致 cookie 外洩,你可以將 cookie 設值時的屬性 HTTP Only 設置為 true,讓客戶端瀏覽器無法直接存取,僅有在發送請求時,由瀏覽器自動夾帶至後端,此外,也可以設置 secure 讓 cookie 只在 HTTPS 下傳輸。

Local Storage

關於 Token 是否儲存在 Local Storage 其實有不同的看法,因為 cookie 存在著 CSRF 或 XSS 等問題需要解決,而存放在 Local Storage 不僅有更大的空間,還不自帶免疫 CSRF,難道不香嗎?

但是別忘了 Local Storage 也是透過 JS 來做儲存與讀取,所以就算使用 Local Storage 了,但 XSS 弱點如果存在,你的 Token 一樣有外洩的可能性。

而且 Local Storage 不具有過期自動刪除的特性,除非自己實作或刪除否則將永遠的存在瀏覽器之中。

所以?

網路上有許多使用 Cookie 或 Local Storage 儲存 Token 說法,有興趣也可以看看兩派各自論述的優缺點,所以,不管是 Cookie 還是 Local Storage 沒有說一定不能使用誰,而是要依據特性及情境來做使用,並做好定期重新產生及相關的安全配置。

我自己大概會依照這個網站或服務,是相對簡單也比較不會有會造成悲劇的操作或內容管理為主的服務,我可能就會採用 cookie 的方案處理驗證,因為相對來說自動夾帶與有效期限及 httpOnly 來控制客戶端是否能直接存取,對我來說還是挺方便與安全的,可能在適當的時機再添加 CSRF Token 或多道驗證機制,就能安全的使用 cookie。

而在多為內部系統使用或是 API 在 Mobile App 甚至是不支援 Cookie 環境之下需要使用 Token 來驗證,我就會採納將 Token 儲存在 Local Storage。

以上是我個人見解,提供給各位參考,也歡迎大家來討論或指正有刊誤的地方。

小結

這篇的範例以將後端產生的 JWT 並使用 Nuxt 3 提供的組合函數來設置在 Cookie 中,並在後續請求自動夾帶進行解析驗證,如果情境上需要,你也可以將 Token 或 JWT 儲存在 Pinia 的 Store 來連動 Local Storage,但也要注意 Cookie 與 Local Storage 兩者間的特點與需要注意的地方。


感謝大家的閱讀,這是我第一次參加 iThome 鐵人賽,請鞭小力一些,也歡迎大家給予建議 :)
如果對這個 Nuxt 3 系列感興趣,可以訂閱接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。

範例程式碼

參考資料


上一篇
[Day 19] Nuxt 3 串接 Google OAuth 登入
下一篇
[Day 21] Nuxt 3 實作部落格 - 資料庫與會員系統
系列文
Nuxt 3 學習筆記30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Wolke
iT邦新手 1 級 ‧ 2022-10-17 15:58:04

jwtSignSecret 這個沒宣告

Ryan iT邦新手 2 級 ‧ 2022-10-17 23:38:17 檢舉

jwtSignSecret 簽署金鑰,可以定義在 Runtime Config 之中。

例如修改 nuxt.config.ts 添加如下:

export default defineNuxtConfig({
  // ...
  runtimeConfig: {
    jwtSignSecret: 'PLEASE_REPLACE_WITH_YOUR_KEY'
  }
  // ...
})

或者也可以參考本篇的範例程式碼
https://github.com/ryanchien8125/ithome-2022-ironman-nuxt3/blob/day20/nuxt-app-whoami/nuxt.config.ts#L8

我要留言

立即登入留言