微服務的用戶認證與受權雜談(下)

[TOC]java


AOP實現登陸狀態檢查

微服務的用戶認證與受權雜談(上)一文中簡單介紹了微服務下常見的幾種認證受權方案,而且使用JWT編寫了一個極簡demo來模擬Token的頒發及校驗。而本文的目的主要是延續上文來補充幾個要點,例如Token如何在多個微服務間進行傳遞,以及如何利用AOP實現登陸態和權限的統一校驗。node

爲了讓登陸態的檢查邏輯可以通用,咱們通常會選擇使用過濾器、攔截器以及AOP等手段來實現這個功能。而本小節主要是介紹使用AOP實現登陸狀態檢查,由於利用AOP一樣能夠攔截受保護的資源訪問請求,在對資源訪問前先作一些必要的檢查。web

首先須要在項目中添加AOP的依賴:面試

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

定義一個註解,用於標識哪些方法在被訪問以前須要進行登陸態的檢查。具體代碼以下:spring

package com.zj.node.usercenter.auth;

/**
 * 被該註解標記的方法都須要檢查登陸狀態
 *
 * @author 01
 * @date 2019-09-08
 **/
public @interface CheckLogin {
}

編寫一個切面,實現登陸態檢查的具體邏輯,代碼以下:apache

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。代碼以下:json

@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:
微服務的用戶認證與受權雜談(下)app

訪問成功,此時控制檯輸出以下:
微服務的用戶認證與受權雜談(下)ide

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);
}
相關文章
相關標籤/搜索