本教程將解釋如何使用Cookies和JWT在GraphQL API上進行身份驗證,並使用Apollo作為驅動。

我們將創建一個私有區域,根據您的用戶登錄顯示不同的信息。

具體而言,以下是步驟:

  • 在客戶端創建一個登錄表單
  • 發送登錄數據到服務器
  • 驗證用戶並返回JWT
  • 將JWT存儲在cookie中
  • 使用JWT進行對GraphQL API的進一步請求

本教程的代碼可在GitHub上找到:https://github.com/flaviocopes/apollo-graphql-client-server-authentication-jwt

讓我們開始。

警告!此教程適用於較舊版本的Apollo。現在Apollo使用@apollo/xxx而不是apollo-xxx,我會在更新之前先做一些研究:)

啟動客戶端應用程序

首先,我們使用create-react-app來創建客戶端部分,執行以下命令以在空文件夾中創建應用程序:

npx create-react-app client

然後進入該文件夾,並通過以下命令安裝所需的所有庫:

cd client
npm install apollo-client apollo-boost apollo-link-http apollo-cache-inmemory react-apollo apollo-link-context @reach/router js-cookie graphql-tag

登錄表單

我們首先創建一個登錄表單。在src文件夾中創建一個名為Form.js的文件,並將以下內容添加到其中:

import React, { useState } from 'react'
import { navigate } from '@reach/router'

const url = 'http://localhost:3000/login'

const Form = () => {
 const [email, setEmail] = useState('')
 const [password, setPassword] = useState('')

 const submitForm = event => {
 event.preventDefault()

 const options = {
 method: 'post',
 headers: {
 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8'
 },
 body: `email=${email}&password=${password}`
 }

 fetch(url, options)
 .then(response => {
 if (!response.ok) {
 if (response.status === 404) {
 alert('Email not found, please retry')
 }
 if (response.status === 401) {
 alert('Email and password do not match, please retry')
 }
 }
 return response
 })
 .then(response => response.json())
 .then(data => {
 if (data.success) {
 document.cookie = 'token=' + data.token
 navigate('/private-area')
 }
 })
 }

 return (
 <div>
 <form onSubmit={submitForm}>
 <p>Email: <input type="text" onChange={event => setEmail(event.target.value)} /></p>
 <p>Password: <input type="password" onChange={event => setPassword(event.target.value)} /></p>
 <p><button type="submit">Login</button></p>
 </form>
 </div>
 )
}

export default Form

在這裡,我假設服務器運行在localhost,使用HTTP協議,端口為3000

我使用了React Hooks和Reach Router。這裡沒有Apollo代碼。只有一個表單和一些代碼,在我們成功驗證時註冊一個新的cookie。

使用Fetch API,當用戶提交表單時,我們通過POST請求將表單數據發送到/login REST端點。

當服務器確認我們已經登錄時,它將JWT令牌存儲到cookie中,並導航到/private-area URL,該URL我們還沒有創建。

將表單添加到應用程序

讓我們編輯應用程序的index.js文件,使用此組件:

import React from 'react'
import ReactDOM from 'react-dom'
import { Router } from '@reach/router'
import Form from './Form'

ReactDOM.render(
 <Router>
 <Form path="/" />
 </Router>
 document.getElementById('root')
)

服務器端

接下來,讓我們切換到服務器端。

創建一個server文件夾,然後執行npm init -y命令,以創建一個預配置的package.json文件。

現在運行以下命令:

npm install express apollo-server-express cors bcrypt jsonwebtoken

接下來,創建一個app.js文件。

我們首先創建一些虛擬數據。一個用戶:

const users = [{
 id: 1,
 name: 'Test user',
 email: '[[email protected]](/cdn-cgi/l/email-protection)',
 password: '$2b$10$ahs7h0hNH8ffAVg6PwgovO3AVzn1izNFHn.su9gcJnUWUzb2Rcb2W' // = ssseeeecrreeet
}]

還有一些代辦事項:

