翻译 | 《JavaScript
Everywhere
》第7
章 用户帐户和身份验证(^_
^)
写在最前面
大家好呀,我是毛小悠,是一位前端开发工程师。正在翻译一本英文技术书籍。
为了提高大家的阅读体验,对语句的结构和内容略有调整。如果发现本文中有存在瑕疵的地方,或者你有任何意见或者建议,可以在评论区留言,或者加我的微信:code\_maomao
,欢迎相互沟通交流学习。
(σ゚∀゚)σ..
:\*☆哎哟不错哦
第7章 用户帐户和身份验证
想像一下自己走在黑暗的小巷里,你即将加入一个“超酷秘密俱乐部”(如果你正在阅读此书,则是当之无愧的成员)。当你进入俱乐部的暗门时,接待员会向你打招呼,并递给你一张表格。在表格上,你必须填写你的姓名和密码,只有你和接待员才能知道。填写完表格后,将其交给接待员,接待员将进入俱乐部的后厅。在后台,接待员使用密钥对你的密码进行加密,然后将加密的密码存储在锁定的文件库中。然后,接待员会在通行币上盖章,然后盖下你的唯一会员ID
。回到前厅,接待员递给你通行币,你将通行币塞到口袋里。现在,每次你返回俱乐部时,只需要出示通行币即可进入。这种互动听起来像是一部低价间谍电影中的东西,但它几乎与每次我们注册Web
应用程序时遵循的过程相同。
在本章中,我们将学习如何构建GraphQL
修改,该修改将允许用户创建一个帐户并登录到我们的应用程序中。我们还将学习如何加密用户的密码并向用户返回令牌,当他们与我们的应用程序交互时,他们可以用来验证其身份。
应用程序认证流程
在开始之前,让我们后退一步,确定用户注册帐户并登录到现有帐户时将遵循的流程。如果你还不了解这里介绍的所有概念,请不要担心:我们将逐步解决它们。
首先,让我们回顾一下帐户创建流程:
- 用户将电子邮件地址、用户名和密码输入到用户界面(
UI
),例如GraphQL
Playground
,Web
应用程序或移动应用程序。 - 用户界面会使用用户信息将
GraphQL
请求发送到我们的服务器。 - 服务器对密码进行加密,并将用户信息存储在数据库中。
- 服务器向用户界面返回一个令牌,其中包含用户的
ID
。 UI
在指定的时间段内存储此令牌,并将其与每个请求一起发送到服务器以验证用户。
现在让我们看一下用户登录流程:
- 用户在
UI
的字段中输入其电子邮件或用户名和密码。 UI
会使用此信息将GraphQL
请求发送到我们的服务器。- 服务器解密存储在数据库中的密码,并将其与用户输入的密码进行比较。
- 如果密码匹配,则服务器将向用户界面返回一个令牌,其中包含用户的
ID
。 UI
在指定的时间段内存储此令牌,并将其与每个请求一起发送到服务器。
如你所见,这些流程与我们的“秘密俱乐部”流程非常相似。在本章中,我们将重点介绍实现这些交互的API
部分。
密码重设流程
你会注意到,我们的应用程序不允许用户更改密码。我们可以允许用户使用修改密码,但是首先通过电子邮件来验证重置请求更加安全。为简便起见,我们不会在本书中实现密码重置功能,但是如果你对创建密码重置流程的示例和资源感兴趣,请访问JavaScript
Everywhere
Spectrum
社区。
加密和令牌
在探索用户身份验证流程时,我提到了加密和令牌。这些听起来像是神话中的黑暗艺术,所以让我们花点时间仔细研究一下每一项。
加密密码
为了有效地加密用户密码,我们应该结合使用哈希和盐析。
哈希
是通过将文本字符串转换为看似随机的字符串来使其混淆的行为。哈希函数是“一种方式”,这意味着一旦对文本进行哈希,就无法将其还原为原始字符串。密码哈希后,密码的纯文本永远不会存储在我们的数据库中。
盐析
是生成随机数据字符串的行为,该数据字符串将与哈希密码一起使用。这样可以确保即使两个用户密码相同,哈希和盐析版本也将是唯一的。
bcrypt
基于河豚密码 ,通常在一系列网络框架中使用。在Node.js
开发中,我们可以使用 bcrypt
模块对密码进行盐析和哈希处理。
在我们的应用程序代码中,我们将需要使用 bcrypt
模块并编写一个函数来处理盐析和哈希。
盐析和散列示例
以下示例仅用于说明目的。 在本章的后面,我们将密码盐析、哈希与bcrypt
集成在一起 。
// require the module
const bcrypt = require('bcrypt');
// the cost of processing the salting data, 10 is the default
const saltRounds = 10;
// function for hashing and salting
const passwordEncrypt = async password => {
return await bcrypt.hash(password, saltRounds)
};
在此示例中,我可以传递密码 PizzaP
@rty99
,生成的盐析为 $2a
$10
$HF2rs.iYSvX1l5FPrX697O
和$2a
$10
$HF2rs.iYSvX1l5FPrX697O9dYF/O2kwHuKdQTdy.7oaMwVga54bWG
的哈希密码 。(盐析加上加密的密码字符串)。
现在,当根据哈希和盐析的密码检查用户密码时,我们将使用 bcrypt
的 compare
方法:
// password is a value provided by the user
// hash is retrieved from our DB
const checkPassword = async (plainTextPassword, hashedPassword) => {
// res is either true or false
return await bcrypt.compare(hashedPassword, plainTextPassword)
};
通过对用户密码进行加密,我们可以将其安全地存储在数据库中。
JSON
Web
令牌
作为用户,如果每次我们想要访问一个站点或应用程序的受保护页面时都需要输入用户名和密码,那将非常令人讨厌。相反,我们可以将用户带有JSON
Web
令牌ID
安全地存储在用户设备中。
用户从客户端发出的每个请求,他们都可以发送该令牌,服务器将使用该令牌来标识用户。
JSON
Web
令牌(JWT
)包含三个部分:
- 标头
Header
有关令牌和正在使用的签名算法类型的一般信息
- 有效载荷
Payload
我们有意存储在令牌中的信息(例如用户名或
ID
) - 签名
Signature
Ameans
验证令牌
我们看一下令牌,它似乎由随机字符组成,每个部分之间用句点分隔:
xx
-header
-xx.yy
-payload
-yy.zz
-signature
-zz
。
在我们的应用程序代码中,我们可以使用jsonwebtoken
模块来生成和验证我们的令牌。为此,我们传递希望存储的信息以及通常在我们的.env
文件中存储的私密密码 。
const jwt = require('jsonwebtoken');
// generate a JWT that stores a user id
const generateJWT = await user => {
return await jwt.sign({ id: user._id }, process.env.JWT_SECRET);
}
// validate the JWT
const validateJWT = await token => {
return await jwt.verify(token, process.env.JWT_SECRET);
}
JWT与会议
如果你以前曾在Web
应用程序中使用过用户身份验证,则很可能会接触到用户会话。
会话信息通常存储在本地的cookie
中,并根据内存中的数据存储区(例如
Redis
,虽然也可以使用传统数据库。关于JWT
或会话,哪个有更好的争论?但我发现JWT
提供了最大的灵活性,尤其是在与非Web
环境(例如本机移动应用程序)集成时。尽管会话可以与GraphQL
很好地配合使用,但JWT
还是是GraphQL
Foundation
和 Apollo
Server
文档中的推荐方法 。
通过使用JWT
,我们可以安全地返回用户ID
并将其存储在客户端应用程序中。
将身份验证集成到我们的API中
现在,你已经对用户身份验证的组件有了深刻的了解,我们将为用户实现注册和登录我们的应用程序的能力。为此,我们将更新GraphQL
和Mongoose
模式,编写 signUp
和 signIn
修改解析器以生成用户令牌,并在对服务器的每个请求中验证令牌。
用户结构
首先,我们将添加User
类型,并更新 Note
类型的 author
字段,引用User
来更新GraphQL
模式 。为此,请更新 src/schema.js
文件:
type Note {
id: ID!
content: String!
author: User!
createdAt: DateTime!
updatedAt: DateTime!
}
type User {
id: ID!
username: String!
email: String!
avatar: String
notes: [Note!]!
}
当用户注册我们的应用程序时,他们将提交用户名、电子邮件地址和密码。当用户登录我们的应用程序时,他们将发送一个包含用户名或电子邮件地址以及密码的修改。如果注册或登录修改成功,则API
将以字符串形式返回令牌。为了在我们的模式中完成此操作,我们将需要在src/schema.js
文件中添加两个新的变量,每个变量将返回 String
,这将是我们的JWT
:
type Mutation {
...
signUp(username: String!, email: String!, password: String!): String!
signIn(username: String, email: String, password: String!): String!
}
现在,我们的GraphQL
模式已更新,我们还需要更新数据库模块。为此,我们将在src/models/user.js
中创建一个Mongoose
模式文件 。该文件的设置将类似于我们的笔记模块文件,其中包含用户名、电子邮件、密码和头像的字段。通过设置index
:{unique
:true
},我们还将要求用户名和电子邮件字段在我们的数据库中必须是唯一的 。
要创建用户数据库模块,请在src/models/user.js
文件中输入以下内容 :
const mongoose = require('mongoose');
const UserSchema = new mongoose.Schema(
{
username: {
type: String,
required: true,
index: { unique: true }
},
email: {
type: String,
required: true,
index: { unique: true }
},
password: {
type: String,
required: true
},
avatar: {
type: String
}
},
{
// Assigns createdAt and updatedAt fields with a Date type
timestamps: true
}
);
const User = mongoose.model('User', UserSchema);
module.exports = User;
放置好我们的用户模块文件后,我们现在必须更新 src/models/index.js
来导出模块:
const Note = require('./note');
const User = require('./user');
const models = {
Note,
User
};
module.exports = models;
身份验证解析器
编写了GraphQL
和Mongoose
模式后,我们可以实现解析器,使用户可以注册并登录到我们的应用程序。
首先,我们需要在 .env
环境文件添加一个JWT\_SECRET
变量。该值应该是没有空格的字符串。它将用于对我们的JWT
进行签名,这使我们可以在对它们进行解码时对其进行验证。
JWT_SECRET=YourPassphrase
创建此变量后,可以将所需的包导入我们的 mutation.js
文件中。我们将使用第三方bcrypt
、jsonwebtoken
、mongoose
和dotenv
包,并导入Apollo
服务器的AuthenticationError
和ForbiddenError
实用程序。
此外,我们将导入gravatar
实用程序功能,该功能已包含在该项目中。这将产生一个用户电子邮件地址中的图片图像URL
。
在 src/resolvers/mutation.js
中,输入以下内容:
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const {
AuthenticationError,
ForbiddenError
} = require('apollo-server-express');
require('dotenv').config();
const gravatar = require('../util/gravatar');
现在我们可以编写我们的signUp
请求。此请求将接受用户名、电子邮件地址和密码作为参数。我们将通过修改所有空格并将其转换为所有小写字母来规范化电子邮件地址和用户名。接下来,我们将使用bcrypt
模块对用户密码进行加密。我们还将通过使用我们的helper
程序库为用户头像生成Gravatar
图像URL
。执行完这些操作后,我们会将用户数据存储在数据库中,并向用户返回令牌。
我们可以在try/catch
块中进行所有的设置 ,如果在注册过程中出现任何问题时,我们的解析器可以将模糊的错误返回给客户端。
要完成所有这些操作,请在src/resolvers/mutation.js
文件中编写 signUp
请求,如下所示 :
signUp: async (parent, { username, email, password }, { models }) => {
// normalize email address
email = email.trim().toLowerCase();
// hash the password
const hashed = await bcrypt.hash(password, 10);
// create the gravatar url
const avatar = gravatar(email);
try {
const user = await models.User.create({
username,
email,
avatar,
password: hashed
});
// create and return the json web token
return jwt.sign({ id: user._id }, process.env.JWT_SECRET);
} catch (err) {
console.log(err);
// if there's a problem creating the account, throw an error
throw new Error('Error creating account');
}
},
现在,如果我们在浏览器中切换到GraphQL
Playground
,我们可以尝试一下 signUp
请求。为此,我们将使用用户名,电子邮件和密码值编写一个GraphQL
修改:
mutation {
signUp(
username: "BeeBoop",
email: "[email protected]",
password: "NotARobot10010!"
)
}
当我们运行请求时,我们的服务器将返回这样的令牌(图7
-1
):
"data": {
"signUp": "eyJhbGciOiJIUzI1NiIsInR5cCI6..."
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HLrver5v-1606266940860)(http://vipkshttp0.wiz.cn/ks/s...]
图7
-1
。GraphQL
Playground
中的signUp
请求
下一步将是写我们的登录请求。
此请求将接受用户的用户名、电子邮件和密码。然后,它将根据用户名或电子邮件地址在数据库中找到用户。找到用户后,它将解密存储在数据库中的密码,并将其与用户输入的密码进行比较。如果用户名和密码匹配,我们的应用程序将向用户返回令牌。如果它们不匹配,我们将抛出一个错误。
在src/resolvers/mutation.js
文件中,按如下所示编写此请求 :
signIn: async (parent, { username, email, password }, { models }) => {
if (email) {
// normalize email address
email = email.trim().toLowerCase();
}
const user = await models.User.findOne({
$or: [{ email }, { username }]
});
// if no user is found, throw an authentication error
if (!user) {
throw new AuthenticationError('Error signing in');
}
// if the passwords don't match, throw an authentication error
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
throw new AuthenticationError('Error signing in');
}
// create and return the json web token
return jwt.sign({ id: user._id }, process.env.JWT_SECRET);
}
现在,我们可以在浏览器中访问GraphQL
Playground
,并 使用通过signUp
请求创建的帐户尝试 signIn
请求:
mutation {
signIn(
username: "BeeBoop",
email: "[email protected]",
password: "NotARobot10010!"
)
}
同样,如果成功,我们的修改应通过JWT
解决(图7
-2
):
{
"data": {
"signIn": ""
}
}
图7
-2
。GraphQL
Playground
中的signIn
修改
有了这两个解析器,用户将能够使用JWT
注册和登录我们的应用程序。为此,请尝试添加更多帐户,甚至故意输入不正确的信息(例如不匹配的密码),以查看GraphQL
API
返回的内容。
将用户添加到解析器上下文
现在,用户可以使用GraphQL
请求来接收唯一令牌,我们将需要在每个请求上验证该令牌。
我们期望将是我们的客户端(无论是web端、移动端还是桌面版)都发送带有与请求中提及的HTTP
标头的授权的令牌。
然后,我们可用于从HTTP
标头读取令牌,使用JWT\_SECRET
变量对其进行解码 ,并将用户信息以及上下文传递给每个GraphQL
解析器。通过这样做,我们可以确定登录用户是否正在发出请求,如果他正在发出请求,那么就是那个用户。
首先,将 jsonwebtoken
模块导入 src/index.js
文件:
const jwt = require('jsonwebtoken');
导入模块后,我们可以添加一个函数来验证令牌的有效性:
// get the user info from a JWT
const getUser = token => {
if (token) {
try {
// return the user information from the token
return jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
// if there's a problem with the token, throw an error
throw new Error('Session invalid');
}
}
};
现在,在每个GraphQL
请求中,我们将从请求的头部中获取令牌,尝试验证令牌的有效性,并将用户信息添加到上下文中。完成此操作后,每个GraphQL
解析器都可以访问我们存储在令牌中的用户ID
。
// Apollo Server setup
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
// get the user token from the headers
const token = req.headers.authorization;
// try to retrieve a user with the token
const user = getUser(token);
// for now, let's log the user to the console:
console.log(user);
// add the db models and the user to the context
return { models, user };
}
});
尽管我们尚未执行用户交互,但是我们可以在GraphQL
Playground
中测试用户上下文。在GraphQL
Playground
UI
的左下角,有一个标为HTTP
Header
的空间。在用户界面的那部分,我们可以添加一个包含JWT
的标头,该标头是通过signUp
或 signIn
修改返回的, 如下所示(图7
-3
):
{
"Authorization": ""
}
图7
-3
。GraphQL
Playground
中的授权标头
我们可以通过将其与GraphQL
Playground
中的任何查询或修改一起传递来测试此授权标头。为此,我们将编写一个简单的笔记查询,并包含 Authorization
标头(图7
-4
)。
query {
notes {
id
}
}
图7
-4
。GraphQL
Playground
中的授权标头和查询
如果我们的身份验证成功,则应该看到一个包含用户ID
的对象记录到终端应用程序的输出中,如图7
-5
所示 。
图7
-5
。终端的console.log
输出中的用户对象
完成所有这些步骤后,我们现在可以在我们的API
中对用户进行身份验证。
结论
用户帐户的创建和登录流程可能会让人感到困惑和不知所措,但是通过逐个进行,我们可以在我们的API
中实现稳定且安全的身份验证流程。在本章中,我们创建了注册和登录用户流程。这些只是帐户管理生态系统的一小部分,但将为我们提供一个稳定的基础。在下一章中,我们将在API
中实现特定于用户的交互,该交互将所有权分配给应用程序中的笔记和活动。
如果有理解不到位的地方,欢迎大家纠错。如果觉得还可以,麻烦你点赞收藏或者分享一下,希望可以帮到更多人。