在本文中,后端开发人员可以了解为什么使用加密很重要,以及如何有效地使用它来保护云上的用户信息(特别是密码),使得数据即使泄露也不会在数十年内被破解。安全性是云中的一个非常重要的主题,它对全栈开发至关重要,而且在所有产品和服务上都不可或缺。
一种保护云中的密码的加密方法
我们首先会列出一些在开发中考虑安全性时要执行(或不执行)的简单事务:
始终选择使用经过其他人仔细检查和审核的非本人的哈希/加密库。
不要将密码输出到日志中!
使用某种形式的密钥管理服务。
不要将密钥(API 密钥、密码)提交到代码存储库中。
在本文中,我将通过一个示例应用程序来重点介绍加密关键数据的方式。对于本文中涉及的密码存储,我们将使用一个 SQLite 数据库,因为它几乎可以在任何系统上轻松使用。几乎所有地方都使用着相同的原则和理念,而且数据库系统应该无关紧要(但根据所选的数据库,可能存在对用户信息执行哈希运算和保护的更好方法)。我还想展示,如果您丢失了数据库文件,但仍保持用户哈希值完整且无法破解,结果会怎样?
使用 bcrypt
bcrypt 是目前对密码执行哈希运算的最广泛使用的函数之一。它适用于大部分编程语言,而且通常有一些可用于特定框架和数据库的非常特殊的模块。让我们看看这个存储库示例。此代码通常与 Node.js 一起使用,而且非常简单(它允许采用 sync 或 async 的方式来调用加盐和哈希函数)。它还使您无需担心实现细节和加盐过程,使您能专注于防止意外的密码泄露。
哈希运算、盐和加密是什么?
尽管哈希运算和加密看起来可能没什么不同,而且可以互换使用,但它们实际上有很大区别,而且有不同的用例。哈希函数接受一些输入,并对输出进行单向映射。虽然有众多的哈希技术和算法,但我推荐对密码使用 bcrypt。可以在此处进一步了解加密哈希函数,但通常不必了解这些函数的基础细节。在执行哈希运算期间使用了盐,将盐作为提供给哈希函数的附加信息,使您(意外或通过暴力)即使找到一个哈希值,也无法校验其他可能具有类似输入的哈希值。例如,user_1 有一个与 user_2 的密码相同的密码。如果哈希函数中使用了盐,这两个用户的密码就无法被找到。要进一步了解此函数,此处提供了各种各样的信息和示例。
加密也是某个输入与一个输出之间的一对一映射。一个重要的关键区别是,如果您拥有加密密钥,那么加密是可逆的。
您可以在以后使用哈希运算来检查一个输入与另一个输入的映射,但您可能并不想直接存储该输入(密码、pin 编号等)。在发送消息时(双方都有一个用于编码/解码的密钥),或者在您想存储一些隐私信息(比如家庭地址或信用卡),但需要在以后通过某种方式检索此信息时,可以使用加密。
前端
因为本文的重点不是前端,所以我们不打算采用任何会增加复杂性的内容或引入另一个令人担忧的框架。我们将在同一个页面上采用两个用于登录/注册的表单。除了使用超级简单的引导指令外,我们不会对这些表单执行任何操作,因为这不是本文的重点。
<form action="/signin" method="post">
<div class="row">
<div class="col">
<input name="email" type="email" class="form-control" placeholder="email"/>
</div>
<div class="col">
<input name="password" type="password" class="form-control" placeholder="password"/>
</div>
<div class="col">
<button class="btn btn-dark">sign in</button>
</div>
</form>
<form action="/register" method="post">
<div class="row">
<div class="col">
<input name="email" type="email" class="form-control" placeholder="email"/>
</div>
<div class="col">
<input name="password" type="password" class="form-control" placeholder="password"/>
</div>
<div class="col">
<button class="btn btn-dark">register</button>
</div>
</div>
</form>
我们还将输入从表单提交到后端,而且不打算校验/创建/设置会话,因为这不属于本文的讨论范围,而且根据应用程序的目标或目的,涉及的内容可能很广泛。
创建后端
接下来,我们将在 Node.js 中运行后端,方法是使用 Express 框架和 SQLite 来实现本文所需的最基本的系统。
const path = require('path')
const bcrypt = require('bcrypt')
const bodyParser = require('body-parser')
const sqlite = require('sqlite')
const express = require('express')
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
const dbPromise = sqlite.open('./database.sqlite', { Promise })
const saltRounds = 10
我们在这里执行的操作包括:为数据库创建一个 promise,生成一个盐,并创建应用程序和简单中间件来获取用户名/密码,加载一些我们想要使用的库。
路径
对于我们的服务器将要执行的操作,我们将有一个登录路径和一个供用户进行注册的路径。为了理解系统中正在发生的事情,我们将这两条路径分开了,但它们不会执行任何操作(与会话/cookie 等相关的任何操作)。一旦密码匹配,我们将(非常简单地)展示如何对一个密码执行哈希运算,然后执行校验。登录路径与注册路径几乎是相同的,尽管我们会在该 HTML 表单上检查电子邮件,但我们不会在任何路径上执行任何数据验证。
app.get('/', async (req,res) => {
res.sendFile(path.join(__dirname, '/main.html'))
})
app.post('/register', async (req, res) => {
const db = await dbPromise
// check if user already exists
const checkUser = await db.get('SELECT * FROM Users WHERE email = ?', req.body.email)
if (checkUser) {
return res.send('user already exists')
}
const hashedPassword = await bcrypt.hash(req.body.password, saltRounds)
const resp = await db.run(`INSERT INTO Users VALUES(?,?)`, req.body.email, hashedPassword)
res.send('registered')
})
注册路径检查用户是否存在于数据库中,以及我们是否已使用一个经过哈希运算的密码将其插入数据库中。请记住,我们不会执行任何操作来减少 SQL 注入或其他各种形式的攻击/滥用。如果该用户不存在,我们会使用 bcrypt 哈希函数对密码执行哈希运算,该函数会在密码中添加盐,因为我们向盐提供了运算的轮数。这种哈希运算使我们能够以这样一种方式存储用户的密码 - 将来,如果用户输入了密码,我们就可以检查密码。我们自己无法查找该密码。另外,我们不应将密码输出到用户的日志中,而且我们可能希望能够使用数据库模型来检查密码,并将用户的密码保存到哈希值中。
尽管登录路径几乎相同(而且我们可以轻松重构此路径来让它更 DRY,但在这里提供它是为了便于理解),但有一行稍有不同:
const passwordMatch = await bcrypt.compare(req.body.password, user.password)
此代码使用 bcrypt 将经过哈希运算的密码与用户在前端输入的密码进行比较,并返回 true 或 false。因为盐已合并到哈希值中,所以我们不需要显式使用它来进行比较。下面是要运行的完整的 server.js:
尽管登录路径几乎相同(而且我们可以轻松地重构此路径来让它更 DRY,但在这里提供它是为了便于理解),但有一行稍有不同:
const passwordMatch = await bcrypt.compare(req.body.password, user.password)
上面这行使用 bcrypt 将经过哈希运算的密码与用户在前端输入的密码进行比较,并返回 true 或 false。因为盐已合并到哈希值中,所以我们不需要显式使用它来进行比较。下面的代码清单是要运行的完整的 server.js:
const bcrypt = require('bcrypt')
const bodyParser = require('body-parser')
const express = require('express')
const app = express()
app.post('/register', async (req, res) => {
const db = await dbPromise
const hashedPassword = await bcrypt.hash(req.body.password, saltRounds)
const resp = await db.run(`INSERT INTO Users VALUES(?,?)`, req.body.email, hashedPassword)
res.send('registered')
})
app.post('/signin', async (req, res) => {
const db = await dbPromise
const user = await db.get('SELECT * FROM Users WHERE email = ?', req.body.email)
if (!user) {
return res.send('user doesnt exist')
}
const passwordMatch = await bcrypt.compare(req.body.password, user.password)
if (passwordMatch) {
return res.send('signed in')
}
res.send('password does not match')
})
app.listen(PORT, async () => {
console.log(`app listening at http://localhost{PORT}`)
})
现在安装依赖项:
yarn add bcrypt express body-parser sqlite。
运行服务器 Node server.js,打开 http://localhost:8080。然后尝试登录,创建一个用户,并再次登录。
通过网络发送未加密的密码!
尽管本文仅展示了如何存储密码并对其执行哈希运算,而且您不会保存用户的明文密码,但我们仍在浏览器与后端之间发送明文,因为我们没有使用 HTTPS。如果将此示例用在生产环境中,当黑客进入此通信渠道时,他们很容易看到在服务器与客户端之间发送的密码(包括登录和注册密码)。有许多不同的方法可用来实际阻止中间人攻击,但为了简单起见,我们将在 Express 中处理它,生成自签名 SSL 证书作为示例,以说明此工作原理。请记住,这些证书的签署方式与从 LetsEncrypt 或其他各种 SSL/TLS 证书提供者获取证书的方式不同。
首先,我们需要通过包管理器或通过 OpenSSL 的官方网站安装 OpenSSL。在 macOS 上,如果您已安装 homebrew,可以简单写入以下代码:
brew-install Openssl
接下来,需要运行以下命令来生成一个密钥和一个证书:
openSSL req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 30
此命令会要求您输入一些信息,但在最后,您将获得一个 key.pem 和一个 cert.pem。有了这两个文件,就可以将以下代码添加到 server.js 的顶部(请注意,我们现在使用的是来自 Node.js 的 https 标准库):
const fs = require('fs')
const https = require('https')
const options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem')
}
在我们的代码底部,以前包含以下代码:
const PORT = 8080
app.listen(PORT, async () => {
const db = await dbPromise
await db.run("CREATE TABLE IF NOT EXISTS Users (email TEXT, password TEXT)")
console.log(`app listening at http://localhost{PORT}`)
})
我们将上述以前的代码更改为:
const PORT = 8081
https.createServer(options, app)
.listen(PORT, async () => {
const db = await dbPromise
await db.run("CREATE TABLE IF NOT EXISTS Users (email TEXT, password TEXT)")
console.log(`app listening at https://localhost{PORT}`)
})
此刻,我们将仅使用 HTTPS 并将加密后的密码发送到服务器,而且会在将密码保存到数据库时执行哈希运算。
最糟的情况:数据库被泄露
设想我们的服务器被黑客攻击,或者出现了其他一些漏洞,而且我们的 SQLite(或任何数据库)被泄露。尽管这种情况很糟糕,但我们至少可以确信,用户密码本身应该是安全的,不会被使用,而且我们最大限度降低了从其他地方要求用户更改密码的可能性。 |