iT邦幫忙

2022 iThome 鐵人賽

DAY 21
1
Modern Web

Nuxt 3 學習筆記系列 第 21

[Day 21] Nuxt 3 實作部落格 - 資料庫與會員系統

  • 分享至 

  • xImage
  •  

前言

大家經過Nuxt 3 學習筆記這一系列文章,應該對於 Nuxt 3 有初步的理解,接下來我們將進入實戰部分,我將會以 Nuxt 3 來實作部落格網站,讓已經註冊的使用者可以在網站上發布文章,實作這個網站的程式碼可能不會講解得非常仔細,但一些實務開發上會需要注意的細節我會把個人經驗做一個紀錄,大家可以再參考看看,文末也都會附上完整的範例程式。接下來,讓我們開始吧!

資料庫 (Database)

這個系列實作的部落格,會把會員與文章等資料儲存於伺服器的資料庫之中,大家可以選擇自己習慣或合適的資料庫來做儲存。為了方便及後續的展示,我最終決定使用 Prisma 搭配本地的 SQLite 來當作儲存體,讓大家測試時不用再煩惱怎麼架設資料庫,可以快速的執行範例程式碼。

你可以在自己的 Nuxt 專案或從新專案開始進行,若你已經有自己的資料庫,也可以直接跳過此段介紹後續實作自己的後端 API 來接續我們的實作系列。

安裝 Prisma

Prisma 操作起來很像 ORM (Object-Relational Mapping),但實際上依據官網的說明,其實不大依樣,Prisma 透過撰寫並根據 Schema 來建立或操作資料庫,在進行 CRUD 的操作是,都是透過 Prisma Client 進行,這也是最方便的地方,此外也支援多種資料庫的來源,只要操作 Model 就可以映射到資料庫的資料,不再需要寫 SQL,在一些情境之下是非常方便的。

首先,使用 NPM 安裝 prisma@prisma/client

npm install -D prisma @prisma/client

打開終端機 (Terminal) 於 Nuxt 專案目錄中, 使用下列指令,初始化一個 PrismaSchema

npx prisma ini

初始化完成後,會建立一個 schema.prisma 檔案。
https://ithelp.ithome.com.tw/upload/images/20221006/20152617cW4jIXKqrB.png

./prisma/schema.prisma 檔案內容如下,這裡就是定義我們資料庫位置與 Schema 的地方,之後我們就可以透過 PrismaClient 使用 ORM 來操作資料庫。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

我們調整 ./prisma/schema.prisma 檔案內容,將 datasource 替換為 SQLite 並儲存在本地的 ./dev.db

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

你也可以將 provider 替換為 PostgreSQL 或 MySQL 等,可以參考這裡,但要注意可能後面定義的 Schema 語法會略微不同。

接下來我們定義一個 User 的資料表,在 schema.prisma 撰寫如下:

model User {
  id             String   @id @default(uuid())
  providerName   String?
  providerUserId String?
  nickname       String   @default("User")
  email          String   @unique
  password       String?
  avatar         String?
  emailVerified  Boolean  @default(false)
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
}

這張 User 資料表,將用作於部落格的登入使用者做使用,也可以視為是會員系統使用的資料表,大家也可以依據需求來擴增欄位,以下稍微講述一下各個欄位將作為何用。

  • id: 預設為 UUID,為使用者的唯一識別。
  • providerName: 作為第三方登入的供應商記錄使用,例如該名使用者使用 Google OAuth 註冊登入,我會在欄位就會填上 google。如果為空值 (null) 表示使用者用電子信箱註冊登入。
  • providerUserId: 與 providerName 搭配使用,第三方供應商通常也會有一組專屬於使用者的 Id,以此我們就可以來比對登入的是哪位使用者。如果為空值 (null) 表示使用者用電子信箱註冊登入。
  • nickname: 使用者暱稱,預設值為字串 User
  • email: 使用者登入的電子信箱,這裡我將欄位設定為 @unique 表示,電子信箱是系統中唯一。
  • password:使用者密碼的雜湊值,如果使用第三方註冊登入,則該欄位為 空值 (null)
  • avatar: 使用者的頭像,存放圖片網址。
  • emailVerified: 布林值,預設為 false,表示使用者的電子信箱是尚未通過驗證。
  • createdAt: 使用者建立時間,預設為插入該筆資料的時間。
  • updatedAt: 使用者更新個人資料的時間,預設為更新該筆資料的時間。

