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