第 4 篇:用JWT与角色权限构筑安全的API防线

JWT 认证 + 角色权限:充电桩平台的安全体系搭建

在前三篇文章中,我们搭建了充电桩平台的基础后端服务、数据模型以及设备接入模块。随着平台功能逐渐完善,安全成为不可回避的核心问题------如何确保只有合法用户才能访问 API?如何区分管理员和普通充电用户?本文将使用 Express.js + JWT + bcryptjs 构建一套完整的认证授权体系,并配合前端 React + AuthContext 实现路由保护。完整的代码示例带你一步步落地。

一、JWT 认证原理:为什么选择它?

传统的 session 认证需要服务器存储会话信息,在分布式环境中需要共享存储。而 JWT(JSON Web Token) 是无状态的:服务器生成一个签名的 token 发给客户端,后续请求客户端在 Authorization 头中携带它,服务器验证签名即可确认用户身份。

一个 JWT 由三部分组成:Header(算法和类型)、Payload(用户信息、过期时间等)、Signature(使用密钥对前两部分签名)。核心优势是自包含------无需查数据库即可验证身份,非常适合 RESTful API。

二、密码加密存储:bcryptjs 的实战应用

用户的密码绝不能明文存储。bcryptjs 是一个慢哈希算法库,内置盐值生成和验证功能。

安装依赖

bash 复制代码
npm install bcryptjs jsonwebtoken express-validator

注册时加密密码

javascript 复制代码
// models/User.js (Mongoose 示例)
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
  role: { type: String, enum: ['admin', 'operator', 'user'], default: 'user' }
});

// 保存前自动哈希密码
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

// 验证密码的方法
userSchema.methods.comparePassword = async function(candidatePassword) {
  return await bcrypt.compare(candidatePassword, this.password);
};

三、用户注册与登录接口

注册接口:POST /api/auth/register

javascript 复制代码
// routes/auth.js
const express = require('express');
const { body, validationResult } = require('express-validator');
const jwt = require('jsonwebtoken');
const User = require('../models/User');

const router = express.Router();

router.post('/register', [
  body('email').isEmail().normalizeEmail(),
  body('password').isLength({ min: 6 }),
  body('role').optional().isIn(['admin', 'operator', 'user'])
], async (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });

  const { email, password, role } = req.body;
  try {
    // 检查用户是否已存在
    const existingUser = await User.findOne({ email });
    if (existingUser) return res.status(400).json({ message: '邮箱已被注册' });

    const user = new User({ email, password, role });
    await user.save();

    // 生成 JWT
    const token = jwt.sign(
      { userId: user._id, email: user.email, role: user.role },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );

    res.status(201).json({
      token,
      user: { id: user._id, email: user.email, role: user.role }
    });
  } catch (err) {
    res.status(500).json({ message: '服务器错误' });
  }
});

登录接口:POST /api/auth/login

javascript 复制代码
router.post('/login', [
  body('email').isEmail(),
  body('password').notEmpty()
], async (req, res) => {
  const { email, password } = req.body;
  try {
    const user = await User.findOne({ email });
    if (!user) return res.status(401).json({ message: '邮箱或密码错误' });

    const isMatch = await user.comparePassword(password);
    if (!isMatch) return res.status(401).json({ message: '邮箱或密码错误' });

    const token = jwt.sign(
      { userId: user._id, email: user.email, role: user.role },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );

    res.json({
      token,
      user: { id: user._id, email: user.email, role: user.role }
    });
  } catch (err) {
    res.status(500).json({ message: '服务器错误' });
  }
});

四、中间件实现路由保护

创建一个通用的 JWT 验证中间件,解析 token 后挂载 req.user

javascript 复制代码
// middleware/auth.js
const jwt = require('jsonwebtoken');

const verifyToken = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: '未提供认证令牌' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;  // 包含 userId, email, role
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ message: '令牌已过期' });
    }
    return res.status(403).json({ message: '无效的令牌' });
  }
};

module.exports = { verifyToken };

使用示例:保护任何需要登录的路由

javascript 复制代码
router.get('/profile', verifyToken, (req, res) => {
  res.json({ user: req.user });
});

五、三种角色权限设计(admin/operator/user)

在充电桩平台中,角色权限定义如下:

  • admin:系统级管理员,可创建/删除运营商,查看所有充电桩数据,分配角色。
  • operator:场站运营商,管理自己名下的充电桩、查看订单、设置电价。
  • user:普通充电用户,仅能查看附近充电桩、发起充电、查看自己的订单。

授权中间件

javascript 复制代码
// middleware/auth.js
const requireRole = (allowedRoles) => {
  return (req, res, next) => {
    if (!req.user) return res.status(401).json({ message: '未认证' });
    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ message: '权限不足' });
    }
    next();
  };
};

实际应用示例

javascript 复制代码
// 只有 admin 可以查看所有用户
router.get('/users', verifyToken, requireRole(['admin']), async (req, res) => {
  const users = await User.find().select('-password');
  res.json(users);
});

