一、梳理逻辑

之前的认证中心作为一个单体项目时,过滤器认证成功后,是直接放行然后访问方法。
但在微服务的情况下

  1. 在网关添加过滤器
  2. 在过滤器中获取到请求的JWT与url,通过openFeign将JWT与url发送给认证中心
  3. ShiroConfig中将该请求放行,该方法用于鉴权,不能被拦截
  4. 放行后进入AuthController中对应的方法,将url拼接成需要访问的uri,将JWT放入头信息,通过RestTemplate发出请求,该请求就会进入认证中心的过滤器中过滤(举例:JWTFilterMyPermissionsAuthorizationFilter自定义过滤器)
  5. 修改过滤器中逻辑,判断当前过滤器是否是最后一个过滤器,如果不是,则权限认证通过时直接放行至下一个过滤器,如果是最后一个过滤器,则返回false,携带认证通过的信息返回给网关,网关执行chain.filter(exchange)方法后进入微服务执行方法
  6. 此时验证通过的话,如果url中的请求本来就是访问认证中心的其他服务(例如获取菜单),过滤器就会重新拦截一个请求,但请求中不包含localhost,而是另一个ip地址,所以我们在判断出最后一个过滤器中,还要加一层逻辑,第二次请求(即网关执行chain.filter(exchange)后进入认证中心的请求)就放行,让其访问认证中心中的方法

无标题.png

二、网关代码

2.1 配置文件application.yml

  1. server:
  2. port: 9005
  3. spring:
  4. application:
  5. name: ticket-gateway
  6. cloud:
  7. gateway:
  8. routes:
  9. - id: users
  10. uri: lb://ticket-user
  11. predicates:
  12. - Path=/user/**
  13. - id: orders
  14. uri: lb://ticket-order
  15. predicates:
  16. - Path=/order/**
  17. - id: auth
  18. uri: lb://ticket-auth
  19. predicates:
  20. - Path=/perm/**
  21. logging:
  22. level:
  23. root: info
  24. eureka:
  25. client:
  26. service-url:
  27. defaultZone: http://localhost:8761/eureka/

2.2 AuthService

import com.woniuxy.ticket.commons.entity.ResponseResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

/**
 * 访问认证服务
 */
@FeignClient("ticket-auth")
public interface AuthService {
    @GetMapping("/auth")
    ResponseResult parseJwt(@RequestParam("jwt") String jwt,@RequestParam("url") String url);
}

2.3 AuthFilter

import com.woniuxy.ticket.commons.entity.ResponseResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;

/**
 * 获取到请求的JWT与url,通过openFeign将JWT与url发送给认证中心
 */
@Slf4j
@Component
public class AuthFilter implements GlobalFilter, Ordered {

    @Resource
    private AuthService authService;

    /**
     * 执行全局过滤器的业务逻辑
     * @param exchange 请求/响应的上下文对象
     * @param chain 过滤器链
     * @return
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        log.info("Auth Filter");
        String url = exchange.getRequest().getURI().getPath();
        String jwt = exchange.getRequest().getHeaders().getFirst("jwt");
        ResponseResult responseResult = authService.parseJwt(jwt,url);
        if(responseResult.getStatusCode() == 403){
            return authErro(exchange.getResponse(),responseResult.getMessage());
        }else if(responseResult.getStatusCode() == 405){
            return authErro(exchange.getResponse(),responseResult.getMessage());
        }else {
            return chain.filter(exchange);
        }
    }
    private Mono<Void> authErro(ServerHttpResponse resp, String mess) {
        resp.setStatusCode(HttpStatus.UNAUTHORIZED);
        resp.getHeaders().add("Content-Type","application/json;charset=UTF-8");

        DataBuffer buffer = resp.bufferFactory().wrap("error".getBytes(StandardCharsets.UTF_8));
        return resp.writeWith(Flux.just(buffer));
    }

    /**
     * 定义过滤器执行顺序
     * 值越小,越先执行
     * @return
     */
    @Override
    public int getOrder() {
        return -100;
    }
}

