使用 Node.js 和 GraphQL 创建自己的身份验证 API

Avatar of Deven Rathore
Deven Rathore

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费额度!

对于刚开始使用 GraphQL 的开发人员来说,身份验证是最具挑战性的任务之一。 其中涉及许多技术考量,包括什么 ORM 易于设置、如何生成安全令牌和散列密码,甚至使用什么 HTTP 库以及如何使用它。

在本文中,我们将重点关注 **本地身份验证**。 这是现代网站处理身份验证最流行的方式之一,它通过请求用户的 **电子邮件** 和 **密码** 来实现(而不是使用 Google 身份验证)。

此外,本文使用 Apollo Server 2、JSON Web Tokens (JWT) 和 Sequelize ORM 使用 Node 构建身份验证 API。

处理身份验证

例如,登录系统

  • **身份验证**用于识别或验证用户。
  • **授权**用于验证已认证用户可以访问的路由(或应用程序的部分)。

实现此功能的流程如下:

  1. 用户使用密码和电子邮件进行注册
  2. 用户的凭据存储在数据库中
  3. 注册完成后,用户将重定向到登录页面
  4. 用户在经过身份验证后,可以访问特定的资源
  5. 用户的状态存储在浏览器的任何一种存储介质中(例如 localStorage、cookie、session)或 JWT 中。

先决条件

在深入研究实现之前,以下是一些您需要遵循的事项。

依赖项

这是一个很大的列表,让我们深入了解一下

  • Apollo Server: 一个开源的 GraphQL 服务器,与任何类型的 GraphQL 客户端兼容。 在此项目中,我们不会使用 Express 作为我们的服务器。 相反,我们将利用 Apollo Server 的强大功能来公开我们的 GraphQL API。
  • bcryptjs: 我们希望在数据库中散列用户密码。 因此我们将使用 bcrypt。 它依赖于 Web Crypto APIgetRandomValues 接口来获取安全的随机数。
  • dotenv: 我们将使用 dotenv 从我们的 .env 文件加载环境变量。
  • jsonwebtoken: 用户登录后,每个后续请求都将包含 JWT,允许用户访问该令牌允许的路由、服务和资源。 jsonwebtoken 将用于生成 JWT,用于对用户进行身份验证。
  • nodemon: 一个工具,通过在检测到目录更改时自动重启 Node 应用程序来帮助开发基于 Node 的应用程序。 我们不希望每次代码发生更改时都关闭并启动服务器。 Nodemon 每次检查应用程序中的更改并自动重启服务器。
  • mysql2: Node 的 SQL 客户端。 我们需要它连接到我们的 SQL 服务器,以便我们可以运行迁移。
  • sequelize: Sequelize 是一个基于 Promise 的 Node ORM,适用于 Postgres、MySQL、MariaDB、SQLite 和 Microsoft SQL Server。 我们将使用 Sequelize 自动生成迁移和模型。
  • sequelize cli: 我们将使用 Sequelize CLI 运行 Sequelize 命令。 在终端中使用 yarn add --global sequelize-cli 全局安装它。

设置目录结构和开发环境

让我们创建一个全新的项目。 创建一个新文件夹,并在其中执行以下操作

yarn init -y

-y 标志表示我们选择对所有 yarn init 问题都选择“是”并使用默认值。

我们还应该在文件夹中放置一个 package.json 文件,因此让我们安装项目依赖项

yarn add apollo-server bcrpytjs dotenv jsonwebtoken nodemon sequelize sqlite3

接下来,让我们添加 Babeto 到我们的开发环境中

yarn add babel-cli babel-preset-env babel-preset-stage-0 --dev

现在,让我们配置 Babel。 在终端中运行 touch .babelrc。 这将创建一个 Babel 配置文件并打开它,在其中我们将添加以下内容

{
  "presets": ["env", "stage-0"]
}

如果我们的服务器启动并迁移数据,那就更好了。 我们可以通过使用以下内容更新 package.json 来实现自动化

"scripts": {
  "migrate": " sequelize db:migrate",
  "dev": "nodemon src/server --exec babel-node -e js",
  "start": "node src/server",
  "test": "echo \"Error: no test specified\" && exit 1"
},

这是我们此时完整的 package.json 文件

