简介

我发现有一些用户搞不懂我的网站的注册和重置密码功能,我的网站的登录和注册功能确实和其它网站有一点不一样,所以就打算用这篇博客来讲解一下关于我的网站(OhMyGPT.COM)的注册以及重置密码功能的设计思路与实现方法。

功能概述

网站一般都是需要通过一个有效的联系方式验证来注册,这是出于验证用户真实性、提高账号安全性等方面的考量。我的网站就选择了使用人人都有的邮箱来作为用户的登录凭据。

注册功能概述

一般的注册网页是用户先输入邮箱,然后发送验证码,最后再输入密码等信息并完成注册的,但是我的网站不是这样设计的。

因为要实现验证码功能就需要保存验证码的状态,有点麻烦且并不优雅,所以为了实现无状态的注册功能,这一部分我用到了JWT技术,通过JWT来对一个注册请求签个名,然后拼接成一个可以请求后端注册API的URL,将URL发到用户邮箱中,用户收到邮件后直接点击链接,就会发送一个GET请求到我的后端,后端接收到数据并验证签名后即可执行用户数据插入操作了。

大致流程如下:

  1. 用户在表单中输入邮箱和密码。
  2. 用户点击登录/注册按钮,发送邮箱和密码到服务器。
  3. 使用Bcrypt对密码进行信息摘要处理。
  4. 服务器检查用户是否已注册。若尚未注册,则将邮箱和密码签名后拼接成一个激活链接发送至用户邮箱。
  5. 用户点击邮箱中的激活链接。
  6. 服务器收到激活请求,验证收到的数据签名,然后将数据插入数据库。此时,用户已完成注册并可登录。

重置密码功能概述

重置密码的功能类似于注册功能。同样是将表单中输入的邮箱和新密码签名,然后将签名发送至用户邮箱。

大致流程如下:

  1. 用户在表单中输入注册时使用的邮箱和新的密码。
  2. 用户点击重置密码按钮,发送邮箱和新密码到服务器。
  3. 使用Bcrypt对密码进行信息摘要处理。
  4. 服务器检查用户是否已注册。若已注册,则将邮箱和新密码签名后拼接成一个重置密码链接发送至用户邮箱。
  5. 用户点击邮箱中的重置密码链接。
  6. 服务器收到重置密码请求,验证收到的数据签名,然后将新密码更新至数据库。此时,用户的密码已成功重置。

注:用户提交的密码都会使用bcrypt做高强度的信息摘要处理,保证密码安全存储

相关文章: 如何安全地存储密码

实现方法

使用Bcrypt对密码进行信息摘要处理

假设我们使用的是NoeJS,那么可以使用bcrypt包来对密码进行加密。

首先在项目中安装bcrypt依赖

# 我倾向于使用pnpm 和 TypeScript
pnpm add bcrypt @types/bcrypt

然后定义一个方法,用来处理密码并返回处理过的值:

import * as bcrypt from "bcrypt";

//密码bcrypt加密
const hashPassword = (password: string): string => {
  return bcrypt.hashSync(password, 12);
};

//验证密码
const verifyHashedPassword = (password: string, hash: string): boolean => {
  return bcrypt.compareSync(password, hash);
};

首先是hashPassword方法,这个方法可以传入一个密码,然后返回经bcrypt算法加密过的hash值。其中hashSync()的第二个参数是加密轮数,轮数越高越安全,但是耗费的计算资源越高,一般来说12左右就行了。

然后后面的verifyHashedPassword方法就可以验证密码和hash值是否匹配,这样就能实现密码验证了。

经过bcrypt处理后的密码就可以安全地放到数据库里了,但是在那之前,还需要验证用户输入的邮箱信息是否真实,此时就需要发送一个验证邮件了。

使用JWT对注册/重置请求进行签名以及验证

前面说过,我觉得输入验证码太麻烦了,而且不想保存验证码的状态,此时就可以用到JWT技术了。

我们可以通过JWT对一个JSON进行签名,这个JSON将作为Payload负载存到JWT字符串中,受到签名保护,任何篡改都会被发现,从而保证了数据(在密钥没有泄露的情况下)的不可篡改和不可伪造的特性。