三、认证中心代码

3.1 放行鉴权方法

PermMapFactoryBean中添加:

    map.put("/auth","anon");

3.2 AuthController

import com.woniuxy.ticket.commons.entity.ResponseResult;
import org.apache.http.client.utils.URIBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import java.net.URI;
import java.net.URISyntaxException;

/**
 * 接收网关的请求
 */
@RestController
public class AuthController {
    @Value("${server.port}")
    private int port;

    //shiro配置类中放行该方法,所以通过网关请求过来不会经过过滤器
    @RequestMapping("auth")
    public ResponseResult<Void> auth(String jwt, String url) throws URISyntaxException {
        //发送HTTP请求到本项目,会经过Shiro过滤器
        URI uri = new URIBuilder().setScheme("http").setHost("localhost").setPort(port).setPath(url).build();
        // build http headers
        HttpHeaders headers = new HttpHeaders();
        headers.add("jwt", jwt);
        RestTemplate restTemplate = new RestTemplate();
        // send request and get response
        ResponseResult result = restTemplate.postForObject(uri, new HttpEntity<String>(headers), ResponseResult.class);
        return result;
    }
}

3.3 修改过滤器逻辑

将过滤器逻辑封装为工具类,判断时调用isLastFilter来判断是否为最后一个过滤器

FilterUtil

import com.fasterxml.jackson.databind.ObjectMapper;
import com.woniuxy.ticket.commons.entity.ResponseResult;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.springframework.context.ApplicationContext;

import javax.servlet.Filter;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Map;

public class FilterUtil {
    /**
     * 用于判断过滤器是否为过滤链中的最后一个
     * @param request
     * @param response
     * @param applicationContext
     * @param clazz
     * @return
     * @throws IOException
     */
    public static boolean isLastFilter(ServletRequest request, ServletResponse response, ApplicationContext applicationContext, Class clazz) throws IOException {
        //根据request获取请求uri
        HttpServletRequest req = (HttpServletRequest)request;
        String uri = req.getRequestURI();
        //获取该uri需要经过的过滤器
        ShiroFilterFactoryBean shiroFilterFactoryBean = applicationContext.getBean(ShiroFilterFactoryBean.class);
        Map<String, String> filterMap = shiroFilterFactoryBean.getFilterChainDefinitionMap();
        //根据过滤器名找到具体的过滤器的类
        String filters = filterMap.get(uri);
        //获取需要经过的最后一个过滤器key
        String[] strs = filters.replaceAll("(\\[(.*?)])", "").split(",");
        String lastFilter = strs[strs.length -1];
        //查询所有过滤器,通过key找到过滤器的value对应的类
        Map<String, Filter> map = shiroFilterFactoryBean.getFilters();
        Filter filter = map.get(lastFilter);
        String filterClassName = filter.getClass().getName();
        //判断该类是否为最后一个过滤器
        if(clazz.getName().equals(filterClassName)){
            //访问认证中心中的业务时,第一次进来时,url中请求有localhost,我们返回false
            //访问认证中心中的业务时,第二次进来时,是网关鉴权完毕,我们就放行进入该微服务,返回true
            if(req.getRequestURL().toString().contains("localhost")){
                ResponseResult<Void> result = new ResponseResult<>(200, "Last Filter");
                ObjectMapper objectMapper = new ObjectMapper();
                response.getWriter().write(objectMapper.writeValueAsString(result));
                return false;
            }else {
                return true;
            }
        }else{
            //如果不是最后一个,返回true放行至下一个过滤器
            return true;
        }
    }

}

JwtFilter

import com.fasterxml.jackson.databind.ObjectMapper;
import com.woniuxy.ticket.commons.entity.ResponseResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.context.ApplicationContext;

import javax.annotation.Resource;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

/**
 * 自定义过滤器,用于JWT认证的校验
 *
 */
@Slf4j
public class JwtFilter extends AuthenticatingFilter {

