4、用户注册与登录:JWT令牌生成与验证、注册接口开发、登录接口开发、密码加密存储

用户注册和登录,这是每个OA系统的「大门」。

我见过太多项目,前期图省事,密码明文存、令牌随便发,结果上线没几天就被脱裤了。嗯,咱们今天就把这扇门焊死,从密码加密到JWT令牌,一步步搭起来。

4.1 密码加密存储:别拿用户的安全开玩笑

先说个我踩过的坑。几年前我做一个内部系统,图方便用了MD5加密密码。结果有一次数据库被拖库,虽然只是测试环境,但想想都后怕。从那以后,我再也不敢用MD5、SHA1这类「快哈希」算法了。

为什么?因为这些算法设计出来就是为了快。你想想看,黑客每秒能算几十亿次MD5,你的密码再复杂也扛不住彩虹表攻击。

正确的做法是用 bcryptArgon2。它们自带盐值,而且计算速度可以调节——慢到让黑客崩溃,但对用户来说只是多等几百毫秒。

核心原则:永远不要自己写加密算法。用业界公认的库,比如 Node.js 的 bcrypt 包。

安装很简单:

npm install bcrypt

注册时加密密码:

const bcrypt = require('bcrypt');
const saltRounds = 10;

async function hashPassword(plainPassword) {
  const salt = await bcrypt.genSalt(saltRounds);
  const hash = await bcrypt.hash(plainPassword, salt);
  return hash;
}

登录时验证密码:

async function verifyPassword(plainPassword, hash) {
  const match = await bcrypt.compare(plainPassword, hash);
  return match; // true 或 false
}

小提示:saltRounds 设为 10 是平衡点。太低不安全,太高用户登录要等好几秒。我一般用 10-12 之间。

4.2 注册接口开发:把好第一道关

注册接口说白了就是接收用户信息,校验合法性,然后存到数据库。但这里有几个细节要注意。

我个人习惯的注册流程:

  1. 参数校验:用户名、密码、邮箱等不能为空,密码长度至少8位,邮箱格式要合法。
  2. 唯一性检查:用户名和邮箱不能重复。我遇到过有人用同一个邮箱注册了十几个账号,搞得系统里全是垃圾数据。
  3. 密码加密:用上面说的 bcrypt 处理。
  4. 存入数据库:只存加密后的密码,永远不存明文。
  5. 返回结果:注册成功返回用户基本信息,但不要返回密码字段。

代码示例:

// 注册接口
app.post('/api/register', async (req, res) => {
  try {
    const { username, password, email } = req.body;

    // 1. 参数校验
    if (!username || !password || !email) {
      return res.status(400).json({ message: '所有字段都是必填的' });
    }
    if (password.length < 8) {
      return res.status(400).json({ message: '密码至少8位' });
    }

    // 2. 唯一性检查
    const existingUser = await User.findOne({ 
      $or: [{ username }, { email }] 
    });
    if (existingUser) {
      return res.status(409).json({ message: '用户名或邮箱已存在' });
    }

    // 3. 密码加密
    const hashedPassword = await hashPassword(password);

    // 4. 存入数据库
    const newUser = new User({
      username,
      password: hashedPassword,
      email
    });
    await newUser.save();

    // 5. 返回结果(不返回密码)
    res.status(201).json({
      message: '注册成功',
      user: {
        id: newUser._id,
        username: newUser.username,
        email: newUser.email
      }
    });
  } catch (error) {
    res.status(500).json({ message: '服务器内部错误' });
  }
});

注意:千万不要在返回结果里包含密码字段,哪怕是加密后的也不行。我曾经见过一个项目,返回用户信息时把 hash 也带上了,虽然比明文好一点,但依然是不安全的做法。

4.3 JWT令牌生成与验证:无状态认证的核心

JWT(JSON Web Token)说白了就是一个「通行证」。用户登录成功后,服务器发给他一个令牌,以后每次请求都带上这个令牌,服务器就知道你是谁了。

为什么用JWT?因为它是无状态的。服务器不需要存session,扩展起来特别方便。我做过一个项目,用户量从几千涨到几十万,认证系统几乎没改过,这就是JWT的好处。

