Spring Boot使用AOP實現REST接口簡易靈活的安全認證
我們先看實現,然後介紹和分析AOP基本原理和常用術語。
一、Authorized實現
1、定義註解
package com.power.demo.common; import java.lang.annotation.*; /* * 安全認證 * */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Authorized { String value() default ""; }
這個註解看上去什麽都沒有,僅僅是一個占位符,用於標誌是否需要安全認證。
2、表現層使用註解
@Authorized @RequestMapping(value = "/getinfobyid", method = RequestMethod.POST) @ApiOperation("根據商品Id查詢商品信息") @ApiImplicitParams({ @ApiImplicitParam(paramType = "header", name = "authtoken", required = true, value = "authtoken", dataType = "String"), }) public GetGoodsByGoodsIdResponse getGoodsByGoodsId(@RequestHeader String authtoken, @RequestBody GetGoodsByGoodsIdRequest request) { return _goodsApiService.getGoodsByGoodsId(request); }
看上去就是在一個方法上加了Authorized註解,其實它也可以作用於類上,也可以類和方法混合使用。
3、請求認證切面
下面的代碼是實現靈活的安全認證的關鍵:
package com.power.demo.controller.tool; import com.power.demo.common.AppConst; import com.power.demo.common.Authorized; import com.power.demo.common.BizResult; import com.power.demo.service.contract.AuthTokenService; import com.power.demo.util.PowerLogger; import com.power.demo.util.SerializeUtil; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.lang.annotation.Annotation; /** * 請求認證切面,驗證自定義請求header的authtoken是否合法 **/ @Aspect @Component public class AuthorizedAspect { @Autowired private AuthTokenService authTokenService; @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)") public void requestMapping() { } @Pointcut("execution(* com.power.demo.controller.*Controller.*(..))") public void methodPointCut() { } /** * 某個方法執行前進行請求合法性認證 註入Authorized註解 (先) */ @Before("requestMapping() && methodPointCut()&&@annotation(authorized)") public void doBefore(JoinPoint joinPoint, Authorized authorized) throws Exception { PowerLogger.info("方法認證開始..."); Class type = joinPoint.getSignature().getDeclaringType(); Annotation[] annotations = type.getAnnotationsByType(Authorized.class); if (annotations != null && annotations.length > 0) { PowerLogger.info("直接類認證"); return; } //獲取當前http請求 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); String token = request.getHeader(AppConst.AUTH_TOKEN); BizResult<String> bizResult = authTokenService.powerCheck(token); System.out.println(SerializeUtil.Serialize(bizResult)); if (bizResult.getIsOK() == true) { PowerLogger.info("方法認證通過"); } else { throw new Exception(bizResult.getMessage()); } } /** * 類下面的所有方法執行前進行請求合法性認證 (後) */ @Before("requestMapping() && methodPointCut()") public void doBefore(JoinPoint joinPoint) throws Exception { PowerLogger.info("類認證開始..."); Annotation[] annotations = joinPoint.getSignature().getDeclaringType().getAnnotationsByType(Authorized.class); if (annotations == null || annotations.length == 0) { PowerLogger.info("類不需要認證"); return; } //獲取當前http請求 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); String token = request.getHeader(AppConst.AUTH_TOKEN); BizResult<String> bizResult = authTokenService.powerCheck(token); System.out.println(SerializeUtil.Serialize(bizResult)); if (bizResult.getIsOK() == true) { PowerLogger.info("類認證通過"); } else { throw new Exception(bizResult.getMessage()); } } }
需要註意的是,對類和方法上的Authorized處理,定義了重載的處理方法doBefore。AuthTokenService和上文介紹的處理邏輯一樣,如果安全認證不通過,則拋出異常。
如果我們在類上或者方法上都加了Authorized註解,不會進行重復安全認證,請放心使用。
4、統一異常處理
上文已經提到過,對所有發生異常的API,都返回統一格式的報文至調用方。主要代碼大致如下:
package com.power.demo.controller.exhandling;
import com.power.demo.common.ErrorInfo;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
/**
* 全局統一異常處理增強
**/
@ControllerAdvice
public class GlobalExceptionHandler {
/**
* API統一異常處理
**/
@ExceptionHandler(value = Exception.class)
@ResponseBody
public ErrorInfo<Exception> jsonApiErrorHandler(HttpServletRequest request, Exception e) {
ErrorInfo<Exception> errorInfo = new ErrorInfo<>();
try {
System.out.println("統一異常處理...");
e.printStackTrace();
Throwable innerEx = e.getCause();
while (innerEx != null) {
//innerEx.printStackTrace();
if (innerEx.getCause() == null) {
break;
}
innerEx = innerEx.getCause();
}
if (innerEx == null) {
errorInfo.setMessage(e.getMessage());
errorInfo.setError(e.toString());
} else {
errorInfo.setMessage(innerEx.getMessage());
errorInfo.setError(innerEx.toString());
}
errorInfo.setData(e);
errorInfo.setTimestamp(new Date());
errorInfo.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());//500錯誤
errorInfo.setUrl(request.getRequestURL().toString());
errorInfo.setPath(request.getServletPath());
} catch (Exception ex) {
ex.printStackTrace();
errorInfo.setMessage(ex.getMessage());
errorInfo.setError(ex.toString());
}
return errorInfo;
}
}
認證不通過的API調用結果如下:
異常的整個堆棧可以非常非常方便地幫助我們排查到問題。
我們再結合上文來看安全認證的時間先後,根據理論分析和實踐發現,過濾器Filter先於攔截器Interceptor先於自定義Authorized方法認證先於Authorized類認證。
到這裏,我們發現通過AOP框架AspectJ,一個@Aspect註解外加幾個方法幾十行業務代碼,就可以輕松實現對REST API的攔截處理。
那麽為什麽會有@Pointcut,既然有@Before,是否有@After?
其實上述簡易安全認證功能實現的過程主要利用了Spring的AOP特性。
下面再簡單介紹下AOP常見概念(主要參考Spring實戰),加深理解。AOP概念較多而且比較乏味,經驗豐富的老鳥到此就可以忽略這一段了。
二、AOP
1、概述
AOP(Aspect Oriented Programming),即面向切面編程,可以處理很多事情,常見的功能比如日誌記錄,性能統計,安全控制,事務處理,異常處理等。
AOP可以認為是一種更高級的“復用”技術,它是OOP(Object Oriented Programming,面向對象編程)的補充和完善。AOP的理念,就是將分散在各個業務邏輯代碼中相同的代碼通過橫向切割的方式抽取到一個獨立的模塊中。將相同邏輯的重復代碼橫向抽取出來,使用動態代理技術將這些重復代碼織入到目標對象方法中,實現和原來一樣的功能。這樣一來,我們在寫業務邏輯時就只關心業務代碼。
OOP引入封裝、繼承、多態等概念來建立一種對象層次結構,用於模擬公共行為的一個集合。不過OOP允許開發者定義縱向的關系,但並不適合定義橫向的關系,例如日誌功能。日誌代碼往往橫向地散布在所有對象層次中,而與它對應的對象的核心功能毫無關系對於其他類型的代碼,如安全性、異常處理和透明的持續性也都是如此,這種散布在各處的無關的代碼被稱為橫切(cross cutting),在OOP設計中,它導致了大量代碼的重復,而不利於各個模塊的重用。
AOP技術恰恰相反,它利用一種稱為"橫切"的技術,剖解開封裝的對象內部,並將那些影響了多個類的公共行為封裝到一個可重用模塊,並將其命名為"Aspect",即切面。
所謂"切面",簡單說就是那些與業務無關,卻為業務模塊所共同調用的邏輯或責任封裝起來,便於減少系統的重復代碼,降低模塊之間的耦合度,並有利於未來的可操作性和可維護性。
使用"橫切"技術,AOP把軟件系統分為兩個部分:核心關註點和橫切關註點。
業務處理的主要流程是核心關註點,與之關系不大的部分是橫切關註點。橫切關註點的一個特點是,它們經常發生在核心關註點的多處,而各處基本相似,比如權限認證、日誌、事務。AOP的作用在於分離系統中的各種關註點,將核心關註點和橫切關註點分離開來。
2、AOP術語
深刻理解AOP,要掌握的術語可真不少。
Target:目標類,需要被代理的類,如:UserService
Advice:通知,所要增強或增加的功能,定義了切面的“什麽”和“何時”,模式有Before、After、After-returning,、After-throwing和Around
Join Point:連接點,應用執行過程中,能夠插入切面的所有“點”(時機)
Pointcut:切點,實際運行中,選擇插入切面的連接點,即定義了哪些點得到了增強。切點定義了切面的“何處”。我們通常使用明確的類和方法名稱,或是利用正則表達式定義所匹配的類和方法名稱來指定這些切點。
Aspect:切面,把橫切關註點模塊化為特殊的類,這些類稱為切面,切面是通知和切點的結合。通知和切點共同定義了切面的全部內容:它是什麽,在何時和何處完成其功能
Introduction:引入,允許我們向現有的類添加新方法或屬性
Weaving:織入,把切面應用到目標對象並創建新的代理對象的過程,切面在指定的連接點被織入到目標對象中。在目標對象的生命周期裏有多個點可以進行織入:編譯期、類加載期、運行期
下面參考自網上圖片,可以比較直觀地理解上述這幾個AOP術語和流轉過程。
3、AOP實現
(1)動態代理
使用動態代理可以為一個或多個接口在運行期動態生成實現對象,生成的對象中實現接口的方法時可以添加增強代碼,從而實現AOP:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
/**
* 動態代理類
*/
public class DynamicProxy implements InvocationHandler {
/**
* 需要代理的目標類
*/
private Object target;
/**
* 寫法固定,aop專用:綁定委托對象並返回一個代理類
*
* @param target
* @return
*/
public Object bind(Object target) {
this.target = target;
return Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass().getInterfaces(), this);
}
/**
* 調用 InvocationHandler接口定義方法
*
* @param proxy 指被代理的對象。
* @param method 要調用的方法
* @param args 方法調用時所需要的參數
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object result = null;
// 切面之前執行
System.out.println("[動態代理]切面之前執行");
// 執行業務
result = method.invoke(target, args);
// 切面之後執行
System.out.println("[動態代理]切面之後執行");
return result;
}
}
缺點是只能針對接口進行代理,同時由於動態代理是通過反射實現的,有時可能要考慮反射調用的開銷,否則很容易引發性能問題。
(2)字節碼生成
動態字節碼生成技術是指在運行時動態生成指定類的一個子類對象(註意是針對類),並覆蓋其中特定方法,覆蓋方法時可以添加增強代碼,從而實現AOP。
最常用的工具是CGLib:
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* 使用cglib動態代理
* <p>
* JDK中的動態代理使用時,必須有業務接口,而cglib是針對類的
*/
public class CglibProxy implements MethodInterceptor {
private Object target;
/**
* 創建代理對象
*
* @param target
* @return
*/
public Object getInstance(Object target) {
this.target = target;
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(this.target.getClass());
// 回調方法
enhancer.setCallback(this);
// 創建代理對象
return enhancer.create();
}
@Override
public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
Object result = null;
System.out.println("[cglib]切面之前執行");
result = methodProxy.invokeSuper(proxy, args);
System.out.println("[cglib]切面之後執行");
return result;
}
}
(3)定制的類加載器
當需要對類的所有對象都添加增強,動態代理和字節碼生成本質上都需要動態構造代理對象,即最終被增強的對象是由AOP框架生成,不是開發者new出來的。
解決的辦法就是實現自定義的類加載器,在一個類被加載時對其進行增強。
JBoss就是采用這種方式實現AOP功能。
這種方式目前只是道聽途說,本人沒有在實際項目中實踐過。
(4)代碼生成
利用工具在已有代碼基礎上生成新的代碼,其中可以添加任何橫切代碼來實現AOP。
(5)語言擴展
可以對構造方法和屬性的賦值操作進行增強,AspectJ是采用這種方式實現AOP的一個常見的Java語言擴展。
比較:根據日誌,上述流程的執行順序依次為:過濾器、攔截器、AOP方法認證、AOP類認證
附:記錄API日誌
最後通過記錄API日誌,記錄日誌時加入API耗時統計(其實我們在開發.NET應用的過程中通過AOP這種記錄日誌的方式也已經是標配),加深上述AOP的幾個核心概念的理解:
package com.power.demo.controller.tool;
import com.power.demo.apientity.BaseApiRequest;
import com.power.demo.apientity.BaseApiResponse;
import com.power.demo.util.DateTimeUtil;
import com.power.demo.util.SerializeUtil;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
/**
* 服務日誌切面,主要記錄接口日誌及耗時
**/
@Aspect
@Component
public class SvcLogAspect {
@Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
public void requestMapping() {
}
@Pointcut("execution(* com.power.demo.controller.*Controller.*(..))")
public void methodPointCut() {
}
@Around("requestMapping() && methodPointCut()")
public Object around(ProceedingJoinPoint pjd) throws Throwable {
System.out.println("Spring AOP方式記錄服務日誌");
Object response = null;//定義返回信息
BaseApiRequest baseApiRequest = null;//請求基類
int index = 0;
Signature curSignature = pjd.getSignature();
String className = curSignature.getClass().getName();//類名
String methodName = curSignature.getName(); //方法名
Logger logger = LoggerFactory.getLogger(className);//日誌
StopWatch watch = DateTimeUtil.StartNew();//用於統計調用耗時
// 獲取方法參數
Object[] reqParamArr = pjd.getArgs();
StringBuffer sb = new StringBuffer();
//獲取請求參數集合並進行遍歷拼接
for (Object reqParam : reqParamArr) {
if (reqParam == null) {
index++;
continue;
}
try {
sb.append(SerializeUtil.Serialize(reqParam));
//獲取繼承自BaseApiRequest的請求實體
if (baseApiRequest == null && reqParam instanceof BaseApiRequest) {
index++;
baseApiRequest = (BaseApiRequest) reqParam;
}
} catch (Exception e) {
sb.append(reqParam.toString());
}
sb.append(",");
}
String strParam = sb.toString();
if (strParam.length() > 0) {
strParam = strParam.substring(0, strParam.length() - 1);
}
//記錄請求
logger.info(String.format("【%s】類的【%s】方法,請求參數:%s", className, methodName, strParam));
response = pjd.proceed(); // 執行服務方法
watch.stop();
//記錄應答
logger.info(String.format("【%s】類的【%s】方法,應答參數:%s", className, methodName, SerializeUtil.Serialize(response)));
// 獲取執行完的時間
logger.info(String.format("接口【%s】總耗時(毫秒):%s", methodName, watch.getTotalTimeMillis()));
//標準請求-應答模型
if (baseApiRequest == null) {
return response;
}
if ((response != null && response instanceof BaseApiResponse) == false) {
return response;
}
System.out.println("Spring AOP方式記錄標準請求-應答模型服務日誌");
Object request = reqParamArr[index];
BaseApiResponse bizResp = (BaseApiResponse) response;
//記錄日誌
String msg = String.format("請求:%s======應答:%s======總耗時(毫秒):%s", SerializeUtil.Serialize(request),
SerializeUtil.Serialize(response), watch.getTotalTimeMillis());
if (bizResp.getIsOK() == true) {
logger.info(msg);
} else {
logger.error(msg);//記錄錯誤日誌
}
return response;
}
}
標準的請求-應答模型,我們都會定義請求基類和應答基類,本文示例給到的是BaseApiRequest和BaseApiResponse,搜集日誌時,可以對錯誤日誌加以區分特殊處理。
註意上述代碼中的@Around環繞通知,參數類型是ProceedingJoinPoint,而前面第一個示例的@Before前置通知,參數類型是JoinPoint。
下面是AspectJ通知和增強的5種模式:
@Before前置通知,在目標方法執行前實施增強,請求參數JoinPoint,用來連接當前連接點的連接細節,一般包括方法名和參數值。在方法執行前進行執行方法體,不能改變方法參數,也不能改變方法執行結果。
@After 後置通知,請求參數JoinPoint,在目標方法執行之後,無論是否發生異常,都進行執行的通知。在後置通知中,不能訪問目標方法的執行結果(因為有可能發生異常),不能改變方法執行結果。
@AfterReturning 返回通知,在目標方法執行後實施增強,請求參數JoinPoint,其能訪問方法執行結果(因為正常執行)和方法的連接細節,但是不能改變方法執行結果。(註意和後置通知的區別)
@AfterThrowing 異常通知,在方法拋出異常後實施增強,請求參數JoinPoint,throwing屬性代表方法體執行時候拋出的異常,其值一定與方法中Exception的值需要一致。
@Around 環繞通知,請求參數ProceedingJoinPoint,環繞通知類似於動態代理的全過程,ProceedingJoinPoint類型的參數可以決定是否執行目標方法,而且環繞通知必須有返回值,返回值即為目標方法的返回值。
Spring Boot使用AOP實現REST接口簡易靈活的安全認證