const todos = [
 {
 id: 1,
 user: 1,
 name: 'Do something'
 },
 {
 id: 2,
 user: 1,
 name: 'Do something else'
 },
 {
 id: 3,
 user: 2,
 name: 'Remember the milk'
 }
]

前兩個代辦事項分配給前面定義的用戶。第三項屬於另一個用戶。我們的目標是登錄用戶並只顯示屬於他們的代辦事項。

出於示例目的,密碼哈希是我手動使用bcrypt.hash()生成的,對應於ssseeeecrreeet字符串。有關bcrypt的更多信息請參見這裡。實際上,您會將用戶和待辦事項存儲在數據庫中,並在用戶註冊時自動生成密碼哈希。

處理登錄過程

現在,我們需要處理登錄過程。

我們加載一系列我們將使用的庫,初始化Express以使用CORS(跨來源資源共享)。

這是我們初始化一些庫的方式:

const express = require('express')
const cors = require('cors')
const bcrypt = require('bcrypt')
const jwt = require('jsonwebtoken')
const app = express()
app.use(cors())
app.use(express.urlencoded({
 extended: true
}))

接下來,我們定義了一個用於JWT簽名的SECRET_KEY,並定義了/login POST端點的處理程序。由於我們將在代碼中使用await,因此此處有一個async關鍵字。我從請求體中提取了電子郵件和密碼字段,然後在我們的“數據庫”中查找用戶。

如果通過電子郵件找不到用戶,我會發送一個錯誤消息。

接下來,我檢查密碼是否不匹配,如果不匹配,則發送一個錯誤消息。

如果一切順利,我使用jwt.sign()方法生成令牌,並將其作為響應的一部分發送給客戶端。

這是代碼:

const SECRET_KEY = 'secret!'

app.post('/login', async (req, res) => {
 const { email, password } = req.body
 const theUser = users.find(user => user.email === email)

 if (!theUser) {
 res.status(404).send({
 success: false,
 message: `Could not find account: ${email}`,
 })
 return
 }

 const match = await bcrypt.compare(password, theUser.password)
 if (!match) {
 //return error to user to let them know the password is incorrect
 res.status(401).send({
 success: false,
 message: 'Incorrect credentials',
 })
 return
 }

 const token = jwt.sign(
 { email: theUser.email, id: theUser.id },
 SECRET_KEY,
 )

 res.send({
 success: true,
 token: token,
 })
})

接下來,我們可以啟動Express應用程序:

app.listen(3000, () =>
 console.log('Server listening on port 3000')
)

私有區域

此時,我們可以將令牌添加到cookies中,並轉到/private-area URL。

那個URL上有什麼?目前什麼也沒有!讓我們添加一個組件處理它,名為src/PrivateArea.js

import React from 'react'

const PrivateArea = () => {
 return (
 <div>
 Private area!
 </div>
 )
}

export default PrivateArea

index.js中,我們可以將其添加到應用程序中:

import React from 'react'
import ReactDOM from 'react-dom'
import { Router } from '@reach/router'
import Form from './Form'
import PrivateArea from './PrivateArea'

ReactDOM.render(
 <Router>
 <Form path="/" />
 <PrivateArea path="/private-area" />
 </Router>
 document.getElementById('root')
)

我使用了很好用的js-cookie庫來輕鬆處理cookies。在使用它之後,我檢查cookies中是否存在token。如果不存在,返回到登錄表單:

import React from 'react'
import Cookies from 'js-cookie'
import { navigate } from '@reach/router'

const PrivateArea = () => {
 if (!Cookies.get('token')) {
 navigate('/')
 }

 return (
 <div>
 Private area!
 </div>
 )
}

export default PrivateArea

現在,理論上我們可以使用GraphQL API了!但是我們還沒有這樣的東西。讓我們開始創建GraphQL API。

GraphQL API

我在服務器端的一個文件中完成了所有操作。這個文件並不大,因為我們只完成了一些小的設置。

我在文件頂部添加了以下代碼:

const {
 ApolloServer,
 gql,
 AuthenticationError,
} = require('apollo-server-express')

這些代碼為我們提供了所有創建Apollo GraphQL服務器所需的功能。

我需要定義3個東西:

  • GraphQL模式(schema)
  • resolvers
  • 上下文(context)

這是模式。我定義了User類型,它代表我們在用戶對象中的內容。然後是Todo類型,最後是Query類型,它定義了我們可以直接查詢的列表。

const typeDefs = gql`
 type User {
 id: ID!
 email: String!
 name: String!
 password: String!
 }

 type Todo {
 id: ID!
 user: Int!
 name: String!
 }

 type Query {
 todos: [Todo]
 }
`

Query類型有一個字段,我們需要為它定義一個解析器。這是解析器:

const resolvers = {
 Query: {
 todos: (root, args) => {
 return todos.filter(todo => todo.user === id)
 }
 }
}

然後是上下文,在上面我們驗證了令牌,如果無效則報錯,並從中獲取idemail值。這是我們知道在與API交互的方式:

const context = ({ req }) => {
 const token = req.headers.authorization || ''

 try {
 return { id, email } = jwt.verify(token.split(' ')[1], SECRET_KEY)
 } catch (e) {
 throw new AuthenticationError(
 'Authentication token is invalid, please log in',
 )
 }
}

現在,我們可以在解析器中使用idemail值。這就是我們在上面使用的id值來源。

現在我們需要將Apollo添加到Express中作為中間件,這樣我們的服務器端部分就完成了!

const server = new ApolloServer({ typeDefs, resolvers, context })
server.applyMiddleware({ app })

Apollo客戶端

現在我們準備初始化Apollo Client!

在客戶端的index.js文件中,我添加了這些庫:

import { ApolloClient } from 'apollo-client'
import { createHttpLink } from 'apollo-link-http'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { ApolloProvider } from 'react-apollo'
import { setContext } from 'apollo-link-context'
import { navigate } from '@reach/router'
import Cookies from 'js-cookie'
import gql from 'graphql-tag'

我初始化了一個指向GraphQL API服務器的HttpLink對象,該服務器在localhost的3000端口上監聽,使用/graphql結束點。

HttpLink提供了描述我們如何獲取GraphQL操作的結果以及如何處理響應的方式。

const httpLink = createHttpLink({ uri: 'http://localhost:3000/graphql' })

const authLink = setContext((\_, { headers }) => {
 const token = Cookies.get('token')

 return {
 headers: {
 ...headers,
 authorization: `Bearer ${token}`
 }
 }
})

const client = new ApolloClient({
 link: authLink.concat(httpLink),
 cache: new InMemoryCache()
})

如果有一個令牌,我導航到私有區域:

if (Cookies.get('token')) {
 navigate('/private-area')
}

最後,我們使用ApolloProvider組件作為父組件,將所有內容包裹在我們定義的應用程序中。這樣我們可以在任何子組件中訪問client對象。特別是在PrivateArea組件中,很快我們就會使用到它!

ReactDOM.render(
 <ApolloProvider client={client}>
 <Router>
 <Form path="/" />
 <PrivateArea path="/private-area" />
 </Router>
 </ApolloProvider>,
 document.getElementById('root')
)

私有區域

所以我們到了最後一步。現在我們終於可以進行GraphQL查詢了!

這是我們現在有的內容:

import React from 'react'
import Cookies from 'js-cookie'
import { navigate } from '@reach/router'

const PrivateArea = () => {
 if (!Cookies.get('token')) {
 navigate('/')
 }

 return (
 <div>
 Private area!
 </div>
 )
}

export default PrivateArea

我將以下兩個項目從Apollo導入:

import { gql } from 'apollo-boost'
import { Query } from 'react-apollo'

而不是

 return (
 <div>
 Private area!
 </div>
 )

我將使用Query組件,並傳遞一個GraphQL查詢。組件body中傳遞一個函數,該函數接收一個帶有三個屬性的對象:loadingerrordata