./prisma/schema.prisma 檔案內容看起來如下:

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id             String   @id @default(uuid())
  providerName   String?
  providerUserId String?
  nickname       String   @default("User")
  email          String   @unique
  password       String?
  avatar         String?
  emailVerified  Boolean  @default(false)
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
}

當我們調整好 schema 後,就可以執行下列指令,來初始化資料庫,Prisma 會依照 schema.prism 來幫我們建立對應的資料表。

npx prisma db push

https://ithelp.ithome.com.tw/upload/images/20221006/201526174FZ8Et1bhY.png

初始化完畢後,你可以登入你的資料庫查看是否建立成功,也可使用 Prisma 提供的 Prisma Studio 來快速的檢視與操作資料庫內的資料。Prisma Studio 已經內建在 prisma 套件中,執行以下指令後,就會啟動一個 Web 服務,如 http://localhost:5555,我們就可以在網頁中查看資料庫內的資料囉!

npx prisma studio

https://ithelp.ithome.com.tw/upload/images/20221006/20152617ZCXNUWIGC0.png

可以在 Prisma Studio 看到我們建立的 User 資料表,也將是我們稍後使用 Prisma 操作 ORM 所對應的 User Model
https://i.imgur.com/2ycrQXe.gif

最後記得執行下列指令來產生 Prisma Client,這樣我們就可以在 Nuxt 3 中使用 Prisma Client 操作資料庫囉!

npx prisma generate

https://ithelp.ithome.com.tw/upload/images/20221006/20152617YGNaQ2LGpg.png

Nuxt 3 操作 Prisma Client 建立一個使用者至資料庫

我們接下來就能使用如下程式碼建立 Prisma Client,後續可以用來來操作 Model,更多的 Prisma Client API 可以參考官方文件

import { PrismaClient } from '@prisma/client'

const prismaClient = new PrismaClient()

我們建立一隻 Server API,新增 ./server/api/test-create-user.get.js,用來測試收到請求後建立一個測試使用者,詳細的程式碼如下:

import { PrismaClient } from '@prisma/client'

const prismaClient = new PrismaClient()

export default defineEventHandler(() => {
  const user = prismaClient.user.create({
    data: {
      providerName: null,
      providerUserId: null,
      nickname: 'Ryan',
      email: 'ryanchien8125@gmail.com',
      password: '這裡要放密碼的雜湊值',
      avatar: '',
      emailVerified: true
    }
  })

  return user
})

當我們送出 /api/test-create-user 後,後端會使用 Prisma Client 操作 User Model,我們就能使用 ORM 來建立出使用者的資料庫記錄。
https://i.imgur.com/5YdAGjh.gif

Nuxt 3 使用者註冊帳號

我們將前面系列文章,所串接的 Google OAuth 及 Cookie 做一個結合,讓使用者透過 Google Auth 登入後可以自動的註冊建立使用者或登入產生 Access Token。

./server/api/auth/google.post.js 程式碼如下:

import { OAuth2Client } from 'google-auth-library'
import jwt from 'jsonwebtoken'
import db from '@/server/db'

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'
    })
  }

  let userRecord = await db.user.getUserByEmail({
    email: userInfo.email
  })

  if (userRecord) {
    if (
      (userRecord.providerName === 'google' && userRecord.providerUserId === userInfo.sub) === false
    ) {
      throw createError({
        statusCode: 400,
        statusMessage: 'This email address does not apply to this login method'
      })
    }
  } else {
    userRecord = await db.user.createUser({
      providerName: 'google',
      providerUserId: userInfo.sub,
      nickname: userInfo.name,
      email: userInfo.email,
      password: null,
      avatar: userInfo.picture,
      emailVerified: userInfo.email_verified
    })
  }

  const jwtTokenPayload = {
    id: userRecord.id
  }

  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: userRecord.id,
    provider: {
      name: userRecord.providerName,
      userId: userRecord.providerUserId
    },
    nickname: userRecord.nickname,
    avatar: userRecord.avatar,
    email: userRecord.email
  }
})

