如何使用GraphQL Cookies和JWT进行认证

495 阅读6分钟

在本教程中,我将解释如何使用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 clientnpm 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() 调用生成令牌,将emailid 作为用户数据传递,并将其作为响应的一部分发送给客户端。

下面是代码。

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)
    }
  }
}

然后是上下文,在这里我们基本上验证令牌,如果无效就出错,我们从它那里得到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客户端了!

在客户端的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,errordata

当数据还不可用时,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 })