當數據尚不可用時,loading為true,我們可以向用戶添加一條消息。如果存在任何錯誤,我們將獲取它,否則我們將在data對像中獲取我們的待辦事項,並且我們可以遍歷它們以向用戶渲染代辦事項!

 return (
 <div>
 <Query
 query={gql`
 {
 todos {
 id
 name
 }
 }
 `}
 >
 {({ loading, error, data }) => {
 if (loading) return <p>Loading...</p>
 if (error) {
 navigate('/')
 return <p></p>
 }
 return <ul>{data.todos.map(item => <li key={item.id}>{item.name}</li>)}</ul>
 }}
 </Query>
 </div>
 )

為了提高安全性,使用HTTPOnly cookies

現在一切正常後,我想稍微改變一下代碼的工作方式,並使用HTTPOnly cookies。這種特殊的cookie更安全,因為我們無法使用JavaScript訪問它,因此無法被第三方腳本竊取並作為攻擊目標使用。

現在事情有點複雜了,所以我在末尾添加了這些代碼。

所有代碼都可以在GitHub上找到:https://github.com/flaviocopes/apollo-graphql-client-server-authentication-jwt,目前我描述的所有內容都可以在此提交中找到

這個教程最後一部分的代碼可以在此提交中找到

首先,在客戶端的Form.js中,不再將令牌添加到cookie中,而是添加一個signedin cookie。

將此刪除

document.cookie = 'token=' + data.token

並添加

document.cookie = 'signedin=true'

接下來,在fetch選項中,我們必須添加

credentials: 'include'

否則,fetch將無法將從服務器獲取的cookies存儲在瀏覽器中。

現在在PrivateArea.js文件中,我們不再檢查token cookie,而是檢查signedin cookie:

將此刪除

if (!Cookies.get('token')) {

並添加

if (!Cookies.get('signedin')) {

回到服務器端,我們需要首先使用npm install cookie-parser命令來安裝cookie-parser庫,然後不再將令牌發送回客戶端:

將此

res.send({
 success: true,
 token: token,
})

改為

res.send({
 success: true
})

將JWT令牌作為HTTPOnly cookie發送給用戶:

res.cookie('jwt', token, {
 httpOnly: true
 // secure: true, //在HTTPS上使用
 // domain: 'example.com', //設置您的域名
})

(在生產環境中,將secure選項設置為HTTPS,並設置域名)

然後,我們需要將CORS中間件設置為使用cookies。否則,在管理GraphQL數據時,很快就會出現問題,因為cookies就會消失。

將此

app.use(cors())

改為

const corsOptions = {
 origin: 'http://localhost:3001', //更改為自己的客戶端URL
 credentials: true
}


app.use(cors(corsOptions))
app.use(cookieParser())

回到客戶端,在index.js中告訴Apollo Client將credentials(cookies)包含在其請求中。切換

const httpLink = createHttpLink({ uri: 'http://localhost:3000/graphql' })

const httpLink = createHttpLink({ uri: 'http://localhost:3000/graphql', credentials: 'include' })

然後完全刪除authLink定義:

const authLink = setContext((\_, { headers }) => {
 const token = Cookies.get('token')

 return {
 headers: {
 ...headers,
 authorization: `Bearer ${token}`
 }
 }
})

因為我們不再需要它。我們只需將httpLink傳遞給new ApolloClient(),因為我們不需要更多自定義的身份驗證設置:

const client = new ApolloClient({
 link: httpLink,
 cache: new InMemoryCache()
})

回到服務器端,這是最後的拼圖!打開index.js文件,將此:

const token = req.headers.authorization || ''

改為

const token = req.cookies['jwt'] || ''

並禁用Apollo Server內置的CORS處理,因為它會覆蓋我們在Express中已經完成的處理。將

const server = new ApolloServer({ typeDefs, resolvers, context })
server.applyMiddleware({ app })

改為

const server = new ApolloServer({ typeDefs, resolvers, context,
 cors: false })
server.applyMiddleware({ app, cors: false })