    @Resource
    private ApplicationContext applicationContext;

    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
        String jwtToken = ((HttpServletRequest) request).getHeader("jwt");
        return new JwtToken(jwtToken);
    }

    //判断验证是否通过
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if (this.isLoginRequest(request, response)) {
            return true;
        }
        boolean allowed = false;
        try {
            allowed = executeLogin(request, response);
        } catch (IllegalStateException e) { // not found any token
            log.error("Not found any token");
        } catch (Exception e) {
            log.error("Error occurs when login", e);
        }

        response.setContentType("application/json");
        try {
            if (allowed) {
                return FilterUtil.isLastFilter(request,response,applicationContext,this.getClass());
            } else {
                ResponseResult<Void> result = new ResponseResult<>(403, "JWT Not Allowed");
                ObjectMapper objectMapper = new ObjectMapper();
                response.getWriter().write(objectMapper.writeValueAsString(result));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return false;
    }

    //onPreHandle中,若请求的路径非该filter中配置的拦截路径,则直接返回true进行下一个filter
    //我们需要重写,通过isAccessAllowed进行判断
    @Override
    public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        return isAccessAllowed(request, response, mappedValue);
    }

    //为JWT自动续期
    //未实现
    @Override
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
        return super.onLoginSuccess(token, subject, request, response);
    }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        ResponseResult<Void> result = new ResponseResult<>(403, "会话失效,重新登录");
        ObjectMapper objectMapper = new ObjectMapper();
        response.getWriter().write(objectMapper.writeValueAsString(result));
        return false;
    }
}

MyPermissionsAuthorizationFilter

package com.woniuxy.ticket.auth.shiro;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.woniuxy.ticket.commons.entity.ResponseResult;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter;
import org.springframework.context.ApplicationContext;

import javax.annotation.Resource;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.IOException;

/**
 * 自定义过滤器
 */
public class MyPermissionsAuthorizationFilter extends PermissionsAuthorizationFilter {

    @Resource
    private ApplicationContext applicationContext;

    public boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws IOException {

        Subject subject = getSubject(request, response);
        String[] perms = (String[]) mappedValue;

        boolean isPermitted = true;
        if (perms != null && perms.length > 0) {
            if (perms.length == 1) {
                if (!subject.isPermitted(perms[0])) {
                    isPermitted = false;
                }
            } else {
                if (!subject.isPermittedAll(perms)) {
                    isPermitted = false;
                }
            }
        }
        response.setContentType("application/json");
        try {
            if (isPermitted) {
                return FilterUtil.isLastFilter(request,response,applicationContext,this.getClass());
            } else {
                ResponseResult<Void> result = new ResponseResult<>(405, "Perm Not Allowed");
                ObjectMapper objectMapper = new ObjectMapper();
                response.getWriter().write(objectMapper.writeValueAsString(result));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        return isAccessAllowed(request, response, mappedValue);
    }
}

3.4 PermController获取菜单方法

import com.woniuxy.ticket.auth.entity.dto.UserRoleDto;
import com.woniuxy.ticket.auth.jwt.Audience;
import com.woniuxy.ticket.auth.jwt.JwtUtil;
import com.woniuxy.ticket.auth.service.UserService;
import com.woniuxy.ticket.commons.entity.ResponseResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
@RequestMapping("/perm")
public class PermController {

    @Resource
    private Audience audience;

    @Resource
    private UserService userService;

    @GetMapping("/list")
    public ResponseResult list(@RequestHeader("jwt") String jwt){
        if(JwtUtil.parseJwt(jwt,audience.getBase64Secret())){
            int id = JwtUtil.getUserId(jwt,audience.getBase64Secret());
            UserRoleDto userRoleDto = userService.findUserMenus(id);
            return new ResponseResult().success(userRoleDto);
        }
        return new ResponseResult().error();
    }

}

四、其他问题

4.1 中文乱码

在网关pom.xml中导入插件依赖
原因:maven运行环境问题,主要原因是使用了spring boot的maven插件,以spring:run运行的项目,需要在插件中添加运行的编码配置:

            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
                </configuration>
            </plugin>