[TOC]


AOP实现登录状态检查

微服务的用户认证与授权杂谈(上)一文中简单介绍了微服务下常见的几种认证授权方案,并且使用JWT编写了一个极简demo来模拟Token的颁发及校验。而本文的目的主要是延续上文来补充几个要点,例如Token如何在多个微服务间进行传递,以及如何利用AOP实现登录态和权限的统一校验。

为了让登录态的检查逻辑能够通用,我们一般会选择使用过滤器、拦截器以及AOP等手段来实现这个功能。而本小节主要是介绍使用AOP实现登录状态检查,因为利用AOP同样可以拦截受保护的资源访问请求,在对资源访问前先做一些必要的检查。

首先需要在项目中添加AOP的依赖:

<!-- AOP -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

定义一个注解,用于标识哪些方法在被访问之前需要进行登录态的检查。具体代码如下:

package com.zj.node.usercenter.auth;

/**
 * 被该注解标记的方法都需要检查登录状态
 *
 * @author 01
 * @date 2019-09-08
 **/
public @interface CheckLogin {
}

编写一个切面,实现登录态检查的具体逻辑,代码如下:

package com.zj.node.usercenter.auth;

import com.zj.node.usercenter.util.JwtOperator;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.BooleanUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;

/**
 * 登录态检查切面类
 *
 * @author 01
 * @date 2019-09-08
 **/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class CheckLoginAspect {

    private static final String TOKEN_NAME = "X-Token";

    private final JwtOperator jwtOperator;

    /**
     * 在执行@CheckLogin注解标识的方法之前都会先执行此方法
     */
    @Around("@annotation(com.zj.node.usercenter.auth.CheckLogin)")
    public Object checkLogin(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取request对象
        ServletRequestAttributes attributes = (ServletRequestAttributes)
                RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        // 从header中获取Token
        String token = request.getHeader(TOKEN_NAME);

        // 校验Token是否合法
        Boolean isValid = jwtOperator.validateToken(token);
        if (BooleanUtils.isFalse(isValid)) {
            log.warn("登录态校验不通过,无效的Token:{}", token);
            // 抛出自定义异常
            throw new SecurityException("Token不合法!");
        }

        // 校验通过,可以设置用户信息到request里
        Claims claims = jwtOperator.getClaimsFromToken(token);
        log.info("登录态校验通过,用户名:{}", claims.get("userName"));
        request.setAttribute("id", claims.get("id"));
        request.setAttribute("userName", claims.get("userName"));
        request.setAttribute("role", claims.get("role"));

        return joinPoint.proceed();
    }
}

然后编写两个接口用于模拟受保护的资源和获取token。代码如下:

@Slf4j
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
    private final JwtOperator jwtOperator;

    /**
     * 需要校验登录态后才能访问的资源
     */
    @CheckLogin
    @GetMapping("/{id}")
    public User findById(@PathVariable Integer id) {
        log.info("get request. id is {}", id);
        return userService.findById(id);
    }

    /**
     * 模拟生成token
     *
     * @return token
     */
    @GetMapping("gen-token")
    public String genToken() {
        Map<String, Object> userInfo = new HashMap<>();
        userInfo.put("id", 1);
        userInfo.put("userName", "小眀");
        userInfo.put("role", "user");

        return jwtOperator.generateToken(userInfo);
    }
}

最后我们来进行一个简单的测试,看看访问受保护的资源时是否会先执行切面方法来检查登录态。首先启动项目获取token:
微服务的用户认证与授权杂谈(下)

在访问受保护的资源时在header中带上token:
微服务的用户认证与授权杂谈(下)

访问成功,此时控制台输出如下:
微服务的用户认证与授权杂谈(下)

Tips:

这里之所以没有使用过滤器或拦截器来实现登录态的校验,而是采用了AOP,这是因为使用AOP写出来的代码比较干净并且可以利用自定义注解实现可插拔的效果,例如访问某个资源不用进行登录态检查了,那么只需要把@CheckLogin注解给去掉即可。另外就是AOP属于比较重要的基础知识,也是在面试中经常被问到的知识点,通过这个实际的应用例子,可以让我们对AOP的使用技巧有一定的了解。