// operator 和 admin 可以管理充电桩
router.post('/stations', verifyToken, requireRole(['admin', 'operator']), (req, res) => {
  // 创建充电桩逻辑...
});

// user 可以查看自己的订单
router.get('/orders/my', verifyToken, requireRole(['user', 'operator', 'admin']), (req, res) => {
  // 返回 req.user.userId 关联的订单
});

六、前端实现:AuthContext 与 ProtectedRoute

前端使用 React + React Router v6 + Axios。首先创建一个认证上下文来管理 token 和用户状态。

1. AuthContext 定义

javascript 复制代码
// context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';

const AuthContext = createContext();

export const useAuth = () => useContext(AuthContext);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const token = localStorage.getItem('token');
    const userInfo = localStorage.getItem('user');
    if (token && userInfo) {
      setUser(JSON.parse(userInfo));
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    }
    setLoading(false);
  }, []);

  const login = async (email, password) => {
    const response = await axios.post('/api/auth/login', { email, password });
    const { token, user } = response.data;
    localStorage.setItem('token', token);
    localStorage.setItem('user', JSON.stringify(user));
    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    setUser(user);
    return user;
  };

  const logout = () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    delete axios.defaults.headers.common['Authorization'];
    setUser(null);
  };

  const value = { user, login, logout, loading };
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

2. ProtectedRoute 组件

javascript 复制代码
// components/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

const ProtectedRoute = ({ children, allowedRoles = [] }) => {
  const { user, loading } = useAuth();

  if (loading) return <div>加载中...</div>;
  if (!user) return <Navigate to="/login" replace />;
  if (allowedRoles.length && !allowedRoles.includes(user.role)) {
    return <Navigate to="/unauthorized" replace />;
  }
  return children;
};

export default ProtectedRoute;

3. 在路由中使用

javascript 复制代码
// App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import AdminDashboard from './pages/AdminDashboard';
import OperatorPanel from './pages/OperatorPanel';
import UserProfile from './pages/UserProfile';

function App() {
  return (
    <BrowserRouter>
      <AuthProvider>
        <Routes>
          <Route path="/login" element={<LoginPage />} />
          <Route path="/admin" element={
            <ProtectedRoute allowedRoles={['admin']}>
              <AdminDashboard />
            </ProtectedRoute>
          } />
          <Route path="/operator" element={
            <ProtectedRoute allowedRoles={['admin', 'operator']}>
              <OperatorPanel />
            </ProtectedRoute>
          } />
          <Route path="/profile" element={
            <ProtectedRoute allowedRoles={['admin', 'operator', 'user']}>
              <UserProfile />
            </ProtectedRoute>
          } />
        </Routes>
      </AuthProvider>
    </BrowserRouter>
  );
}

4. Axios 拦截器自动刷新 token(进阶)

在实际生产环境中,token 过期后需要静默刷新。可以添加响应拦截器:

javascript 复制代码
// axiosConfig.js
import axios from 'axios';

let refreshPromise = null;

axios.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      try {
        // 调用刷新 token 接口(需要后端支持)
        const refreshToken = localStorage.getItem('refreshToken');
        const { data } = await axios.post('/api/auth/refresh', { refreshToken });
        localStorage.setItem('token', data.token);
        axios.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
        originalRequest.headers['Authorization'] = `Bearer ${data.token}`;
        return axios(originalRequest);
      } catch (err) {
        // 刷新失败,跳转登录
        window.location.href = '/login';
        return Promise.reject(err);
      }
    }
    return Promise.reject(error);
  }
);

七、总结

现在,你的充电桩平台已经拥有一套完整的认证和权限体系:

  • 后端:基于 JWT 的无状态认证,bcryptjs 加密密码,中间件 + 角色守卫确保每个 API 的访问控制。
  • 前端:AuthContext 统一管理登录状态,ProtectedRoute 实现客户端路由保护,支持三种角色的差异化界面。

安全是一个持续演进的话题,后续还可以加入:限流防暴力破解、HTTPS 强制、日志审计、API 请求签名(防止重放攻击)等。

相关推荐
fengfuyao9858 小时前
基于MATLAB的ALOHA防碰撞、二进制搜索算法和帧时隙算法
人工智能·算法·matlab
yongui478348 小时前
光伏逆变器完整控制程序
算法
吃好睡好便好8 小时前
在Matlab中绘制峰值图
开发语言·学习·算法·matlab·信息可视化
此生决int8 小时前
算法从入门到精通——滑动窗口
c++·算法·蓝桥杯
兩尛8 小时前
std::shared_mutex、std::mutex和std::recursive_mutex是什么锁
开发语言·c++·算法
木井巳8 小时前
【递归算法】不同路径Ⅲ
java·算法·leetcode·深度优先
想带你从多云到转晴8 小时前
07、数据结构与算法---优先级队列(堆)与排序
java·数据结构·算法
吃好睡好便好8 小时前
在Matlab中绘制非默认峰值图
开发语言·学习·算法·matlab
Huangjin007_9 小时前
【C++ STL篇(九)】map容器——零基础入门与核心用法精讲
开发语言·c++·算法