{
  "name": "graphql-auth",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "migrate": " sequelize db:migrate",
    "dev": "nodemon src/server --exec babel-node -e js",
    "start": "node src/server",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "apollo-server": "^2.17.0",
    "bcryptjs": "^2.4.3",
    "dotenv": "^8.2.0",
    "jsonwebtoken": "^8.5.1",
    "nodemon": "^2.0.4",
    "sequelize": "^6.3.5",
    "sqlite3": "^5.0.0"
  },
  "devDependencies": {
    "babel-cli": "^6.26.0",
    "babel-preset-env": "^1.7.0",
    "babel-preset-stage-0": "^6.24.1"
  }
}

现在我们的开发环境已设置好,让我们转向数据库,我们将在此存储数据。

数据库设置

我们将使用 MySQL 作为我们的数据库,并使用 Sequelize ORM 来处理我们的关系。 运行 sequelize init(假设您之前已全局安装它)。 该命令应该创建三个文件夹:/config /models/migrations 此时,我们的项目目录结构正在形成。

让我们配置我们的数据库。 首先,在项目根目录中创建一个 .env 文件并粘贴以下内容

NODE_ENV=development
DB_HOST=localhost
DB_USERNAME=
DB_PASSWORD=
DB_NAME=

然后转到我们刚刚创建的 /config 文件夹,并将其中的 config.json 文件重命名为 config.js。 然后,将以下代码放入其中

require('dotenv').config()
const dbDetails = {
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  host: process.env.DB_HOST,
  dialect: 'mysql'
}
module.exports = {
  development: dbDetails,
  production: dbDetails
}

在这里,我们读取在 .env 文件中设置的数据库详细信息。 process.env 是 Node 注入的一个全局变量,它用于表示系统环境的当前状态。

让我们使用适当的数据更新我们的数据库详细信息。 打开 SQL 数据库并创建一个名为 graphql_auth 的表。 我使用 Laragon 作为我的本地服务器,并使用 phpmyadmin 来管理数据库表。

无论您使用什么,我们都需要使用最新信息更新 .env 文件

NODE_ENV=development
DB_HOST=localhost
DB_USERNAME=graphql_auth
DB_PASSWORD=
DB_NAME=<your_db_username_here>

让我们配置 Sequelize。 在项目的根目录中创建一个 .sequelizerc 文件并粘贴以下内容

const path = require('path')


module.exports = {
  config: path.resolve('config', 'config.js')
}

现在让我们将我们的配置集成到模型中。 转到 /models 文件夹中的 index.js 并编辑 config 变量。

const config = require(__dirname + '/../../config/config.js')[env]

最后,让我们编写我们的模型。 对于此项目,我们需要一个 User 模型。 让我们使用 Sequelize 自动生成模型。 以下是我们需要在终端中运行以设置它的内容

sequelize model:generate --name User --attributes username:string,email:string,password:string

让我们编辑为我们创建的模型。 转到 /models 文件夹中的 user.js 并粘贴以下内容

'use strict';
module.exports = (sequelize, DataTypes) => {
  const User = sequelize.define('User', {
    username: {
      type: DataTypes.STRING,
    },
    email: {
      type: DataTypes.STRING,  
    },
    password: {
      type: DataTypes.STRING,
    }
  }, {});
  return User;
};

在这里,我们为用户名、电子邮件和密码创建了属性和字段。 让我们运行迁移以跟踪架构中的更改

yarn migrate

现在让我们编写模式和解析器。

将模式和解析器与 GraphQL 服务器集成

在本节中,我们将定义我们的模式,编写解析器函数并在我们的服务器上公开它们。

模式

在 src 文件夹中,创建一个名为 /schema 的新文件夹,并在其中创建一个名为 schema.js 的文件。 粘贴以下代码

const { gql } = require('apollo-server')
const typeDefs = gql`
  type User {
    id: Int!
    username: String
    email: String!
  }
  type AuthPayload {
    token: String!
    user: User!
  }
  type Query {
    user(id: Int!): User
    allUsers: [User!]!
    me: User
  }
  type Mutation {
    registerUser(username: String, email: String!, password: String!): AuthPayload!
    login (email: String!, password: String!): AuthPayload!
  }
`
module.exports = typeDefs

在这里,我们从 apollo-server 导入了 graphql-tag。 Apollo Server 要求使用 gql 包装我们的模式。