程式碼內容稍微有一點多,但講解一下流整與概念:

  1. 當前端 Google OAuth 登入成功後,將回傳的 Google access_token 傳送至這隻 API,並使用 Google API 取得使用者資訊。
  2. db.user.getUserByEmail 這個是我封裝的方法,裡面對應著 Prisma 的 ORM 操作,如果你想也可以在這邊替換你的資料庫操作邏輯,主要這個方法,就是依照使用者的 Email 回傳資料庫內是否存在一筆符合的使用者記錄
  3. 如果存在,我會判斷 provider 是否符合 Google 的使用者資訊,否則判斷為應該是用電子信箱註冊的使用者。
  4. 如果不存在,則建立一個新的使用者至資料庫內,建立時不需要傳入 id 資料庫因為設定為自動產生 UUID。
  5. 最後就是產生使用者的 JWT 並設定在 cookie 之中。

另外,我也實作了使用電子信箱直接註冊的方式,./server/api/auth/register.post.js 程式碼如下:

import bcrypt from 'bcrypt'
import db from '@/server/db'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  let userRecord = await db.user.getUserByEmail({
    email: body.email
  })

  if (userRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'A user with that email address already exists'
    })
  }

  userRecord = await db.user.createUser({
    providerName: null,
    providerUserId: null,
    nickname: body.nickname,
    email: body.email,
    password: bcrypt.hashSync(body.password, 10),
    avatar: null,
    emailVerified: false
  })

  return {
    id: userRecord.id,
    nickname: userRecord.nickname,
    email: userRecord.email
  }
})

使用電子信箱與密碼註冊的流程很簡單,就是判斷是否存在相同信箱的使用者,不存在的話就為它建立一筆紀錄。
這邊要注意的是,會員系統或牽扯到帳號密碼相關的,請一律使用雜湊演算法,例如 BCryptArgon2,為使用者的密碼做 Hash,不要再存明碼在資料庫之中囉,以免發生資安事件時,造成不可挽回的悲劇。

順便也實作一下使用電子信箱與密碼登入的 API,./server/api/auth/login.post.js 程式碼如下:

import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import db from '@/server/db'

const runtimeConfig = useRuntimeConfig()

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  const userRecord = await db.user.getUserByEmail({
    email: body.email
  })

  if (!userRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Email or password is incorrect'
    })
  }

  if ((await bcrypt.compare(body.password, userRecord.password)) !== true) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Email or password is incorrect'
    })
  }

  const jwtTokenPayload = {
    id: userRecord.id
  }

  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: userRecord.id,
    provider: {
      name: userRecord.providerName,
      userId: userRecord.providerUserId
    },
    nickname: userRecord.nickname,
    avatar: userRecord.avatar,
    email: userRecord.email
  }
})

使用 Google OAuth 登入

https://i.imgur.com/iGlXO9A.gif

使用電子信箱與密碼登入

https://i.imgur.com/wXTyAXw.gif

結合 Pinia 儲存使用者資料

我們可以結合 Pinia 來將使用者的資料持久話儲存在 Local Storage 之中,這樣就可以在前端儲存使用者登入的狀態,例如導覽列的頭像、信箱,就可以從 Store 中拿出來囉。

建立 ./server/profile.get.js 檔案,用來取得使用者料:

import jwt from 'jsonwebtoken'
import db from '@/server/db'

