在本教程中,我将解释如何使用Apollo来处理GraphQLAPI的登录机制。
我们将创建一个私人区域,根据用户的登录情况,显示不同的信息。
详细来说,有以下几个步骤。
- 在客户端创建一个登录表格
- 将登录数据发送到服务器上
- 验证用户并发送一个JWT回来
- 将JWT存储在一个cookie中
- 将JWT用于对GraphQL API的进一步请求
本教程的代码可在GitHub上找到:github.com/flaviocopes…。
让我们开始吧。
警告!这个教程是旧的。Apollo现在使用的是
@apollo/xxx,而不是apollo-xxx,在我更新之前请做好研究:)
我启动了客户端应用程序
让我们使用create-react-app 创建客户端部分,在一个空文件夹中运行npx create-react-app client 。
然后调用cd client 和npm install 所有我们需要的东西,这样我们以后就不需要再回去了。
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,当用户发送表单时,我在/login REST端点上用POST请求联系服务器。
当服务器确认我们已经登录时,它将把JWT令牌存储到一个cookie中,并将导航到我们还没有建立的/private-area 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: 'your@email.com',
password: '$2b$10$ahs7h0hNH8ffAVg6PwgovO3AVzn1izNFHn.su9gcJnUWUzb2Rcb2W' // = ssseeeecrreeet
}]
和一些TODO项目。
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'
}
]
其中前2项是分配给我们刚刚定义的用户的。第三个项目属于另一个用户。我们的目标是登录用户,并且只显示属于他们的TODO项目。
在这个例子中,密码哈希值是由我用bcrypt.hash() 手动生成的,与ssseeeecrreeet 字符串相对应。关于bcrypt的更多信息在这里。在实践中,你将在数据库中存储用户和todos,密码哈希值在用户注册时自动创建。
处理登录过程
现在,我想处理登录过程。
我加载了一堆我们要使用的库,并初始化Express以使用CORS,这样我们就可以从我们的客户端应用程序中使用它(因为它在另一个端口上),我还添加了中间件来解析urlencoded数据。
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端点处理器。有一个async 关键字,因为我们将在代码中使用await 。我从请求体中提取电子邮件和密码字段,并在我们的users "数据库 "中查找用户。
如果用户的电子邮件没有被找到,我就发送一个错误信息回去。
接下来,我检查密码是否与我们拥有的哈希值不匹配,如果是的话,我将发送一个错误信息。
如果一切顺利,我使用jwt.sign() 调用生成令牌,将email 和id 作为用户数据传递,并将其作为响应的一部分发送给客户端。
下面是代码。
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 库来轻松处理cookie。使用它,我检查在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
在服务器端,我在一个单一的文件中做了所有的事情。它没有那么大,因为我们已经有了一些小东西。
我把这个添加到文件的顶部。
const {
ApolloServer,
gql,
AuthenticationError,
} = require('apollo-server-express')
这个文件提供了我们制作Apollo GraphQL服务器所需要的一切。
我需要定义3个东西。
- GraphQL模式
- 解析器
- 上下文
这里是模式。我定义了User 类型,它代表我们在用户对象中的内容。然后是Todo 类型,最后是Query 类型,它设置了我们可以直接查询的东西:todos 列表。
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客户端了!
在客户端的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'
我初始化了一个HttpLink对象,它指向GraphQL API服务器,在本地主机的3000端口上监听/graphql 端点,并使用它来设置ApolloClient 对象。
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 查询。在组件主体内,我们传递一个函数,该函数接收一个具有3个属性的对象。loading,error 和data 。
当数据还不可用时,loading 为真,我们可以向用户添加一条信息。如果有任何错误,我们将得到它,但除此之外,我们将在data 对象中得到我们的TO-DO项目,我们可以迭代它们来向用户呈现我们的项目!
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 cookie以提高安全性
现在事情已经开始工作了,我想稍微改变一下代码的工作方式,增加使用HTTPOnly cookie。这种特殊的cookie更安全,因为我们不能用JavaScript访问它,因此它不能被第三部分脚本窃取并作为攻击的目标。
现在事情有点复杂了,所以我在底部添加了这个。
所有的代码都可以在GitHub上找到,网址是github.com/flaviocopes…,而我到现在为止所描述的都可以在这个提交中找到。
最后这部分的代码可以在这个单独的提交中获得。
首先,在客户端,在Form.js ,而不是将令牌添加到一个cookie中,我添加了一个signedin cookie。
删除这个
document.cookie = 'token=' + data.token
并添加
document.cookie = 'signedin=true'
接下来,在fetch 的选项中,我们必须添加
否则,fetch 将不会在浏览器中存储它从服务器得到的cookie。
现在在PrivateArea.js 文件中,我们不检查token cookie,而是检查signedin cookie。
删除
if (!Cookies.get('token')) {
并添加
if (!Cookies.get('signedin')) {
让我们进入服务器部分。
首先将cookie-parser 库与npm install cookie-parser 一起安装,而不是向客户端发回令牌。
res.send({
success: true,
token: token,
})
只发送这个。
res.send({
success: true
})
我们把JWT令牌作为一个HTTPOnly cookie发送给用户。
res.cookie('jwt', token, {
httpOnly: true
//secure: true, //on HTTPS
//domain: 'example.com', //set your domain
})
(在生产中,在HTTPS上设置安全选项,同时设置域名)
接下来,我们需要将 CORS 中间件也设置为使用 cookie。否则,当我们管理GraphQL数据时,事情很快就会中断,因为cookie会消失。
改变
与
const corsOptions = {
origin: 'http://localhost:3001', //change with your own client URL
credentials: true
}
app.use(cors(corsOptions))
app.use(cookieParser())
回到客户端,在index.js ,我们告诉Apollo客户端在其请求中包括证书(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 ,在context 的函数定义中,将
const token = req.headers.authorization || ''
改为
const token = req.cookies['jwt'] || ''
并禁用Apollo服务器内置的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 })