解析器

src 文件夹中,创建一个名为 /resolvers 的新文件夹,并在其中创建一个名为 resolver.js 的文件。 粘贴以下代码

const bcrypt = require('bcryptjs')
const jsonwebtoken = require('jsonwebtoken')
const models = require('../models')
require('dotenv').config()
const resolvers = {
    Query: {
      async me(_, args, { user }) {
        if(!user) throw new Error('You are not authenticated')
        return await models.User.findByPk(user.id)
      },
      async user(root, { id }, { user }) {
        try {
          if(!user) throw new Error('You are not authenticated!')
          return models.User.findByPk(id)
        } catch (error) {
          throw new Error(error.message)
        }
      },
      async allUsers(root, args, { user }) {
        try {
          if (!user) throw new Error('You are not authenticated!')
          return models.User.findAll()
        } catch (error) {
          throw new Error(error.message)
        }
      }
    },
    Mutation: {
      async registerUser(root, { username, email, password }) {
        try {
          const user = await models.User.create({
            username,
            email,
            password: await bcrypt.hash(password, 10)
          })
          const token = jsonwebtoken.sign(
            { id: user.id, email: user.email},
            process.env.JWT_SECRET,
            { expiresIn: '1y' }
          )
          return {
            token, id: user.id, username: user.username, email: user.email, message: "Authentication succesfull"
          }
        } catch (error) {
          throw new Error(error.message)
        }
      },
      async login(_, { email, password }) {
        try {
          const user = await models.User.findOne({ where: { email }})
          if (!user) {
            throw new Error('No user with that email')
          }
          const isValid = await bcrypt.compare(password, user.password)
          if (!isValid) {
            throw new Error('Incorrect password')
          }
          // return jwt
          const token = jsonwebtoken.sign(
            { id: user.id, email: user.email},
            process.env.JWT_SECRET,
            { expiresIn: '1d'}
          )
          return {
           token, user
          }
      } catch (error) {
        throw new Error(error.message)
      }
    }
  },


}
module.exports = resolvers

有很多代码,让我们看看那里发生了什么。

首先,我们导入了模型 `bcrypt` 和 `jsonwebtoken`,然后初始化了环境变量。

接下来是解析器函数。在查询解析器中,我们有三个函数(`me`、`user` 和 `allUsers`)。

  • `me` 查询获取当前已登录用户的详细信息。它接受一个 `user` 对象作为上下文参数。**上下文** 用于提供对我们数据库的访问权限,该数据库用于根据查询中提供的 **参数** 中的 ID 加载用户数据。
  • `user` 查询根据用户的 ID 获取用户的详细信息。它接受 `id` 作为上下文参数和一个 `user` 对象。
  • `alluser` 查询返回所有用户的详细信息。

如果用户状态为 `loggedIn`,则 `user` 将是一个对象;如果用户未登录,则为 `null`。我们将在我们的变异中创建此用户。

在变异解析器中,我们有两个函数(`registerUser` 和 `loginUser`)。

  • `registerUser` 接受用户的 `username`、`email` 和 `password`,并在我们的数据库中使用这些字段创建一个新行。需要注意的是,我们使用 bcryptjs 包通过 `bcrypt.hash(password, 10)` 对用户的密码进行哈希处理。 `jsonwebtoken.sign` 同步地将给定的负载签名到 JSON Web Token 字符串中(在本例中为用户 `id` 和 `email`)。最后,如果成功,`registerUser` 将返回 JWT 字符串和用户个人资料;如果出现错误,则返回错误消息。
  • `login` 接受 `email` 和 `password`,并检查这些详细信息是否与提供的详细信息匹配。首先,我们检查 `email` 值是否已存在于用户数据库中的某个位置。
models.User.findOne({ where: { email }})
if (!user) {
  throw new Error('No user with that email')
}

然后,我们使用 bcrypt 的 `bcrypt.compare` 方法来检查密码是否匹配。

const isValid = await bcrypt.compare(password, user.password)
if (!isValid) {
  throw new Error('Incorrect password')
}

然后,就像我们之前在 `registerUser` 中所做的那样,我们使用 `jsonwebtoken.sign` 生成一个 JWT 字符串。`login` 变异返回令牌和 `user` 对象。

