服務閘道器 Zuul 與 Redis 結合實現 Token 許可權校驗

solo發表於2019-01-19

這兩天在寫專案的全域性許可權校驗,用 Zuul 作為服務閘道器,在 Zuul 的前置過濾器裡做的校驗。

許可權校驗或者身份驗證就不得不提 Token,目前 Token 的驗證方式有很多種,有生成 Token 後將 Token 儲存在 Redis 或資料庫的,也有很多用 JWT(JSON Web Token)的。

說實話這方面我的經驗不多,又著急趕專案,所以就先用個簡單的方案。

登入成功後將 Token 返回給前端,同時將 Token 存在 Redis 裡。每次請求介面都從 Cookie 或 Header 中取出 Token,在從 Redis 中取出儲存的 Token,比對是否一致。

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

專案地址:https://github.com/cachecats/…

本文將分四部分介紹

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

一、登入邏輯

登入成功後,將生成的 Token 儲存在 Redis 中。用 String 型別的 key, value 格式儲存,key是 TOKEN_userId,如果使用者的 userId 是 222222,那鍵就是 TOKEN_222222;值是生成的 Token。

只貼出登入的 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;
}

二、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);
    }
}

三、工具類

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);
    }
}

四、演示驗證

在 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/…


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

相關文章