Skip to content

前后端分离如何保证API的安全

看到标题就有同学疑惑,我们的接口不是在https协议下的吗?为什么还要保证API的安全?

一、 为什么要保证API的安全?

我们的接口都是在https协议下的,为什么还要保证API的安全呢?这是因为我们的接口是在公网上的,所以我们的接口是不安全的,我们需要保证API的安全,防止接口被恶意攻击。

二、 恶意攻击的手段是什么?

身份的冒充和滥用

  • 作用:HTTPS 加密通信可以防止数据被窃取,但它无法确保请求发起者的身份是否合法。
  • 限制:攻击者可以伪造请求并与服务器通信,尤其是在没有身份验证的情况下。如果接口只是使用 HTTPS 而没有有效的身份验证(如 OAuth、JWT、API Key),任何人都可以利用有效的 userid 或其他方法发起请求。 为什么接口仍需要限制
  • 身份验证:通过身份验证机制(如 JWT、OAuth、Session 等)来确保每个请求都来自合法的用户,而不仅仅是一个有效的 HTTPS 加密通道。
  • 权限控制:即使用户身份合法,也应该有权限控制来确保用户只能访问他们有权限的资源,例如用户只能访问与他们自己的 userid 相关的数据。”

CSRF(跨站请求伪造)

  • 作用:HTTPS 能防止中间人攻击,但如果接口没有足够的防护机制,可能会受到 CSRF 攻击,即攻击者利用用户的身份进行恶意操作。
  • 限制:假设用户已经登录了一个网站并存储了有效的 cookie,攻击者可能诱导用户在不知情的情况下发起恶意请求(例如,通过点击一个链接或提交表单)。

为什么接口仍需要限制

  • CSRF 保护机制(如使用 anti-CSRF token)可以确保请求是由用户主动发起的,而不是被恶意站点伪造的。
  • HTTPS 本身不会防止这类攻击,必须通过其他安全措施(如确保 API 请求带有 token,使用 SameSite cookie 等)来避免。”

重放攻击

  • 作用:HTTPS 能防止传输中的数据被窃取和篡改,但它无法防止重放攻击,即攻击者捕获合法的请求并重复发送。
  • 限制:攻击者可以拦截并重放一个合法的请求,服务器可能无法分辨请求是合法的用户发起的,还是被恶意用户重放的。

为什么接口仍需要限制

  • 时间戳和唯一标识符:可以通过在请求中加入时间戳或唯一标识符来防止重放攻击,确保每个请求都是唯一的且及时有效。
  • Nonce 和验证码:在请求中使用 nonce 值或验证码等机制可以防止请求被重放。

防止暴力破解

  • 作用:HTTPS 可以防止中间人攻击,但对于密码、令牌等敏感信息,它并没有防止恶意用户暴力破解接口。
  • 限制:如果接口没有做限制,例如频繁请求或无任何防护,攻击者可以通过暴力破解获取用户名、密码或令牌等。 为什么接口仍需要限制
  • 请求频率限制:接口应限制单个 IP 或用户的请求频率,以防止暴力破解。
  • 密码策略和加密存储:密码和敏感信息应该使用强加密存储,且采取合适的策略(如多因素认证)来防止滥用。

HTTPS 仅保护传输过程中的数据,会被中间人攻击

  • 作用:HTTPS 加密了客户端和服务器之间的通信,确保数据传输的过程中不会被中间人(如黑客、攻击者)窃取或篡改。
  • 限制:HTTPS 并不解决数据本身的安全问题。例如,虽然传输过程中的数据是加密的,但如果服务器存储了敏感信息(如密码、手机号等)且没有适当的保护机制,攻击者可能通过漏洞访问存储数据。

为什么接口仍需要限制

  • 即使 HTTPS 保证了数据在网络中的安全,但如果攻击者能通过 SQL 注入、权限问题、或其他漏洞获取数据库中的数据,或者绕过验证直接访问接口,数据仍然会泄露。
  • 例如,如果数据库没有加密存储敏感信息,攻击者能够在获取数据库后直接访问用户的手机号等信息

总的来说就三点

  • 数据被抓包窃取
  • 数据被掉包篡改
  • 数据被爬取泄露

三、怎么保证接口的安全性

使用token授权机制

用户使用用户名密码登录后服务器给客户端返回一个Token(通常是UUID),并将Token-UserId以键值对的形式存放在缓存服务器中。服务端接收到请求后进行Token验证,如果Token不存在,说明请求无效 alt text

时间戳超时机制