现在让我们将 `JWT_SECRET` 添加到我们的 `.env` 文件中。

JWT_SECRET=somereallylongsecret

服务器

最后,是服务器!在项目的根文件夹中创建一个 `server.js` 文件并粘贴以下内容

const { ApolloServer } = require('apollo-server')
const jwt =  require('jsonwebtoken')
const typeDefs = require('./schema/schema')
const resolvers = require('./resolvers/resolvers')
require('dotenv').config()
const { JWT_SECRET, PORT } = process.env
const getUser = token => {
  try {
    if (token) {
      return jwt.verify(token, JWT_SECRET)
    }
    return null
  } catch (error) {
    return null
  }
}
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => {
    const token = req.get('Authorization') || ''
    return { user: getUser(token.replace('Bearer', ''))}
  },
  introspection: true,
  playground: true
})
server.listen({ port: process.env.PORT || 4000 }).then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});

在这里,我们导入 schema、解析器和 jwt,并初始化我们的环境变量。首先,我们使用 `verify` 验证 JWT 令牌。 `jwt.verify` 接受令牌和 JWT 密钥作为参数。

接下来,我们使用接受 `typeDefs` 和解析器的 `ApolloServer` 实例创建我们的服务器。

我们有一个服务器了!让我们在终端中运行 `yarn dev` 来启动它。

测试 API

现在让我们使用 GraphQL Playground 测试 GraphQL API。我们应该能够注册、登录和查看所有用户——包括单个用户——通过 ID。

我们将首先打开 GraphQL Playground 应用程序,或者直接在浏览器中打开 `localhost://4000` 来访问它。

注册用户的变异

mutation {
  registerUser(username: "Wizzy", email: "[email protected]", password: "wizzyekpot" ){
    token
  }
}

我们应该得到类似这样的结果

{
  "data": {
    "registerUser": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTUsImVtYWlsIjoiZWtwb3RAZ21haWwuY29tIiwiaWF0IjoxNTk5MjQwMzAwLCJleHAiOjE2MzA3OTc5MDB9.gmeynGR9Zwng8cIJR75Qrob9bovnRQT242n6vfBt5PY"
    }
  }
}

登录的变异

现在让我们使用刚刚创建的用户详细信息登录

mutation {
  login(email:"[email protected]" password:"wizzyekpot"){
    token
  }
}

我们应该得到类似这样的结果

{
  "data": {
    "login": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTUsImVtYWlsIjoiZWtwb3RAZ21haWwuY29tIiwiaWF0IjoxNTk5MjQwMzcwLCJleHAiOjE1OTkzMjY3NzB9.PDiBKyq58nWxlgTOQYzbtKJ-HkzxemVppLA5nBdm4nc"
    }
  }
}

太棒了!

单个用户的查询

为了查询单个用户,我们需要将用户令牌作为授权标头传递。转到 HTTP 标头选项卡。

Showing the GraphQL interface with the HTTP Headers tab highlighted in red in the bottom left corner of the screen,

…并粘贴以下内容

{
  "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTUsImVtYWlsIjoiZWtwb3RAZ21haWwuY29tIiwiaWF0IjoxNTk5MjQwMzcwLCJleHAiOjE1OTkzMjY3NzB9.PDiBKyq58nWxlgTOQYzbtKJ-HkzxemVppLA5nBdm4nc"
}

这是查询

query myself{
  me {
    id
    email
    username
  }
}

我们应该得到类似这样的结果

{
  "data": {
    "me": {
      "id": 15,
      "email": "[email protected]",
      "username": "Wizzy"
    }
  }
}

太好了!现在让我们根据 ID 获取用户

query singleUser{
  user(id:15){
    id
    email
    username
  }
}

这是获取所有用户的查询

{
  allUsers{
    id
    username
    email
  }
}

总结

在构建需要身份验证的网站时,身份验证是最棘手的任务之一。GraphQL 使我们能够仅使用一个端点构建整个身份验证 API。Sequelize ORM 使得与我们的 SQL 数据库创建关系变得非常容易,我们几乎不必担心我们的模型。同样值得注意的是,我们不需要 HTTP 服务器库(如 Express)并使用 Apollo GraphQL 作为中间件。Apollo Server 2 现在使我们能够创建自己的库无关的 GraphQL 服务器!

GitHub 上查看本教程的源代码。