当然也可以选择过滤器或拦截器来实现,没有说哪种方式就是最好的,毕竟这三种方式都有各自的特性和优缺点,需要根据具体的业务场景来选择。


Feign实现Token传递

在微服务架构中通常会使用Feign来调用其他微服务所提供的接口,若该接口需要对登录态进行检查的话,那么就得传递当前客户端请求所携带的Token。而默认情况下Feign在请求其他服务的接口时,是不会携带任何额外信息的,所以此时我们就得考虑如何在微服务之间传递Token。

让Feign实现Token的传递还是比较简单的,主要有两种方式,第一种是使用Spring MVC的@RequestHeader注解。如下示例:

@FeignClient(name = "order-center")
public interface OrderCenterService {

    @GetMapping("/orders/{id}")
    OrderDTO findById(@PathVariable Integer id,
                      @RequestHeader("X-Token") String token);
}

Controller里的方法也需要使用这个注解来从header中获取Token,然后传递给Feign。如下:

@RestController
@RequiredArgsConstructor
public class TestController {

    private final OrderCenterService orderCenterService;

    @GetMapping("/{id}")
    public OrderDTO findById(@PathVariable("id") Integer id,
                            @RequestHeader("X-Token") String token) {
        return orderCenterService.findById(id, token);
    }
}

从上面这个例子可以看出,使用@RequestHeader注解的优点就是简单直观,而缺点也很明显。当只有一两个接口需要传递Token时,这种方式还是可行的,但如果有很多个远程接口需要传递Token的话,那么每个方法都得加上这个注解,显然会增加很多重复的工作。

所以第二种传递Token的方式更为通用,这种方式是通过实现一个Feign的请求拦截器,然后在拦截器中获取当前客户端请求所携带的Token并添加到Feign的请求header中,以此实现Token的传递。如下示例:

package com.zj.node.contentcenter.feignclient.interceptor;

import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;

/**
 * 请求拦截器,实现在服务间传递Token
 *
 * @author 01
 * @date 2019-09-08
 **/
public class TokenRelayRequestInterceptor implements RequestInterceptor {

    private static final String TOKEN_NAME = "X-Token";

    @Override
    public void apply(RequestTemplate requestTemplate) {
        // 获取当前的request对象
        ServletRequestAttributes attributes = (ServletRequestAttributes)
                RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        // 从header中获取Token
        String token = request.getHeader(TOKEN_NAME);

        // 传递token
        requestTemplate.header(TOKEN_NAME,token);
    }
}

然后需要在配置文件中,配置该请求拦截器的包名路径,不然不会生效。如下:

# 定义feign相关配置
feign:
  client:
    config:
      # default即表示为全局配置
      default:
        requestInterceptor:
          - com.zj.node.contentcenter.feignclient.interceptor.TokenRelayRequestInterceptor

RestTemplate实现Token传递

除了Feign以外,部分情况下有可能会使用RestTemplate来请求其他服务的接口,所以本小节也介绍一下,在使用RestTemplate的情况下如何实现Token的传递。

RestTemplate也有两种方式可以实现Token的传递,第一种方式是请求时使用exchange()方法,因为该方法可以接收header。如下示例:

@RestController
@RequiredArgsConstructor
public class TestController {

    private final RestTemplate restTemplate;

    @GetMapping("/{id}")
    public OrderDTO findById(@PathVariable("id") Integer id,
                            @RequestHeader("X-Token") String token) {
        // 传递token                    
        HttpHeaders headers = new HttpHeaders();
        headers.add("X-Token", token);

        return restTemplate.exchange(
                "http://order-center/orders/{id}",
                HttpMethod.GET,
                new HttpEntity<>(headers),
                OrderDTO.class,
                id).getBody();
    }
}

另一种则是实现ClientHttpRequestInterceptor接口,该接口是RestTemplate的拦截器接口,与Feign的拦截器类似,都是用来实现通用逻辑的。具体代码如下:

public class TokenRelayRequestInterceptor implements ClientHttpRequestInterceptor {

    private static final String TOKEN_NAME = "X-Token";

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        // 获取当前的request对象
        ServletRequestAttributes attributes = (ServletRequestAttributes)
                RequestContextHolder.getRequestAttributes();
        HttpServletRequest servletRequest = attributes.getRequest();
        // 从header中获取Token
        String token = servletRequest.getHeader(TOKEN_NAME);

        // 传递Token
        request.getHeaders().add(TOKEN_NAME,token);
        return execution.execute(request, body);
    }
}

最后需要将实现的拦截器注册到RestTemplate中让其生效,代码如下:

@Configuration
public class BeanConfig {

    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setInterceptors(Collections.singletonList(
                new TokenRelayRequestInterceptor()
        ));

        return restTemplate;
    }
}

AOP实现用户权限验证

在第一小节中我们介绍了如何使用AOP实现登录态检查,除此之外某些受保护的资源可能需要用户拥有特定的权限才能够访问,那么我们就得在该资源被访问之前做权限校验。权限校验功能同样也可以使用过滤器、拦截器或AOP来实现,和之前一样本小节采用AOP作为示例。

这里也不做太复杂的校验逻辑,主要是判断用户是否是某个角色即可。所以首先定义一个注解,该注解有一个value,用于标识受保护的资源需要用户为哪个角色才允许访问。代码如下:

package com.zj.node.usercenter.auth;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * 被该注解标记的方法都需要检查用户权限
 *
 * @author 01
 * @date 2019-09-08
 **/
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckAuthorization {

    /**
     * 允许访问的角色名称
     */
    String value();
}

然后定义一个切面,用于实现具体的权限校验逻辑。代码如下:

package com.zj.node.usercenter.auth;

import com.zj.node.usercenter.util.JwtOperator;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.BooleanUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;

/**
 * 权限验证切面类
 *
 * @author 01
 * @date 2019-09-08
 **/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AuthAspect {

    private static final String TOKEN_NAME = "X-Token";

    private final JwtOperator jwtOperator;

    /**
     * 在执行@CheckAuthorization注解标识的方法之前都会先执行此方法
     */
    @Around("@annotation(com.zj.node.usercenter.auth.CheckAuthorization)")
    public Object checkAuth(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取request对象
        ServletRequestAttributes attributes = (ServletRequestAttributes)
                RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        // 从header中获取Token
        String token = request.getHeader(TOKEN_NAME);

        // 校验Token是否合法
        Boolean isValid = jwtOperator.validateToken(token);
        if (BooleanUtils.isFalse(isValid)) {
            log.warn("登录态校验不通过,无效的Token:{}", token);
            // 抛出自定义异常
            throw new SecurityException("Token不合法!");
        }

        Claims claims = jwtOperator.getClaimsFromToken(token);
        String role = (String) claims.get("role");
        log.info("登录态校验通过,用户名:{}", claims.get("userName"));

        // 验证用户角色名称是否与受保护资源所定义的角色名称匹配
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        CheckAuthorization annotation = signature.getMethod()
                .getAnnotation(CheckAuthorization.class);
        if (!annotation.value().equals(role)) {
            log.warn("权限校验不通过!当前用户角色:{} 允许访问的用户角色:{}",
                    role, annotation.value());
            // 抛出自定义异常
            throw new SecurityException("权限校验不通过,无权访问该资源!");
        }

        log.info("权限验证通过");
        // 设置用户信息到request里
        request.setAttribute("id", claims.get("id"));
        request.setAttribute("userName", claims.get("userName"));
        request.setAttribute("role", claims.get("role"));

        return joinPoint.proceed();
    }
}

使用的时候只需要加上该注解并且设置角色名称即可,如下示例:

/**
 * 需要校验登录态及权限后才能访问的资源
 */
@GetMapping("/{id}")
@CheckAuthorization("admin")
public User findById(@PathVariable Integer id) {
    log.info("get request. id is {}", id);
    return userService.findById(id);
}