时间戳,是客户端调用接口时对应的当前时间戳,时间戳用于防止DoS攻击。当黑客劫持了请求的url去DoS攻击,每次调用接口时接口都会判断服务器当前系统时间和接口中传的的timestamp的差值,如果这个差值超过某个设置的时间(假如5分钟),那么这个请求将被拦截掉,如果在设置的超时时间范围内,是不能阻止DoS攻击的。 timestamp机制只能减轻DoS攻击的时间,缩短攻击时间。如果黑客修改了时间戳的值可通过sign签名机制来处理。 DoS 重放攻击DoSDenial of Service的简称,即拒绝服务,造成DoS的攻击行为被称为DoS攻击,其目的是使计算机或网络无法提供正常的服务。最常见的DoS攻击有计算机网络带宽攻击和连通性攻击。

DoS攻击是指故意的攻击网络协议实现的缺陷或直接通过野蛮手段残忍地耗尽被攻击对象的资源,目的是让目标计算机或网络无法提供正常的服务或资源访问,使目标系统服务系统停止响应甚至崩溃,而在此攻击中并不包括侵入目标服务器或目标网络设备。这些服务资源包括网络带宽,文件系统空间容量,开放的进程或者允许的连接。这种攻击会导致资源的匮乏,无论计算机的处理速度多快、内存容量多大、网络带宽的速度多快都无法避免这种攻击带来的后果。 一句话,重复使用请求参数伪造二次请求的隐患

sign机制

nonce:随机值,是客户端随机生成的值,作为参数传递过来,随机值的目的是增加sign签名的多变性。随机值一般是数字和字母的组合,6位长度,随机值的组成和长度没有固定规则。

sign: 一般用于参数签名,防止参数被非法篡改,最常见的是修改金额等重要敏感参数, sign的值一般是将所有非空参数按照升续排序然后+token+key+timestamp+nonce(随机数)拼接在一起,然后使用某种加密算法进行加密,作为接口中的一个参数sign来传递,也可以将sign放到请求头中 。

并将该签名存放到缓存服务器中,超时时间设定为跟时间戳的超时时间一致(这就是为什么要尽量短,二者时间一致可以保证无论在timestamp规定时间内还是外本URL都只能访问一次)。 同一个签名只能使用一次,如果发现缓存服务器中已经存在了本次签名,则拒绝服务。(可以有效防止重放攻击 — DoS攻击)

接口在网络传输过程中如果被黑客挟持,并修改其中的参数值,然后再继续调用接口,虽然参数的值被修改了,但是因为黑客不知道sign是如何计算出来的,不知道sign都有哪些值构成,不知道以怎样的顺序拼接在一起的,最重要的是不知道签名字符串中的key是什么,所以黑客可以篡改参数的值,但没法修改sign的值,当服务器调用接口前会按照sign的规则重新计算出sign的值然后和接口传递的sign参数的值做比较,如果相等表示参数值没有被篡改,如果不等,表示参数被非法篡改了,就不执行接口了。

  • 防止重复提交(重放攻击) 对于一些重要的操作需要防止客户端重复提交的(如非幂等性重要操作),具体办法是当请求第一次提交时将sign作为key保存到redis,并设置超时时间,超时时间和Timestamp中设置的差值相同。当同一个请求第二次访问时会先检测redis是否存在该sign,如果存在则证明重复提交了,接口就不再继续调用了。如果sign在缓存服务器中因过期时间到了,而被删除了,此时当这个url再次请求服务器时,因token的过期时间和sign的过期时间一直,sign过期也意味着token过期,那样同样的url再访问服务器会因token错误会被拦截掉,这就是为什么sign和token的过期时间要保持一致的原因。拒绝重复调用机制确保URL被别人截获了也无法使用(如抓取数据)。 对于哪些接口需要防止重复提交可以自定义个注解来标记。

    注意: 所有的安全措施都用上的话有时候难免太过复杂,在实际项目中需要根据自身情况作出裁剪,比如可以只使用签名机制就可以保证信息不会被篡改,或者定向提供服务的时候只用Token机制就可以了。如何裁剪,全看项目实际情况和对接口安全性的要求。

数据加密

alt text

  • 对称加密AES,3DES,DES等,适合做大量数据或数据文件的加解密。
  • 非对称加密RSA,Rabin。公钥加密,私钥解密。对大数据量进行加解密时性能较低。

前端 h5 页面如何保存签名key

思考一个问题:

如果是app可以通过加密固化处理,但是前端h5该如何处理呢,如果将参与生成签名的key,放入到页面中,会导致任意一个人访问网站后都可以按F12查看源代码,就知道key了,同时也知道生成签名的流程,因此中间者可以修改参数后自己再重新生成签名。

解决办法:

前端在调用接口前随机生成一个字符串,然后通过AES进行加密处理,将加密结果放入到请求头中key , 然后将随机生成的字符串 — key , 参与sign的生成,最后进行接口的调用。

