服務網關 Zuul 與 Redis 結合實現 Token 權限校驗

這兩天在寫項目的全局權限校驗,用 Zuul 做爲服務網關,在 Zuul 的前置過濾器裏作的校驗。前端

權限校驗或者身份驗證就不得不提 Token,目前 Token 的驗證方式有不少種,有生成 Token 後將 Token 存儲在 Redis 或數據庫的,也有不少用 JWT(JSON Web Token)的。java

說實話這方面個人經驗很少,又着急趕項目,因此就先用個簡單的方案。git

登陸成功後將 Token 返回給前端,同時將 Token 存在 Redis 裏。每次請求接口都從 Cookie 或 Header 中取出 Token,在從 Redis 中取出存儲的 Token,比對是否一致。程序員

我知道這方案不是最完美的,還有安全性問題,容易被劫持。但目前的策略是先把項目功能作完,上線以後再慢慢優化,不在一個功能點上扣的太細,保證項目進度不至於太慢。github

項目地址:https://github.com/cachecats/... redis

本文將分四部分介紹算法

  1. 登陸邏輯
  2. AuthFilter 前置過濾器校驗邏輯
  3. 工具類
  4. 演示驗證

1、登陸邏輯

登陸成功後,將生成的 Token 存儲在 Redis 中。用 String 類型的 key, value 格式存儲,key是 TOKEN_userId,若是用戶的 userId 是 222222,那鍵就是 TOKEN_222222;值是生成的 Token。spring

只貼出登陸的 Serive 代碼數據庫

@Override
public UserInfoDTO loginByEmail(String email, String password) {

    if (StringUtils.isEmpty(email) || StringUtils.isEmpty(password)) {
        throw new UserException(ResultEnum.EMAIL_PASSWORD_EMPTY);
    }

    UserInfo user = userRepository.findUserInfoByEmail(email);
    if (user == null) {
        throw new UserException(ResultEnum.EMAIL_NOT_EXIST);
    }
    if (!user.getPassword().equals(password)) {
        throw new UserException(ResultEnum.PASSWORD_ERROR);
    }

    //生成 token 並保存在 Redis 中
    String token = KeyUtils.genUniqueKey();
    //將token存儲在 Redis 中。鍵是 TOKEN_用戶id, 值是token
    redisUtils.setString(String.format(RedisConsts.TOKEN_TEMPLATE, user.getId()), token, 2l, TimeUnit.HOURS);

    UserInfoDTO dto = new UserInfoDTO();
    BeanUtils.copyProperties(user, dto);
    dto.setToken(token);

    return dto;
}

2、AuthFilter 前置過濾器

AuthFilter 繼承自 ZuulFilter,必須實現 ZuulFilter 的四個方法。小程序

filterType(): Filter 的類型,前置過濾器返回 PRE_TYPE

filterOrder(): Filter 的順序,值越小越先執行。這裏的寫法是 PRE_DECORATION_FILTER_ORDER - 1, 也是官方建議的寫法。

shouldFilter(): 是否應該過濾。返回 true 表示過濾,false 不過濾。能夠在這個方法裏判斷哪些接口不須要過濾,本例排除了註冊和登陸接口,除了這兩個接口,其餘的都須要過濾。

run(): 過濾器的具體邏輯

爲了方便前端,考慮到要給 pc、app、小程序等不一樣平臺提供服務,token 設置在 cookie 和 header 任選一都可,會先從 cookie 中取,cookie 中沒有再從 header 中取。

package com.solo.coderiver.gateway.filter;

import com.google.gson.Gson;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.solo.coderiver.gateway.VO.ResultVO;
import com.solo.coderiver.gateway.consts.RedisConsts;
import com.solo.coderiver.gateway.utils.CookieUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;

import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_DECORATION_FILTER_ORDER;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;

/**
 * 權限驗證 Filter
 * 註冊和登陸接口不過濾
 *
 * 驗證權限須要前端在 Cookie 或 Header 中(二選一便可)設置用戶的 userId 和 token
 * 由於 token 是存在 Redis 中的,Redis 的鍵由 userId 構成,值是 token
 * 在兩個地方都沒有找打 userId 或 token其中之一,就會返回 401 無權限,並給與文字提示
 */
@Slf4j
@Component
public class AuthFilter extends ZuulFilter {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    //排除過濾的 uri 地址
    private static final String LOGIN_URI = "/user/user/login";
    private static final String REGISTER_URI = "/user/user/register";

    //無權限時的提示語
    private static final String INVALID_TOKEN = "invalid token";
    private static final String INVALID_USERID = "invalid userId";

    @Override
    public String filterType() {
        return PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return PRE_DECORATION_FILTER_ORDER - 1;
    }

    @Override
    public boolean shouldFilter() {
        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletRequest request = requestContext.getRequest();

        log.info("uri:{}", request.getRequestURI());
        //註冊和登陸接口不攔截,其餘接口都要攔截校驗 token
        if (LOGIN_URI.equals(request.getRequestURI()) ||
                REGISTER_URI.equals(request.getRequestURI())) {
            return false;
        }
        return true;
    }

    @Override
    public Object run() throws ZuulException {
        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletRequest request = requestContext.getRequest();

        //先從 cookie 中取 token,cookie 中取失敗再從 header 中取,兩重校驗
        //經過工具類從 Cookie 中取出 token
        Cookie tokenCookie = CookieUtils.getCookieByName(request, "token");
        if (tokenCookie == null || StringUtils.isEmpty(tokenCookie.getValue())) {
            readTokenFromHeader(requestContext, request);
        } else {
            verifyToken(requestContext, request, tokenCookie.getValue());
        }

        return null;
    }

