1. 程式人生 > >服務閘道器 Zuul 與 Redis 結合實現 Token 許可權校驗

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

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

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

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

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

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

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

本文將分四部分介紹

  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後端的全平臺型全棧專案,歡迎關注。

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


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