使用流程

前端随机生成一个字符串,然后通过AES进行加密,将加密结果放入到请求头中 key = 加密结果(key) 客户端携带参数 nonce(随机数)、ts、sign去调用服务器端的API token , sign = md5(所有非空参数按照升续排序然后 + token + key + ts(当前时间戳) + nonce)

第一步

设所有发送或者接收到的数据为集合M,将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序),比如现在有url = http://ip:port?a=1&b=3&c=4

stringA = a1b3c4 特别注意以下重要规则:

  • 参数名ASCII码从小到大排序(字典序);
  • 如果参数的值为空不参与签名;
  • 参数名区分大小写;
  • 如果存在请求体,则将请求体中的json属性按照上面的规律进行排序
  • 如果您的请求url包含路径参数以及请求体,那所有的参数都将参与运算

    第二步

stringA + token + key + ts(当前时间戳) + nonce 得到stringSignTemp字符并对stringSignTemp进行MD5运算,再将得到的字符串所有字符转换为大写,得到sign值signValue。 将 token , sign , ts , nonce , 加密后的key —> 放入到请求头中访问后端 sign的作用是防止参数被篡改,客户端调用服务端时需要传递sign参数,服务器响应客户端时也可以返回一个sign用于客户度校验返回的值是否被非法篡改了。客户端传的sign和服务器端响应的sign算法可能会不同。

四、demo实现

安装依赖

bash
yarn add jsonwebtoken crypto uuid redis nodemailer express-rate-limit

后端 Node.js 实现

js
// 以下逻辑
// ✅ Redis 记录黑名单(封禁 IP 10 分钟)
// ✅ 自动解封(10 分钟后 IP 可重新访问)
// ✅ 邮件通知(管理员收到封禁通知)
// 以下逻辑
// ✅ Redis 记录黑名单(封禁 IP 10 分钟)
// ✅ 自动解封(10 分钟后 IP 可重新访问)
// ✅ 邮件通知(管理员收到封禁通知)
const express = require("express");
const bodyParser = require("body-parser");
const CryptoJS = require("crypto-js");
const rateLimit = require("express-rate-limit");
const redis = require("redis");
const cors = require("cors");
const nodemailer = require("nodemailer");

const app = express();
app.use(bodyParser.json());
app.use(cors());
// 连接 Redis
const redisClient = redis.createClient();
redisClient.connect().catch(console.error);

const AES_SECRET_KEY = "my_secret_32_byte_key_1234567890"; // 32字节密钥
const FAILED_LIMIT = 5; // 失败次数限制
const BAN_DURATION = 10 * 60; // 10 分钟自动解封(单位:秒)

// 邮件通知配置
const transporter = nodemailer.createTransport({
  service: "gmail", // 你的邮件服务商
  auth: {
    user: "your-email@gmail.com",
    pass: "your-password",
  },
});

// 限制 IP 请求频率
const limiter = rateLimit({
  windowMs: 60 * 1000, // 1 分钟
  max: 5, // 每分钟最多 5 次
  message: { error: "请求过于频繁,请稍后再试" },
  keyGenerator: (req) => req.ip,
});
app.use(limiter);

async function isIPBlocked(ip) {
  return (await redisClient.exists(`blocked:${ip}`)) > 0;
}

async function blockIP(ip) {
  await redisClient.setEx(`blocked:${ip}`, BAN_DURATION, "true");
  await sendBlockNotification(ip);
}

async function unblockIP(ip) {
  await redisClient.del(`blocked:${ip}`);
}

async function sendBlockNotification(ip) {
  const mailOptions = {
    from: "your-email@gmail.com",
    to: "admin@example.com",
    subject: "⚠️ 服务器安全警报:IP 被封禁",
    text: `IP ${ip} 由于多次失败请求已被封禁!`,
  };
  transporter.sendMail(mailOptions, (error, info) => {
    if (error) console.error("邮件发送失败", error);
    else console.log("📧 邮件通知已发送:", info.response);
  });
}

function decryptAES(encryptedText, key) {
  console.log("解密前的数据:", encryptedText);
  const bytes = CryptoJS.AES.decrypt(
    encryptedText,
    CryptoJS.enc.Utf8.parse(key),
    {
      mode: CryptoJS.mode.ECB,
      padding: CryptoJS.pad.Pkcs7,
    },
  );
  console.log("解密后的数据:", bytes.toString(CryptoJS.enc.Utf8));
  return bytes.toString(CryptoJS.enc.Utf8);
}

function generateSign(data, key, timestamp, nonce) {
  const str = JSON.stringify(data) + key + timestamp + nonce;
  return CryptoJS.MD5(str).toString();
}

