/

如何使用GraphQL Cookies和JWT進行驗證

如何使用GraphQL Cookies和JWT進行驗證

本教程將解釋如何使用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來創建客戶端部分,執行以下命令以在空文件夾中創建應用程序:

1
npx create-react-app client

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

1
2
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的文件,並將以下內容添加到其中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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文件,使用此組件:

1
2
3
4
5
6
7
8
9
10
11
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文件。

現在運行以下命令:

1
npm install express apollo-server-express cors bcrypt jsonwebtoken

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

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

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

還有一些代辦事項:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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(跨來源資源共享)。

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

1
2
3
4
5
6
7
8
9
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()方法生成令牌,並將其作為響應的一部分發送給客戶端。

這是代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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應用程序:

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

私有區域

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

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

1
2
3
4
5
6
7
8
9
10
11
import React from 'react'

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

export default PrivateArea

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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。如果不存在,返回到登錄表單:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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

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

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

1
2
3
4
5
const {
ApolloServer,
gql,
AuthenticationError,
} = require('apollo-server-express')

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

我需要定義3個東西:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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類型有一個字段,我們需要為它定義一個解析器。這是解析器:

1
2
3
4
5
6
7
const resolvers = {
Query: {
todos: (root, args) => {
return todos.filter(todo => todo.user === id)
}
}
}

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

1
2
3
4
5
6
7
8
9
10
11
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中作為中間件,這樣我們的服務器端部分就完成了!

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

Apollo客戶端

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

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

1
2
3
4
5
6
7
8
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操作的結果以及如何處理響應的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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()
})

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

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

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

1
2
3
4
5
6
7
8
9
ReactDOM.render(
<ApolloProvider client={client}>
<Router>
<Form path="/" />
<PrivateArea path="/private-area" />
</Router>
</ApolloProvider>,
document.getElementById('root')
)

私有區域

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

這是我們現在有的內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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導入:

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

而不是

1
2
3
4
5
return (
<div>
Private area!
</div>
)

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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。

將此刪除

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

並添加

1
document.cookie = 'signedin=true'

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

1
credentials: 'include'

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

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

將此刪除

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

並添加

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

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

將此

1
2
3
4
res.send({
success: true,
token: token,
})

改為

1
2
3
res.send({
success: true
})

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

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

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

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

將此

1
app.use(cors())

改為

1
2
3
4
5
6
7
8
const corsOptions = {
origin: 'http://localhost:3001', //更改為自己的客戶端URL
credentials: true
}


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

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

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

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

然後完全刪除authLink定義:

1
2
3
4
5
6
7
8
9
10
const authLink = setContext((\_, { headers }) => {
const token = Cookies.get('token')

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

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

1
2
3
4
const client = new ApolloClient({
link: httpLink,
cache: new InMemoryCache()
})

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

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

改為

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

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

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

改為

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