本教程將解釋如何使用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)
}
}
}
然後是上下文,在上面我們驗證了令牌,如果無效則報錯,並從中獲取id
和email
值。這是我們知道誰在與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',
)
}
}
現在,我們可以在解析器中使用id
和email
值。這就是我們在上面使用的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中傳遞一個函數,該函數接收一個帶有三個屬性的對象:loading
,error
和data
。
當數據尚不可用時,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 })