app.post("/api/decrypt", async (req, res) => {
  const clientIP = req.ip;
  if (await isIPBlocked(clientIP)) {
    return res.status(403).json({ error: "此 IP 已被封禁,请 10 分钟后再试" });
  }

  try {
    const { key, data, timestamp, nonce, sign } = req.body;
    console.log(`[${clientIP}] 请求体111:`, req.body);
    const randomKey = decryptAES(key, AES_SECRET_KEY);
    console.log(`[${clientIP}] 解密后的 Random Key:`, randomKey);

    const decryptedData = JSON.parse(decryptAES(data, randomKey));
    console.log(`[${clientIP}] 解密后的数据:`, decryptedData);

    const serverSign = generateSign(decryptedData, randomKey, timestamp, nonce);
    if (serverSign !== sign) {
      console.warn(`[${clientIP}] 签名校验失败!`);

      const failedAttempts =
        parseInt(await redisClient.get(`failed:${clientIP}`)) || 0;
      await redisClient.set(`failed:${clientIP}`, failedAttempts + 1, "EX", 60);

      if (failedAttempts + 1 >= FAILED_LIMIT) {
        await blockIP(clientIP);
        return res.status(403).json({ error: "多次签名失败,IP 已被封禁" });
      }

      return res.status(403).json({ error: "签名校验失败" });
    }

    await redisClient.del(`failed:${clientIP}`);
    res.json({ success: true, decryptedData });
  } catch (error) {
    console.error(`[${clientIP}] 解密失败:`, error);
    res.status(500).json({ error: "解密失败" });
  }
});

app.listen(3000, () => {
  console.log("服务器运行在 http://localhost:3000");
});

前端代码

前端页面将使用 JavaScript 生成一个随机字符串,使用 AES 加密该字符串,然后发送请求到后端时附带 sign 和 timestamp。

html
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>纯 AES 密钥交换</title>
    <script src="https://cdn.jsdelivr.net/npm/crypto-js@4.1.1/crypto-js.js"></script>
  </head>

  <body>
    <h2>AES 密钥交换示例</h2>
    <button onclick="sendSecureRequest()">发送加密请求</button>

    <script>
      const AES_SECRET_KEY = "my_secret_32_byte_key_1234567890"; // 32字节密钥

      function encryptAES(text, key) {
        return CryptoJS.AES.encrypt(text, CryptoJS.enc.Utf8.parse(key), {
          mode: CryptoJS.mode.ECB,
          padding: CryptoJS.pad.Pkcs7,
        }).toString();
      }

      function generateRandomKey() {
        return CryptoJS.lib.WordArray.random(16).toString();
      }

      function generateSign(data, key, timestamp, nonce) {
        const str = JSON.stringify(data) + key + timestamp + nonce;
        return CryptoJS.MD5(str).toString();
      }

      async function sendSecureRequest() {
        const randomKey = generateRandomKey();
        const encryptedRandomKey = encryptAES(randomKey, AES_SECRET_KEY);

        const data = { phone: "18812345678", amount: "500" };
        const encryptedData = encryptAES(JSON.stringify(data), randomKey);

        const timestamp = Date.now();
        const nonce = Math.random().toString(36).substring(2, 8);
        const sign = generateSign(data, randomKey, timestamp, nonce);

        const requestBody = {
          key: encryptedRandomKey,
          data: encryptedData,
          timestamp,
          nonce,
          sign,
        };
        console.log("发送数据:", requestBody);

        const response = await fetch("http://localhost:3000/api/decrypt", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(requestBody),
        });

        const result = await response.json();
        console.log("服务器返回:", result);
      }
    </script>
  </body>
</html>
  1. 运行后端
bash
node server.js
  1. 打开前端 HTML,点击 "发送加密请求"
  2. 后端成功解密
bash
解密后的 Random Key: xxxxxx
解密后的数据: { phone: '18812345678', amount: '500' }
  1. 如果篡改数据,签名校验失败
  • JWT Token:后端生成并验证 JWT,用于身份验证。
  • 时间戳超时机制:前端发送请求时附带时间戳,后端通过比较时间戳和当前时间来判断请求是否超时。
  • 签名机制:前端生成一个随机的 signKey,并与 requestIdtimestamp 一起计算签名,后端验证该签名。
  • 防止重放攻击:使用唯一的 requestId 来标识请求,防止重复请求。
  • 加密方法:前端通过 AES 加密 signKey,后端使用私钥解密进行验证。

五、完整demo

前后端分离如何保证API的安全

六、参考文章

cookie从入门到精通
token从入门到精通
jwt从入门到精通
session从入门到精通

上次更新于: