Spring Boot使用AOP实现REST接口简易灵活的安全认证

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Boot使用AOP实现REST接口简易灵活的安全认证相关的知识,希望对你有一定的参考价值。

本文将通过AOP的方式实现一个相对更加简易灵活的API安全认证服务。

我们先看实现,然后介绍和分析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接口简易灵活的安全认证的主要内容,如果未能解决你的问题,请参考以下文章

SpringBoot+rest接口+swagger2生成API文档+validator+mybatis+aop+国际化

Spring Boot AOP 扫盲,实现接口访问的统一日志记录

Spring Boot# 使用AOP实现接口鉴权访问白名单限制记录接口访问日志限制接口请求次数

Spring Boot# 使用AOP实现接口鉴权访问白名单限制记录接口访问日志限制接口请求次数

Spring Boot实现RESTful接口架构实战(包括REST的讲解定义REST服务测试)

Spring AOP动态代理实现,解决Spring Boot中无法正常启用JDK动态代理的问题