本文是《手把手項目實戰系列》的第三篇文章,預告一下,整個系列會介紹以下內容:java
幾乎全部的Web系統都須要登陸、權限管理、角色管理等功能,並且這些功能每每具備較大的普適性,與系統具體的業務關聯性較小。所以,這些功能徹底能夠被封裝成一個可配置、可插拔的框架,當開發一個新系統的時候直接將其引入、並做簡單配置便可,無需再從頭開發,極大節約了人力成本、時間成本。git
在Java Web領域,有兩大主流的安全框架,Spring Security和Apache Shiro。他們都能實現用戶鑑權、權限管理、角色管理、防止Web攻擊等功能,並且這兩套開源框架都已通過大量項目的驗證,趨於穩定成熟,能夠很好地爲咱們的項目服務。github
本文將帶領你們從頭開始實現一套安全框架,該框架與Spring Boot深度融合,從而可以幫助你們加深對Spring Boot的理解。這套框架中將涉及到以下內容:redis
本文將從安全框架的設計與實現兩個角度帶領你們完成安全框架的開發,廢話很少說,如今開始吧~spring
https://github.com/bz51/SpringBoot-Dubbo-Docker-Jenkins數據庫
在全部事情開始以前,咱們首先要搞清楚,咱們究竟要實現哪些功能?緩存
當咱們明確了開發目標以後,下面就須要基於這些目標,設計咱們的系統。咱們首先要作的就是要搞清楚「用戶」、「角色」、「權限」的定義以及他們之間的關係。這在領域驅動設計中被稱爲「領域模型」。安全
當咱們捋清楚了「權限」、「用戶」、「角色」的定義和他們之間的關係後,下面咱們就能夠基於這個領域模型設計出具體的數據存儲結構。bash
爲了可以方便地給每個接口標註權限,咱們須要自定義三個註解@Login
、@Role
和@Permission
。session
@Login
:用於標識當前接口是否須要登陸。當接口使用了這個註解後,用戶只有在登陸後才能訪問。
@Role("角色名")
:用於標識容許調用當前接口的角色。當接口使用了這個註解後,只有指定角色的用戶才能調用本接口。
@Permission("權限名")
:用於標識容許調用當前接口的權限。當接口使用了這個註解後,只有具有指定權限的用戶才能調用本接口。
要使得這個安全框架運行起來,首先就須要在系統初始化完成前,初始化全部接口的權限、角色等信息,這個過程即爲「接口權限信息初始化流程」;而後在系統運行期間,若是有用戶請求接口,就能夠根據這些權限信息判斷該用戶是否有權限訪問接口。
這一小節主要介紹接口權限信息初始化流程,不涉及任何實現細節,實現的細節將在本文的實現部分介紹。
@GetMapping
、@PostMapping
、@PutMapping
和@DeleteMapping
,經過這些註解獲取接口的URL、請求方式等信息;@Login
、@Role
和@Permission
,經過這些註解,獲取該接口是否須要登陸、容許訪問的角色以及容許訪問的權限信息;本套安全框架一共定義了四個註解:@AuthScan
、@Login
、@Role
、@Permission
。
該註解用來告訴安全框架,本項目中全部Controller類所在的包,從而可以幫助安全框架快速找到Controller類,避免了全部類的掃描。
它有且僅有一個參數,用來指定Controller所在的包:@AuthScan("com.gaoxi.controller")
。它的代碼實現以下:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuthScan {
public String value();
}
複製代碼
註解顧名思義,它是用來在代碼中進行標註,它自己不承載任何邏輯,經過註解
@Retention 它解釋說明了這個註解的的存活時間。它的取值以下:
@Documented 顧名思義,這個元註解確定是和文檔有關。它的做用是可以將註解中的元素包含到 Javadoc 中去。
@Target 當一個註解被 @Target 註解時,這個註解就被限定了運用的場景。
這個註解用於標識指定接口是否須要登陸後才能訪問,它有一個默認的boolean類型的值,用於表示是否須要登陸,其代碼以下:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Login {
// 是否須要登陸(默認爲true)
public boolean value() default true;
}
複製代碼
該註解用於指定容許訪問當前接口的角色,其代碼以下:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Role {
public String value();
}
複製代碼
該註解用於指定容許訪問當前接口的權限,其代碼以下:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Permission {
public String value();
}
複製代碼
上文中提到,註解自己不含任何業務邏輯,它只是在代碼中起一個標識的做用,那麼怎麼才能讓註解「活」起來?這就須要經過反射機制來獲取註解。
當完成這些註解的定義後,接下來就須要使用他們,以下面代碼所示:
public interface ProductController {
/** * 建立產品 * @param prodInsertReq 產品詳情 * @return 是否建立成功 */
@PostMapping("product")
@Login
@Permission("product:create")
public Result createProduct(ProdInsertReq prodInsertReq);
}
複製代碼
ProductController
是一個Controller類,它提供了處理產品的各類接口。簡單起見,這裏只列出了一個建立產品的接口。 @PostMapping
是SpringMVC提供的註解,用於標識該接口的訪問路徑和訪問方式。 @Login
聲明瞭該接口須要登陸後才能訪問。 @Permission
聲明瞭用戶只有擁有product:create
權限才能訪問該接口。
當系統初始化的時候,須要加載接口上的這些權限信息,存儲在Redis中。在系統運行期間,當有用戶請求接口的時候,系統會根據接口的權限信息判斷用戶是否有訪問接口的權限。權限信息初始化過程的代碼以下:
/** * @author 大閒人柴毛毛 * @date 2017/11/1 上午10:04 * * @description 初始化權限信息 */
@AuthScan("com.gaoxi.controller")
@Component
public class InitAuth implements CommandLineRunner {
@Override
public void run(String... strings) throws Exception {
// 加載接口訪問權限
loadAccessAuth();
}
……
}
複製代碼
InitAuth
類,該類實現了CommandLineRunner
接口,該接口中含有run()
方法,當Spring的上下文初始化完成後,就會調用run()
,從而完成權限信息的初始化過程。@AuthScan("com.gaoxi.controller")
註解,用於標識當前項目Controller類所在的包名,從而避免掃描全部類,必定程度上加速系統初始化的速度。@Component
註解會在Spring容器初始化完成後,建立本類的對象,並加入IoC容器中。下面來看一下loadAccessAuth()
方法的具體實現:
/** * 加載接口訪問權限 */
private void loadAccessAuth() throws IOException {
// 獲取待掃描的包名
AuthScan authScan = AnnotationUtil.getAnnotationValueByClass(this.getClass(), AuthScan.class);
String pkgName = authScan.value();
// 獲取包下全部類
List<Class<?>> classes = ClassUtil.getClasses(pkgName);
if (CollectionUtils.isEmpty(classes)) {
return;
}
// 遍歷類
for (Class clazz : classes) {
Method[] methods = clazz.getMethods();
if (methods==null || methods.length==0) {
continue;
}
// 遍歷函數
for (Method method : methods) {
AccessAuthEntity accessAuthEntity = buildAccessAuthEntity(method);
if (accessAuthEntity!=null) {
// 生成key
String key = generateKey(accessAuthEntity);
// 存至本地Map
accessAuthMap.put(key, accessAuthEntity);
logger.debug("",accessAuthEntity);
}
}
}
// 存至Redis
redisService.setMap(RedisPrefixUtil.Access_Auth_Prefix, accessAuthMap, null);
logger.info("接口訪問權限已加載完畢!"+accessAuthMap);
}
複製代碼
@AuthScan
註解,並獲取註解中聲明瞭Controller類所在的包pkgName
;pkgName
是一個字符串,所以須要使用Java反射機制將字符串解析成Class對象。其解析過程經過工具包ClassUtil.getClasses(pkgName)
完成,具體解析過程這裏就不作詳細介紹了,感興趣的同窗能夠參閱本項目源碼。ClassUtil.getClasses(pkgName)
解析以後,該包下的全部Controller類將會被解析成List<Class<?>>
對象,而後遍歷全部的Class對象;buildAccessAuthEntity(method)
方法將一個個Method對象解析成AccessAuthEntity
對象(具體解析過程在稍後介紹);AccessAuthEntity
對象存儲在Redis中,供用戶訪問接口時使用。這就是整個權限信息初始化的過程,下面詳細介紹buildAccessAuthEntity(method)
方法的解析過程,它到底是如何將一個Mehtod對象解析成AccessAuthEntity
對象?而且AccessAuthEntity
對象的結構到底是怎樣的?
首先來看一下AccessAuthEntity
的數據結構:
/** * @author 大閒人柴毛毛 * @date 2017/11/1 上午11:05 * @description 接口訪問權限的實體類 */
public class AccessAuthEntity implements Serializable {
/** 請求 URL */
private String url;
/** 接口方法名 */
private String methodName;
/** HTTP 請求方式 */
private HttpMethodEnum httpMethodEnum;
/** 當前接口是否須要登陸 */
private boolean isLogin;
/** 當前接口的訪問權限 */
private String permission;
// setter/getter省略
}
複製代碼
AccessAuthEntity
用於存儲一個接口的訪問路徑、訪問方式和權限信息。在系統初始化的時候,Controller類中的每一個Mehtod對象都會被buildAccessAuthEntity()
方法解析成AccessAuthEntity
對象。buildAccessAuthEntity()
方法的代碼以下所示:
/** * 構造AccessAuthEntity對象 * @param method * @return */
private AccessAuthEntity buildAccessAuthEntity(Method method) {
GetMapping getMapping = AnnotationUtil.getAnnotationValueByMethod(method, GetMapping.class);
PostMapping postMapping = AnnotationUtil.getAnnotationValueByMethod(method, PostMapping.class);
PutMapping putMapping= AnnotationUtil.getAnnotationValueByMethod(method, PutMapping.class);
DeleteMapping deleteMapping = AnnotationUtil.getAnnotationValueByMethod(method, DeleteMapping.class);
AccessAuthEntity accessAuthEntity = null;
if (getMapping!=null
&& getMapping.value()!=null
&& getMapping.value().length==1
&& StringUtils.isNotEmpty(getMapping.value()[0])) {
accessAuthEntity = new AccessAuthEntity();
accessAuthEntity.setHttpMethodEnum(HttpMethodEnum.GET);
accessAuthEntity.setUrl(trimUrl(getMapping.value()[0]));
}
else if (postMapping!=null
&& postMapping.value()!=null
&& postMapping.value().length==1
&& StringUtils.isNotEmpty(postMapping.value()[0])) {
accessAuthEntity = new AccessAuthEntity();
accessAuthEntity.setHttpMethodEnum(HttpMethodEnum.POST);
accessAuthEntity.setUrl(trimUrl(postMapping.value()[0]));
}
else if (putMapping!=null
&& putMapping.value()!=null
&& putMapping.value().length==1
&& StringUtils.isNotEmpty(putMapping.value()[0])) {
accessAuthEntity = new AccessAuthEntity();
accessAuthEntity.setHttpMethodEnum(HttpMethodEnum.PUT);
accessAuthEntity.setUrl(trimUrl(putMapping.value()[0]));
}
else if (deleteMapping!=null
&& deleteMapping.value()!=null
&& deleteMapping.value().length==1
&& StringUtils.isNotEmpty(deleteMapping.value()[0])) {
accessAuthEntity = new AccessAuthEntity();
accessAuthEntity.setHttpMethodEnum(HttpMethodEnum.DELETE);
accessAuthEntity.setUrl(trimUrl(deleteMapping.value()[0]));
}
// 解析@Login 和 @Permission
if (accessAuthEntity!=null) {
accessAuthEntity = getLoginAndPermission(method, accessAuthEntity);
accessAuthEntity.setMethodName(method.getName());
}
return accessAuthEntity;
}
複製代碼
該方法首先會獲取當前Method上的XXXMapping
四個註解,經過解析這些註解可以獲取到當前接口的訪問路徑和請求方式,並將這二者存儲在AccessAuthEntity
對象中。
而後經過getLoginAndPermission
方法,解析當前Method對象中的@Login 和@Permission信息,其代碼以下所示:
/** * 獲取指定方法上的@Login的值和@Permission的值 * @param method 目標方法 * @param accessAuthEntity * @return */
private AccessAuthEntity getLoginAndPermission(Method method, AccessAuthEntity accessAuthEntity) {
// 獲取@Permission的值
Permission permission = AnnotationUtil.getAnnotationValueByMethod(method, Permission.class);
if (permission!=null && StringUtils.isNotEmpty(permission.value())) {
accessAuthEntity.setPermission(permission.value());
accessAuthEntity.setLogin(true);
return accessAuthEntity;
}
// 獲取@Login的值
Login login = AnnotationUtil.getAnnotationValueByMethod(method, Login.class);
if (login!=null) {
accessAuthEntity.setLogin(true);
}
accessAuthEntity.setLogin(false);
return accessAuthEntity;
}
複製代碼
該註解的解析過程由註解工具包AnnotationUtil.getAnnotationValueByMethod
完成,具體的解析過程這裏就再也不贅述,感興趣的同窗請參閱項目源碼。
到此爲止,接口的訪問路徑、請求方式、是否須要登陸、權限信息都已經解析成一個個AccessAuthEntity
對象,並以「請求方式+訪問路徑」做爲key,存儲在Redis中。接口權限信息的初始化過程也就完成了!
當用戶請求全部接口前,系統都應該攔截這些請求,只有在權限校驗經過的狀況下才運行調用接口,不然直接拒絕請求。
基於上述需求,咱們須要給Controller中全部方法執行前增長切面,並將用於權限校驗的代碼織入到該切面中,從而在方法執行前完成權限校驗。下面就詳細介紹在SpringBoot中AOP的使用。
<!-- AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
複製代碼
@Aspect
註解,用於標識當前類是一個AOP切面類@Component
註解,讓Spring初始化完成後建立本類的對象,並加入IoC容器中@Pointcut
註解定義切點;切點描述了哪些類中的哪些方法須要織入權限校驗代碼。咱們這裏將全部Controller類中的全部方法做爲切點。@Before
註解聲明切面織入的時機;因爲咱們須要在方法執行前攔截全部的請求,所以使用@Before
註解。authentication()
方法中完成。/** * @author 大閒人柴毛毛 * @date 2017/11/2 下午7:06 * * @description 訪問權限處理類(全部請求都要通過此類) */
@Aspect
@Component
public class AccessAuthHandle {
/** 定義切點 */
@Pointcut("execution(public * com.gaoxi.controller..*.*(..))")
public void accessAuth(){}
/** * 攔截全部請求 */
@Before("accessAuth()")
public void doBefore() {
// 訪問鑑權
authentication();
}
}
複製代碼
/** * 檢查當前用戶是否容許訪問該接口 */
private void authentication() {
// 獲取 HttpServletRequest
HttpServletRequest request = getHttpServletRequest();
// 獲取 method 和 url
String method = request.getMethod();
String url = request.getServletPath();
// 獲取 SessionID
String sessionID = getSessionID(request);
// 獲取SessionID對應的用戶信息
UserEntity userEntity = getUserEntity(sessionID);
// 獲取接口權限信息
AccessAuthEntity accessAuthEntity = getAccessAuthEntity(method, url);
// 檢查權限
authentication(userEntity, accessAuthEntity);
}
複製代碼
throw new CommonBizException(ExpCodeEnum.NO_PERMISSION)
異常來拒絕請求,這由SpringBoot統一異常處理機制來完成,稍後會詳細介紹);若已經登陸,則開始檢查權限信息;checkPermission()
方法完成,它會將用戶所具有的權限和接口要求的權限進行比對;若是用戶所具有的權限包含接口要求的權限,那麼權限校驗經過;反之,則經過拋異常的方式拒絕請求。/** * 檢查權限 * @param userEntity 當前用戶的信息 * @param accessAuthEntity 當前接口的訪問權限 */
private void authentication(UserEntity userEntity, AccessAuthEntity accessAuthEntity) {
// 無需登陸
if (!accessAuthEntity.isLogin()) {
return;
}
// 檢查是否登陸
checkLogin(userEntity, accessAuthEntity);
// 檢查是否擁有權限
checkPermission(userEntity, accessAuthEntity);
}
/** * 檢查當前用戶是否擁有訪問該接口的權限 * @param userEntity 用戶信息 * @param accessAuthEntity 接口權限信息 */
private void checkPermission(UserEntity userEntity, AccessAuthEntity accessAuthEntity) {
// 獲取接口權限
String accessPermission = accessAuthEntity.getPermission();
// 獲取用戶權限
List<PermissionEntity> userPermissionList = userEntity.getRoleEntity().getPermissionList();
// 判斷用戶是否包含接口權限
if (CollectionUtils.isNotEmpty(userPermissionList)) {
for (PermissionEntity permissionEntity : userPermissionList) {
if (permissionEntity.getPermission().equals(accessPermission)) {
return;
}
}
}
// 沒有權限
throw new CommonBizException(ExpCodeEnum.NO_PERMISSION);
}
/** * 檢查當前接口是否須要登陸 * @param userEntity 用戶信息 * @param accessAuthEntity 接口訪問權限 */
private void checkLogin(UserEntity userEntity, AccessAuthEntity accessAuthEntity) {
// 還沒有登陸
if (accessAuthEntity.isLogin() && userEntity==null) {
throw new CommonBizException(ExpCodeEnum.UNLOGIN);
}
}
複製代碼
@ControllerAdvice
註解聲明便可@ResponseBody
註解,它可以幫助咱們當處理完異常後,直接向用戶返回JSON格式的錯誤信息,而無需咱們手動處理。@ExceptionHandler
註解告訴Spring,該方法用於處理什麼類型的異常。/** * @Author 大閒人柴毛毛 * @Date 2017/10/27 下午11:02 * REST接口的通用異常處理 */
@ControllerAdvice
@ResponseBody
public class ExceptionHandle {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
/** * 業務異常處理 * @param exception * @param <T> * @return */
@ExceptionHandler(CommonBizException.class)
public <T> Result<T> exceptionHandler(CommonBizException exception) {
return Result.newFailureResult(exception);
}
/** * 系統異常處理 * @param exception * @return */
@ExceptionHandler(Exception.class)
public <T> Result<T> sysExpHandler(Exception exception) {
logger.error("系統異常 ",exception);
return Result.newFailureResult();
}
}
複製代碼