服务网关 Zuul 与 Redis 结合实现 Token 权限校验

4,101 阅读6分钟

这两天在写项目的全局权限校验,用 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…


您的鼓励是我前行最大的动力,欢迎点赞,欢迎送小星星✨ ~