4、用户注册与登录模块(后端):用户模型完善、密码加密(bcrypt)、JWT令牌生成与验证、注册接口开发、登录接口开发

好,咱们进入实战环节的第一个硬核模块——用户注册与登录。

说实话,这个模块我做了不下几十次了。从最早用MD5存密码,到后来加盐,再到用bcrypt,踩过的坑能写一本小册子。今天咱们就一步到位,把用户模型、密码加密、JWT令牌、注册和登录接口全部搞定。

4.1 用户模型完善

之前我们搭了个基础架子,现在要把用户模型真正用起来。先看看需要哪些字段:

字段名 类型 说明
id INTEGER 主键,自增
username STRING(50) 用户名,唯一
email STRING(100) 邮箱,唯一
password STRING(255) 加密后的密码
role ENUM('admin','user') 角色,默认user
status INTEGER 状态:1启用,0禁用
createdAt DATE 创建时间
updatedAt DATE 更新时间

嗯,这里要注意一点:密码字段长度一定要给够。bcrypt生成的哈希值通常是60个字符左右,但不同轮数可能更长。我建议直接给255,省得以后出问题。

在Sequelize里,模型定义是这样的:

// models/User.js
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');

const User = sequelize.define('User', {
  username: {
    type: DataTypes.STRING(50),
    allowNull: false,
    unique: true,
    validate: {
      len: [2, 50]
    }
  },
  email: {
    type: DataTypes.STRING(100),
    allowNull: false,
    unique: true,
    validate: {
      isEmail: true
    }
  },
  password: {
    type: DataTypes.STRING(255),
    allowNull: false
  },
  role: {
    type: DataTypes.ENUM('admin', 'user'),
    defaultValue: 'user'
  },
  status: {
    type: DataTypes.INTEGER,
    defaultValue: 1
  }
}, {
  timestamps: true
});

module.exports = User;
小提示:我习惯把验证规则直接写在模型里,这样不管从哪个入口创建用户,都能保证数据质量。以前吃过亏,在路由层写了验证,结果换个入口就漏了。

4.2 密码加密(bcrypt)

密码不能明文存,这是底线。为什么用bcrypt?说白了,它自带盐值,而且计算速度可以调节。MD5那种秒出的算法,在GPU面前跟纸糊的一样。

安装依赖:

npm install bcryptjs

注意我用的是bcryptjs,纯JavaScript实现,不需要编译。如果你用bcrypt,需要装C++编译工具,在Windows上经常出问题。我个人建议直接用bcryptjs,省心。

封装一个密码工具类:

// utils/password.js
const bcrypt = require('bcryptjs');

const SALT_ROUNDS = 10;

// 加密密码
async function hashPassword(password) {
  const salt = await bcrypt.genSalt(SALT_ROUNDS);
  return bcrypt.hash(password, salt);
}

// 验证密码
async function comparePassword(password, hash) {
  return bcrypt.compare(password, hash);
}

module.exports = { hashPassword, comparePassword };
关于SALT_ROUNDS:10是推荐值,大约需要100ms。轮数越大越安全,但响应时间也越长。我曾经试过12,结果注册接口响应慢了300ms,用户体验明显下降。所以,平衡很重要。

4.3 JWT令牌生成与验证

用户登录后,怎么证明「他是他」?传统方案用session,但分布式部署时很麻烦。JWT(JSON Web Token)就解决了这个问题——令牌自带用户信息,服务端不需要存状态。

安装依赖:

npm install jsonwebtoken

JWT工具类:

// utils/jwt.js
const jwt = require('jsonwebtoken');

const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key';
const EXPIRES_IN = '7d';

// 生成令牌
function generateToken(payload) {
  return jwt.sign(payload, SECRET_KEY, { expiresIn: EXPIRES_IN });
}

// 验证令牌
function verifyToken(token) {
  try {
    return jwt.verify(token, SECRET_KEY);
  } catch (error) {
    return null;
  }
}

module.exports = { generateToken, verifyToken };
警告:SECRET_KEY一定要用环境变量,别写死在代码里。我曾经见过有人把密钥提交到GitHub,结果整个系统被脱裤。嗯,那个人就是我同事。

JWT的结构分三部分:Header、Payload、Signature。说白了就是:

  • Header:声明算法类型,比如HS256
  • Payload:存放用户信息,比如userId、role
  • Signature:用密钥对前两部分签名,防止篡改

你想想看,如果中间人改了Payload里的userId,Signature验证就会失败。这就是JWT的安全基础。

4.4 注册接口开发

注册接口的逻辑很清晰:接收用户名、邮箱、密码 → 验证是否已存在 → 加密密码 → 存入数据库 → 返回成功。

但这里有个细节:密码不要返回给前端。哪怕加密了也不行,这是安全规范。

// routes/auth.js
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const { hashPassword } = require('../utils/password');

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

    // 检查用户名是否已存在
    const existingUser = await User.findOne({
      where: {
        [Op.or]: [{ username }, { email }]
      }
    });

    if (existingUser) {
      return res.status(400).json({
        code: 400,
        message: '用户名或邮箱已存在'
      });
    }

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

    // 创建用户
    const user = await User.create({
      username,
      email,
      password: hashedPassword
    });

    // 返回用户信息(不包含密码)
    res.status(201).json({
      code: 201,
      message: '注册成功',
      data: {
        id: user.id,
        username: user.username,
        email: user.email,
        role: user.role
      }
    });
  } catch (error) {
    res.status(500).json({
      code: 500,
      message: '服务器内部错误'
    });
  }
});
避坑指南:我曾经在注册接口里直接返回了user.toJSON(),结果密码字段也暴露了。后来我学乖了,要么用attributes排除,要么手动构造返回对象。

4.5 登录接口开发

登录接口比注册多一步:验证密码 + 生成JWT。

// 登录接口
router.post('/login', async (req, res) => {
  try {
    const { username, password } = req.body;

    // 查找用户
    const user = await User.findOne({
      where: { username }
    });

    if (!user) {
      return res.status(401).json({
        code: 401,
        message: '用户名或密码错误'
      });
    }

    // 检查用户状态
    if (user.status === 0) {
      return res.status(403).json({
        code: 403,
        message: '账号已被禁用'
      });
    }

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

    // 生成JWT
    const token = generateToken({
      userId: user.id,
      username: user.username,
      role: user.role
    });

    res.json({
      code: 200,
      message: '登录成功',
      data: {
        token,
        user: {
          id: user.id,
          username: user.username,
          email: user.email,
          role: user.role
        }
      }
    });
  } catch (error) {
    res.status(500).json({
      code: 500,
      message: '服务器内部错误'
    });
  }
});

为什么登录成功要返回token?因为后续所有需要鉴权的接口,前端都要在请求头里带上这个token。格式是:Authorization: Bearer <token>

安全建议:
  • 登录失败不要告诉用户是「用户名不存在」还是「密码错误」,统一返回「用户名或密码错误」
  • JWT过期时间不要太长,7天是比较合理的值
  • 生产环境一定要用HTTPS,否则token会被中间人截获

4.6 完整流程测试

接口写完了,咱们用Postman测试一下:

  1. 注册:POST /api/auth/register,body传username、email、password
  2. 登录:POST /api/auth/login,body传username、password
  3. 验证:登录成功后拿到token,在后续请求的Header里加Authorization

嗯,到这里用户注册登录模块就完成了。下一章咱们会写一个JWT中间件,用来保护需要登录才能访问的接口。到时候你就知道token怎么用了。

记住一句话:用户认证是系统的第一道门,门没锁好,里面装修得再漂亮也没用