AquariusYuxin 2019-06-26
本文是《手把手项目实战系列》的第三篇文章,预告一下,整个系列会介绍如下内容:

几乎所有的Web系统都需要登录、权限管理、角色管理等功能,而且这些功能往往具有较大的普适性,与系统具体的业务关联性较小。因此,这些功能完全可以被封装成一个可配置、可插拔的框架,当开发一个新系统的时候直接将其引入、并作简单配置即可,无需再从头开发,极大节约了人力成本、时间成本。
在Java Web领域,有两大主流的安全框架,Spring Security和Apache Shiro。他们都能实现用户鉴权、权限管理、角色管理、防止Web攻击等功能,而且这两套开源框架都已经过大量项目的验证,趋于稳定成熟,可以很好地为我们的项目服务。
本文将带领大家从头开始实现一套安全框架,该框架与Spring Boot深度融合,从而能够帮助大家加深对Spring Boot的理解。这套框架中将涉及到如下内容:
本文将从安全框架的设计与实现两个角度带领大家完成安全框架的开发,废话不多说,现在开始吧~
https://github.com/bz51/Sprin...
在所有事情开始之前,我们首先要搞清楚,我们究竟要实现哪些功能?
当我们明确了开发目标之后,下面就需要基于这些目标,设计我们的系统。我们首先要做的就是要搞清楚“用户”、“角色”、“权限”的定义以及他们之间的关系。这在领域驱动设计中被称为“领域模型”。

权限:
角色:
用户:
当我们捋清楚了“权限”、“用户”、“角色”的定义和他们之间的关系后,下面我们就可以基于这个领域模型设计出具体的数据存储结构。
为了能够方便地给每一个接口标注权限,我们需要自定义三个注解@Login、@Role和@Permission。
@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
它解释说明了这个注解的的存活时间。它的取值如下:
@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);
}authentication():
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);
    }
}全局异常处理
为了是得代码具备良好的可读性,这里使用了SpringBoot提供的全局异常处理机制。我们只需抛出异常即可,这些异常会被我们预先设置的全局异常处理类捕获并处理。全局异常处理本质上借助于AOP完成。
@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();
    }
}