这样就可以将一段数据通过自己的私钥进行签名,然后发给用户作为凭证,当用户将JWT发给服务端时,服务端可以使用公钥验证签名并拿到Payload中的数据,这样做的好处是服务器不用维护一个Token用户凭证表。

这个过程就相当于:

  1. 往一个纸条上写一段字(放入Payload数据
  2. 在这个纸条上盖个章,签个名然后发送给用户(使用私钥签名
  3. 当用户需要请求某个资源时,将纸条发给服务端
  4. 服务端收到纸条后,使用公钥验证印章和签名是否合法(使用公钥验证
  5. 确认没问题后就可以取出Payload中的数据,并通过Payload来确定用户身份

要使用JWT,首先要创建一个密钥对。

生成JWT密钥对

在使用JWT作为验证方式时,使用一个安全可靠的密钥和高强度的签名算法是很有必要的。

在JWT中,强度最高的签名算法是ES512。ES512 属于 ECDSA(椭圆曲线数字签名算法)的一种,它使用了 P-521 曲线。ECDSA 是基于椭圆曲线密码学(ECC)的一种签名算法,可以提供相对于 RSA 更高的安全性和更短的密钥长度。

我们可以使用openssl命令来生成一个ES512(椭圆曲线加密,使用P-521曲线)密钥对。下面是生成私钥和公钥的命令:

  1. 生成ES512私钥:
openssl ecparam -genkey -name secp521r1 -noout -out ec512-private-key.pem
  1. 从私钥中导出对应的公钥:
openssl ec -in ec512-private-key.pem -pubout -out ec512-public-key.pem

通过执行上述命令,你将会得到两个PEM格式的文件,分别为ec512-private-key.pem(私钥)和ec512-public-key.pem公钥)。这两个文件可以用于JWT签名和验证过程。

JWT签发

在用户提交注册请求后,服务端就可以得到邮箱和密码哈希值了,但是此时还不能直接将数据插入到数据库中,因为需要验证用户提交的邮箱是否真的是用户的邮箱,我是直接将邮箱和密码哈希值用JWT签名一下,然后直接拼接成一个指向服务端注册验证API的URL就可以发送给用户邮箱了:

这里我以NodeJS中的 jsonwebtoken 库为例来讲解:

签名示例方法的代码如下:

//jwt方法示例
const newJWT = (payload: any): string => {
  return jwt.sign(
    payload,
    JWTPrivateKey,
    { algorithm: "ES512", expiresIn: "7d" });
};

在这个示例中,我们创建了一个名为newJWT的函数,它接收一个payload参数并返回一个带有签名的JWT字符串(注意这里的类型最好规定一下类型格式)。

我们使用ES512算法对数据进行签名,并设置签名的过期时间为7天。这样一来,我们就可以确保注册验证过程既简单又安全。

下面是调用示例代码:

//调用方法示例
const registerToken = newJWT({
  email: email,
  hash: hashedPwd,
  purpose: "register"
});

可以看到,在这个调用代码中,除了放入email和密码哈希值,还写入了该Token的目的(purpose),即规定这个Token只能用来注册,而不能用来干别的,这样就提高了整个系统的安全性。

这种做法能有效防止潜在的安全漏洞,比如用注册Token发给重置密码的接口,(虽然这样做即使不加验证也不会成功,因为参数都不一样)。

JWT验证

JWT的验证也很简单,只需要提供token+JWT公钥+算法即可

示例代码:

const verifyJWT = (token: string): any | null => {
  //验证JWT
  try {
    return jwt.verify(token, JWTPublicKey, { algorithms: ["ES512"] }) as any;//建议自己定义返回类型
  } catch (e) {
    console.log("JWT Verify failed");
    if (e instanceof JsonWebTokenError) {
      console.log("JsonWebTokenError:", e.message);
    }
    return null;
  }
};

通过调用这个方法,即可轻松验证JWT的合法性,即是否是自己签发的、是否过期等。

然后在用户激活时调用这个方法,验证JWT是否合法的同时获取JWT中的Payload负载中的数据,一举两得。

拿到Payload后,再验证一下purpose是否和该接口的功能一致即可,比如说让注册激活接口就只接受purposeregister的JWT,否则会返回报错。