const runtimeConfig = useRuntimeConfig()

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

  let userInfo = null

  try {
    const { data } = jwt.verify(jwtToken, runtimeConfig.jwtSignSecret)
    userInfo = data
  } catch (e) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }

  if (!userInfo?.id) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }

  const userRecord = await db.user.getUserById({
    id: userInfo.id
  })

  if (!userRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Could not find user.'
    })
  }

  return {
    id: userRecord.id,
    provider: {
      name: userRecord.providerName,
      userId: userRecord.providerUserId
    },
    nickname: userRecord.nickname,
    avatar: userRecord.avatar,
    email: userRecord.email
  }
})

新增一個 userstore./stores/user.js 內容如下:

import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    profile: {
      id: null,
      provider: {
        name: null,
        userId: null
      },
      nickname: null,
      avatar: null,
      email: null
    }
  }),
  actions: {
    async refreshUserProfile() {
      const { data, error } = await useFetch('/api/user/profile', { initialCache: false })
      if (data.value) {
        this.profile = data.value
      } else {
        return error.value?.data?.message ?? '未知錯誤'
      }
    }
  },
  persist: {
    enabled: true,
    strategies: [
      {
        key: 'user',
        storage: process.client ? localStorage : null
      }
    ]
  }
})

我們就可以直接使用 refreshUserProfile() 來發送請求至 /api/user/profile 取得最新的使用者資料來更新 store。

import { useUserStore } from '@/stores/user'
const userStore = useUserStore()

userStore.refreshUserProfile()

https://i.imgur.com/3WMsoN7.gif

使用伺服器中間件來驗證 JWT

我們的會員系統在登入後,會產生一組 JWT 放置於 cookie 之中,在後端 API 使用時都要在呼叫 getCookie() 來解析 cookie,所以我們可以將驗證 JWT 的流程,放置在伺服器中間件 (middleware) 之中,後端收到的每個請求就會經過這個中間件,只要有夾帶 access_token 的 cookie 就會進行驗證解析出 JWT 所含的 payload id,即為使用者的 ID。

建立 ./server/middleware/auth.js 檔案,內容如下:

import jwt from 'jsonwebtoken'

const runtimeConfig = useRuntimeConfig()

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

  if (!jwtToken) {
    return
  }

  let userInfo = null

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

    userInfo = data
    if (userInfo?.id) {
      event.context.auth = {
        user: {
          id: userInfo.id
        }
      }
    }
  } catch (e) {
    console.error('Invalid token')
  }
})

伺服器的中間件只要定義在 ./server/middleware 目錄下就會自動被載入,之後在每個 Server API 收到請求,中間件只要有成功驗證並解析 JWT,就會在 event.context.auth 添加使用者資訊,之後在 Server API 的處理函數中,就可以以下列程式碼進行使用。

export default defineEventHandler(async (event) => {
  const user = event.context?.auth?.user
})

調整後的 ./server/profile.get.js 檔案,就會乾淨許多囉!

import db from '@/server/db'

export default defineEventHandler(async (event) => {
  const user = event.context?.auth?.user

  if (!user?.id) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }

  const userRecord = await db.user.getUserById({
    id: user.id
  })

  if (!userRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Could not find user.'
    })
  }

  return {
    id: userRecord.id,
    provider: {
      name: userRecord.providerName,
      userId: userRecord.providerUserId
    },
    nickname: userRecord.nickname,
    avatar: userRecord.avatar,
    email: userRecord.email
  }
})

小結

這篇文章主要為了實作部落格,我們使用 Prisma 快速的建立資料庫環境,也方便大家可以下載範例程式碼,就可以在自己的電腦上運作 SQLite。也結合 Pinia 來將使用者的資料進行持久化的儲存,這樣我們就可以實作出如判斷使用者是否登入或是建立導覽列上的登入狀態。


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

範例程式碼

參考資料


上一篇
[Day 20] Nuxt 3 Cookie 的設置與 JWT 的搭配
下一篇
[Day 22] Nuxt 3 實作部落格 - 導覽列模板與新增文章
系列文
Nuxt 3 學習筆記30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言