前后端分离环境的下的API安全实践

/ api安全gin / 没有评论 / 2016浏览

流程前后端分离环境下的api保护.jpg

  1. login 页面用户携带登陆凭证去请求后台。
  2. 后台收到登陆凭证表单去做用户名和密码的验证,验证成功后开始往Redis写一个30分钟有效期的Token。
  3. 后台增加一个中间件,可选择性的给某些API增加安全🔐认证,主要就是验证Toekn有效期,让用户重登。
// route.go	
// hostinfo api
	hostinfo := r.Group("/hostinfo")
	{
		//	 单节点信息获取
		hostinfo.GET("/cpuinfo", src.GetAgentCpuInfo)
		hostinfo.GET("/raminfo", src.GetAgentRamInfo)
		hostinfo.GET("/diskinfo", src.GetAgentDiskInfo)
		// Get All Node Info
		hostinfo.GET("/GetAllNodeInfo", middleware.TokenAuthMiddleware(), src.GetAllNodeInfo)
		hostinfo.GET("/GetAllOfflineAgentInfo", middleware.TokenAuthMiddleware(), src.GetOfflineAgentInfo)
	}
  1. Axios请求拦截: 每次请求后端都要从本地localStorage.setItem get 到token值,携带这个token去请求那些有保护的API

  2. Axios响应拦截: 检测每次后端返回的状态码,约定好,我这里是403,表示前端携带的登陆Toekn在Redis没查到(过期失效),200则正常显示,可以在Axios响应层增加一个全局响应。

Server login

				data := make(map[string]interface{})
				code := e.INVALID_PARAMS				
				data["token"] = token
				data["username"] = username
				data["LoginTimeStamp"] = time.Now().Unix()
				// 专属登陆响应码
				code = e.SUCCESS_LOGIN
				//生成随机数,作为token存在Redis的value
				value := strconv.Itoa(randomdata.Number(10, 200000000))
				err := model.InitRedis().Set(ctx, token, value, time.Minute*30).Err()
				if err != nil {
					log.Error(err)
				}
				
				c.JSON(200, gin.H{
				"code": code,
				"msg":  e.GetMsg(code),
				"data": data,
				})

验证

Server chk token Middleware

没错,是gin的middleware

package middleware

import (
	"context"
	"github.com/gin-gonic/gin"
	"github.com/lijinghuatongxue/server-go/model"
	"github.com/lijinghuatongxue/server-go/pkg/e"
	"net/http"
)

var ctx = context.Background()

type LoginStatusCode struct {
	Code string `json:"code"`
}

func TokenAuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		tokenStr := c.Request.Header.Get("token")
		if tokenStr == "" {
			// 尝试从链接中获取 兼容下部分特殊get请求
			//tokenStr = c.Query("token")
			tokenStr = c.Request.Header.Get("Authorization")
			//logrus.Error(tokenStr)
			if tokenStr == "" {
				c.JSON(http.StatusUnauthorized, gin.H{
					"code": e.INVALID_PARAMS,
					"msg":  e.GetMsg(400),
				})
				c.Abort()
				return
			}
			// key不存在检测
			isexist, _ := model.InitRedis().Exists(ctx, tokenStr).Result()
			if isexist != 1 {
				data := LoginStatusCode{
					Code: "4001",
				}
				c.JSON(403, gin.H{
					"code": e.INVALID_PARAMS,
					"data": data,
					"msg":  e.GetMsg(4001),
				})
				c.Abort()
				return
			}
		}
		c.Next()
	}
}

UI login

登陆完之后,获取一个只有半小时有效期的token,存储在本地

      return this.$http
        .post("/api/login",
        {
          username: this.username,
          password: this.password
        },
        )
        .then(result => {
         // 将Token、时间戳、用户名字,存储到localStorage,用户名用来右上角显示用户
          localStorage.setItem("token", result.data.data.token);
          localStorage.setItem("username", result.data.data.username);
          // 时间戳这个存储意义不大,前期作为检验用户登陆时长用来强制重登的验证,但是localStorage可编辑,可篡改,生产不建议。
          localStorage.setItem("logintimestamp", result.data.data.LoginTimeStamp);
          // localStorage.setItem("username", JSON.stringify(result.data.data.username));
          // 登陆失败重登
          if (result.data.code !== 2001){
            this.$message({
              message: "🐱喵~  "+result.data.msg,
              type: "error"
            });
            this.$router.push("/login");
          }
          // return
          if (result.data.code === 2001){
            // 获取登陆专属响应码,登录成功提醒用户
            this.$message({
              message: result.data.msg+"  欢迎  "+result.data.data.username+" 🐱喵~" ,
              type: "success"
            });
            // 登录成功跳转到首页
            this.$router.push("/navmenu/dashboard");
          }
        }

UI axios 添加请求拦截器

// 添加请求拦截器
axios.interceptors.request.use(function (config) {
  // 在发送请求之前做些什么
  // {
  //   headers: { Authorization: token }
  // }
  let token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = token
  }
  return config;
}, function (error) {
  // 对请求错误做些什么
  return Promise.reject(error);
});

UI axios 添加响应拦截器

每次请求后端api,都携带对应的token去请求,这个token只有半小时的生命周期

// 添加响应拦截器
axios.interceptors.response.use(function (response) {
  // 对响应数据做点什么
  return response;
}, function (error) {
  // 对响应错误做点什么
  if (error) {
    // 请求配置发生的错误
    if (!error.response) {
      return console.log('Error', error.message);
    }
    // 获取状态码
    const status = error.response.status;
    // 错误状态处理
    if (status === 403) {
      console.log("登陆凭证失效,点击重新登陆 🐱")
      // alert("登陆凭证失效,点击重新登陆 🐱")
      ElementUI.Message({
        message: '登陆凭证失效,重新登陆 🐱',
        duration:'2000',
        type: 'warning'
      });
      router.push('/login')
    } else if (status === 400) {
      router.push('/login')
    } else if (status >= 404 && status < 422) {
      router.push('/404')
    }

  }
  return Promise.reject(error);
});

Conclusion / 结论

1. 🌲 前端浏览器任何可编辑的地方,不要存储明文登陆认证信息。
2. 🔒 前端浏览器要是存的话,最好采用前后端共同加密的api认证过期机制。
3. 💻 单独采用后端返回Token是否过期的话,比较死板,因为从登陆事件到Token过期重新登陆事件中,比如我们设置了30分钟过期Token,那便每次登陆,半小时都要重新登陆,再迭代一次,可以增加用户在操作页面功能,一定频率的去后台刷新Token 有效期,保证在登陆之后,不再以30分钟一次的频率,暴力跳转页面。