    /**
     * 從 header 中讀取 token 並校驗
     */
    private void readTokenFromHeader(RequestContext requestContext, HttpServletRequest request) {
        //從 header 中讀取
        String headerToken = request.getHeader("token");
        if (StringUtils.isEmpty(headerToken)) {
            setUnauthorizedResponse(requestContext, INVALID_TOKEN);
        } else {
            verifyToken(requestContext, request, headerToken);
        }
    }

    /**
     * 從Redis中校驗token
     */
    private void verifyToken(RequestContext requestContext, HttpServletRequest request, String token) {
        //須要從cookie或header 中取出 userId 來校驗 token 的有效性,由於每一個用戶對應一個token,在Redis中是以 TOKEN_userId 爲鍵的
        Cookie userIdCookie = CookieUtils.getCookieByName(request, "userId");
        if (userIdCookie == null || StringUtils.isEmpty(userIdCookie.getValue())) {
            //從header中取userId
            String userId = request.getHeader("userId");
            if (StringUtils.isEmpty(userId)) {
                setUnauthorizedResponse(requestContext, INVALID_USERID);
            } else {
                String redisToken = stringRedisTemplate.opsForValue().get(String.format(RedisConsts.TOKEN_TEMPLATE, userId));
                if (StringUtils.isEmpty(redisToken) || !redisToken.equals(token)) {
                    setUnauthorizedResponse(requestContext, INVALID_TOKEN);
                }
            }
        } else {
            String redisToken = stringRedisTemplate.opsForValue().get(String.format(RedisConsts.TOKEN_TEMPLATE, userIdCookie.getValue()));
            if (StringUtils.isEmpty(redisToken) || !redisToken.equals(token)) {
                setUnauthorizedResponse(requestContext, INVALID_TOKEN);
            }
        }
    }


    /**
     * 設置 401 無權限狀態
     */
    private void setUnauthorizedResponse(RequestContext requestContext, String msg) {
        requestContext.setSendZuulResponse(false);
        requestContext.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());

        ResultVO vo = new ResultVO();
        vo.setCode(401);
        vo.setMsg(msg);
        Gson gson = new Gson();
        String result = gson.toJson(vo);

        requestContext.setResponseBody(result);
    }
}

3、工具類

MD5 工具類

package com.solo.coderiver.user.utils;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
 * 生成 MD5 的工具類
 */
public class MD5Utils {

    public static String getMd5(String plainText) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(plainText.getBytes());
            byte b[] = md.digest();

            int i;

            StringBuffer buf = new StringBuffer("");
            for (int offset = 0; offset < b.length; offset++) {
                i = b[offset];
                if (i < 0)
                    i += 256;
                if (i < 16)
                    buf.append("0");
                buf.append(Integer.toHexString(i));
            }
            //32位加密
            return buf.toString();
            // 16位的加密
            //return buf.toString().substring(8, 24);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 加密解密算法 執行一次加密,兩次解密
     */
    public static String convertMD5(String inStr){

        char[] a = inStr.toCharArray();
        for (int i = 0; i < a.length; i++){
            a[i] = (char) (a[i] ^ 't');
        }
        String s = new String(a);
        return s;

    }
}

生成 key 的工具類

package com.solo.coderiver.user.utils;

import java.util.Random;

public class KeyUtils {

    /**
     * 產生獨一無二的key
     */
    public static synchronized String genUniqueKey(){
        Random random = new Random();
        int number = random.nextInt(900000) + 100000;
        String key = System.currentTimeMillis() + String.valueOf(number);
        return MD5Utils.getMd5(key);
    }
}

4、演示驗證

在 8084 端口啓動 api_gateway 項目,同時啓動 user 項目。

用 postman 經過網關訪問登陸接口,由於過濾器對登陸和註冊接口排除了,因此不會校驗這兩個接口的 token。

能夠看到,訪問地址 http://localhost:8084/user/user/login 登陸成功並返回了用戶信息和 token。

此時應該把 token 存入 Redis 中了,用戶的 id 是 111111 ,因此鍵是 TOKEN_111111,值是剛生成的 token 值

再來隨便請求一個其餘的接口,應該走過濾器。

header 中不傳 token 和 userId,返回 401

只傳 token 不傳 userId,返回401並提示 invalid userId

token 和 userId 都傳,但 token 不對,返回401,並提示 invalid token

同時傳正確的 token 和 userId,請求成功

以上就是簡單的 Token 校驗,若是有更好的方案歡迎在評論區交流


代碼出自開源項目 CodeRiver,致力於打造全平臺型全棧精品開源項目。

coderiver 中文名 河碼,是一個爲程序員和設計師提供項目協做的平臺。不管你是前端、後端、移動端開發人員,或是設計師、產品經理,均可以在平臺上發佈項目,與志同道合的小夥伴一塊兒協做完成項目。

coderiver河碼 相似程序員客棧,但主要目的是方便各細分領域人才之間技術交流,共同成長,多人協做完成項目。暫不涉及金錢交易。

計劃作成包含 pc端(Vue、React)、移動H5(Vue、React)、ReactNative混合開發、Android原生、微信小程序、java後端的全平臺型全棧項目,歡迎關注。

項目地址:https://github.com/cachecats/...


您的鼓勵是我前行最大的動力,歡迎點贊,歡迎送小星星✨ ~

相關文章
相關標籤/搜索