JWT的结构分三部分:

部分 说明 示例
Header 声明类型和加密算法 {"alg": "HS256", "typ": "JWT"}
Payload 存放用户信息,比如用户ID、角色 {"userId": "123", "role": "admin"}
Signature 对前两部分签名,防止篡改 用密钥加密后的字符串

安装 jsonwebtoken 包:

npm install jsonwebtoken

生成令牌:

const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

function generateToken(user) {
  const payload = {
    userId: user._id,
    username: user.username,
    role: user.role || 'user'
  };
  // 设置过期时间,我一般用 7 天
  const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' });
  return token;
}

验证令牌:

function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    return decoded; // 返回 payload 中的信息
  } catch (error) {
    // 令牌过期或无效
    return null;
  }
}

经验之谈:JWT_SECRET 一定要放在环境变量里,不要硬编码在代码中。我习惯用 crypto.randomBytes(64).toString('hex') 生成一个足够长的密钥。

4.4 登录接口开发:验证身份,发放令牌

登录接口的逻辑其实很简单:

  1. 接收用户名和密码
  2. 根据用户名查找用户
  3. 用 bcrypt 验证密码
  4. 验证通过则生成JWT令牌
  5. 返回令牌和用户信息

代码实现:

app.post('/api/login', async (req, res) => {
  try {
    const { username, password } = req.body;

    // 1. 参数校验
    if (!username || !password) {
      return res.status(400).json({ message: '用户名和密码不能为空' });
    }

    // 2. 查找用户
    const user = await User.findOne({ username });
    if (!user) {
      return res.status(401).json({ message: '用户名或密码错误' });
    }

    // 3. 验证密码
    const isPasswordValid = await verifyPassword(password, user.password);
    if (!isPasswordValid) {
      return res.status(401).json({ message: '用户名或密码错误' });
    }

    // 4. 生成令牌
    const token = generateToken(user);

    // 5. 返回结果
    res.json({
      message: '登录成功',
      token,
      user: {
        id: user._id,
        username: user.username,
        email: user.email,
        role: user.role
      }
    });
  } catch (error) {
    res.status(500).json({ message: '服务器内部错误' });
  }
});

安全提示:登录失败时,不要告诉用户「用户名不存在」或「密码错误」这种具体信息。统一返回「用户名或密码错误」,防止黑客通过枚举用户名来试探有效账号。

4.5 中间件:保护需要登录的接口

有了JWT令牌,我们还需要一个中间件来保护那些需要登录才能访问的接口。比如查看个人资料、提交审批等。

// 认证中间件
function authMiddleware(req, res, next) {
  // 从请求头中获取令牌
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: '未提供认证令牌' });
  }

  const token = authHeader.split(' ')[1];
  const decoded = verifyToken(token);

  if (!decoded) {
    return res.status(401).json({ message: '令牌无效或已过期' });
  }

  // 将用户信息挂载到请求对象上
  req.user = decoded;
  next();
}

// 使用中间件保护接口
app.get('/api/profile', authMiddleware, async (req, res) => {
  const user = await User.findById(req.user.userId);
  res.json({
    id: user._id,
    username: user.username,
    email: user.email,
    role: user.role
  });
});

注意:前端在每次请求时,需要在请求头中带上 Authorization: Bearer <token>。如果令牌过期了,前端需要跳转到登录页让用户重新登录。

4.6 完整流程总结

咱们把整个流程串起来看看:

  1. 注册:用户提交信息 → 后端校验 → bcrypt加密密码 → 存入数据库 → 返回成功
  2. 登录:用户提交凭证 → 后端查找用户 → bcrypt验证密码 → 生成JWT令牌 → 返回令牌
  3. 访问受保护接口:前端携带令牌 → 中间件验证令牌 → 通过则放行 → 返回数据

这套流程我用了好几年,从几十个用户的小系统到几万人的企业OA,从来没出过安全问题。说白了,只要把密码加密和令牌验证这两块做扎实了,认证这块基本就稳了。

下一章咱们聊聊权限控制——有了用户身份之后,怎么控制谁能看什么、谁能做什么。嗯,那才是OA系统的精髓所在。