17-第16课:Spring Cloud 实例详解——基础框架搭建(三)

  1. Redis 的集成
  2. API 鉴权
    1. HTTP VS HTTPS
    2. 动态密钥的获取
    3. 接口请求的合法性校验
    4. 输入参数的合法性校验
    5. 输入参数签名认证
    6. 输入输出参数加密

本文我们将集成 Redis,实现 API 鉴权机制。

Redis 的集成

Spring Boot 集成 Redis 相当简单,只需要在 pom 里加入如下依赖即可:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

由于每个模块都可能用到 Redis,因此我们可以考虑将 Redis 的依赖放到 common 工程下

然后创建一个类实现基本的 Redis 操作:

@Component
public class Redis {
    @Autowired
    private StringRedisTemplate template;
    // expire为过期时间,秒为单位
    public void set(String key, String value, long expire) {
        template.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
    }
    public void set(String key, String value) { template.opsForValue().set(key, value); }
    public Object get(String key) { return template.opsForValue().get(key); }
    public void delete(String key) { template.delete(key); }
}

如果具体的模块需要操作 Redis 还需要在配置文件配置 Redis 的连接信息,这里我们在 Git 仓库创建一个 yaml 文件 redis.yaml,并加入以下内容:

spring:
  redis:
    host: localhost
    port: 6379
    password:

最后在需要操作 Redis 的工程的 bootstrap.yml 文件中加上 Redis 配置文件名即可,如下:

spring:
  cloud:
    config:
      # 这里加入 redis
      name: user,feign,database,redis,key
      label: master
      discovery:
        enabled: true
        serviceId: config
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8888/eureka/

这样在工程想操作 Redis 的地方注入 Redis 类:

@Autowired
private Redis redis;

但这样启动工程会报错,原因是 CommonScan 默认从工程根目录开始扫描,我们工程的根包名是:com.lynn.xxx(其中 xxx 为工程名),而 Redis 类在 com.lynn.common 下,因此我们需要手动指定开始扫描的包名,我们发现二者都有 com.lynn,所以指定为 comm.lynn 即可。

在每个工程的 Application 类加入如下注解:

@SpringCloudApplication
@ComponentScan(basePackages = "com.lynn")
@EnableHystrixDashboard
@EnableFeignClients
public class Application {
  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

API 鉴权

互联网发展至今,已由传统的前后端统一架构演变为如今的前后端分离架构,最初的前端网页大多由 JSP、ASP、PHP 等动态网页技术生成,前后端十分耦合,也不利于扩展。现在的前端分支很多,如 Web 前端、Android 端、iOS 端,甚至还有物联网等。前后端分离的好处就是后端只需要实现一套界面,所有前端即可通用。

前后端通过 HTTP 进行传输,也带来了一些安全问题,如抓包、模拟请求、洪水攻击、参数劫持、网络爬虫等。如何对非法请求进行有效拦截,保护合法请求的权益是这篇文章需要讨论的。

我依据多年互联网后端开发经验,总结出了以下提升网络安全的方式:

  • 采用 HTTPS 协议;
  • 密钥存储到服务端而非客户端,客户端应从服务端动态获取密钥;
  • 请求隐私接口,利用 Token 机制校验其合法性;
  • 对请求参数进行合法性校验;
  • 对请求参数进行签名认证,防止参数被篡改;
  • 对输入输出参数进行加密,客户端加密输入参数,服务端加密输出参数。

接下来,将对以上方式展开做详细说明。

HTTP VS HTTPS

普通的 HTTP 协议是以明文形式进行传输,不提供任何方式的数据加密,很容易解读传输报文。而 HTTPS 协议在 HTTP 基础上加入了 SSL 层,而 SSL 层通过证书来验证服务器的身份,并为浏览器和服务器之间的通信加密,保护了传输过程中的数据安全。

动态密钥的获取

对于可逆加密算法,是需要通过密钥进行加解密,如果直接放到客户端,那么很容易反编译后拿到密钥,这是相当不安全的做法,因此考虑将密钥放到服务端,由服务端提供接口,让客户端动态获取密钥,具体做法如下:

  • 客户端先通过 RSA 算法生成一套客户端的公私钥对(clientPublicKey 和 clientPrivateKey);
  • 调用 getRSA 接口,服务端会返回 serverPublicKey;
  • 客户端拿到 serverPublicKey 后,用 serverPublicKey 作为公钥,clientPublicKey 作为明文对 clientPublicKey 进行 RSA 加密,调用 getKey 接口,将加密后的 clientPublicKey 传给服务端,服务端接收到请求后会传给客户端 RSA 加密后的密钥;
  • 客户端拿到后以 clientPrivateKey 为私钥对其解密,得到最终的密钥,此流程结束。

注: 上述提到数据均不能保存到文件里,必须保存到内存中,因为只有保存到内存中,黑客才拿不到这些核心数据,所以每次使用获取的密钥前先判断内存中的密钥是否存在,不存在,则需要获取。

为了便于理解,我画了一个简单的流程图:

  1. 客户端启动,发送请求到服务端,服务端用RSA算法生成一对公钥和私钥,我们简称为pubkey1,prikey1,将公钥pubkey1返回给客户端。
  2. 客户端拿到服务端返回的公钥pubkey1后,自己用RSA算法生成一对公钥和私钥,我们简称为pubkey2,prikey2,并将公钥pubkey2通过公钥pubkey1加密,加密之后传输给服务端。
  3. 此时服务端收到客户端传输的密文,用私钥prikey1进行解密,因为数据是用公钥pubkey1加密的,通过解密就可以得到客户端生成的公钥pubkey2;
    然后自己在生成对称加密,也就是我们的AES,其实也就是相对于我们配置中的那个16的长度的加密key,生成了这个key之后我们就用公钥pubkey2进行加密,返回给客户端,因为只有客户端有pubkey2对应的私钥prikey2。
  4. 只有客户端才能解密,客户端得到数据之后,用prikey2进行解密操作,得到AES的加密key,最后就用加密key进行数据传输的加密,至此整个流程结束。

接口请求的合法性校验

对于一些隐私接口(即必须要登录才能调用的接口),我们需要校验其合法性,即只有登录用户才能成功调用,具体思路如下:

  • 调用登录或注册接口成功后,服务端会返回 Token(设置较短有效时间)和 refreshToken(设定较长有效时间);
  • 隐私接口每次请求接口在请求头带上 Token,如 header(“token”, token),若服务端 返回 403 错误,则调用 refreshToken 接口获取新的 Token 重新调用接口,若 refreshToken 接口继续返回 403,则跳转到登录界面。

这种算法较为简单,这里就不写出具体实现了。

输入参数的合法性校验

一般情况下,客户端会进行参数的合法性校验,这个只是为了减轻服务端的压力,针对于普通用户做的校验,如果黑客通过直接调用接口地址,就可绕过客户端的校验,这时要求我们服务端也应该做同样的校验。

SpringMVC 提供了专门用于校验的注解,我们通过 AOP 即可实现统一的参数校验,下面请看代码:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
@Slf4j
@Aspect
@Component
public class WebExceptionAspect {
  //凡是注解了RequestMapping的方法都被拦截
  @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
  private void webPointcut() {
  }
  /**
   * 拦截 web 层异常,记录异常日志,并返回友好信息到前端 目前只拦截 Exception,是否要拦截 Error 需再做考虑
   * @param e 异常对象
   */
  @AfterThrowing(pointcut = "webPointcut()", throwing = "e")
  public void handleThrowing(Exception e) {
    e.printStackTrace();
    logger.error("发现异常!" + e.getMessage());
    logger.error(JSON.toJSONString(e.getStackTrace()));    
    try {           
      if(StringUtils.isEmpty(e.getMessage())){
        writeContent(JSON.toJSONString(SingleResult.buildFailure()));
      } else {
        writeContent(JSON.toJSONString(SingleResult.buildFailure(Code.ERROR,e.getMessage())));
      }
    } catch (Exception ex) { ex.printStackTrace(); }
  }
  /**
   * 将内容输出到浏览器
   * @param content 输出内容
   */
  private void writeContent(String content)throws Exception {
    HttpServletResponse response = ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getResponse();
    response.reset();
    response.setCharacterEncoding("UTF-8");
    response.setHeader("Content-Type", "text/plain;charset=UTF-8");
    response.setHeader("icop-content-type", "exception");
    response.getWriter().print(content);
    response.getWriter().close();
  }
}

在 Controller 提供共有方法:接口输入参数合法性校验

protected void validate(BindingResult result) {
  if (result.hasFieldErrors()) {
    List<FieldError> errorList = result.getFieldErrors();
    errorList.stream().forEach(item -> Assert.isTrue(false, item.getDefaultMessage()));
  }
}

每个接口的输入参数都需要加上 @Valid 注解,并且在参数后面加上 BindResult 类:

@RequestMapping(value = "/hello",method = RequestMethod.POST)
public SingleResult<String> hello(@Valid @RequestBody TestRequest request, BindingResult result) {
  validate(result);
  return "name="+name;
}
@Data
public class TestRequest {
  @NotNull(message = "name不能为空")    
  private String name;
}

输入参数签名认证

我们请求的接口是通过 HTTP/HTTPS 传输的,一旦参数被拦截,很有可能被黑客篡改,并传回给服务端,为了防止这种情况发生,我们需要对参数进行签名认证,保证传回的参数是合法性,具体思路如下。

请求接口前,将 Token、Timstamp 和接口需要的参数按照 ASCII 升序排列,拼接成 url=key1=value1&key2=value2,如 name=xxx&timestamp=xxx&token=xxx,进行 MD5(url+salt),得到 Signature,将 Token、Signature、Timestamp 放到请求头传给服务端,如 header(“token”, token)、header(“timestamp”, timestamp)、header(“signature”, signature)。

注: salt 即为动态获取的密钥。

下面请看具体的实现,应该在拦截器里统一处理:

public class ApiInterceptor implements HandlerInterceptor {
  private static final Logger logger = LoggerFactory.getLogger(ApiInterceptor.class);
  private String salt="ed4ffcd453efab32";

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
    logger.info("进入拦截器");
    request.setCharacterEncoding("UTF-8");
    response.setCharacterEncoding("UTF-8");
    response.setHeader("Content-Type", "application/json;charset=utf8");
    StringBuilder urlBuilder = getUrlAuthenticationApi(request); // 这里是MD5加密算法
    String sign = MD5(urlBuilder.toString() + salt);
    String signature = request.getHeader("signature");
    logger.info("加密前传入的签名" + signature);
    logger.info("后端加密后的签名" + sign);
    if(sign.equals(signature)) {
      return true;
    } else { //签名错误
      response.getWriter().print("签名错误");
      response.getWriter().close();           
      return false;
    }
  }    
  private StringBuilder getUrlAuthenticationApi(HttpServletRequest request) {
    Enumeration<String> paramesNames = request.getParameterNames();
    List<String> nameList = new ArrayList<>();
    nameList.add("token");
    nameList.add("timestamp");        
    while (paramesNames.hasMoreElements()) {
      nameList.add(paramesNames.nextElement());
    }
    StringBuilder urlBuilder = new StringBuilder();
    nameList.stream().sorted().forEach(name -> {            
      if ("token".equals(name) || "timestamp".equals(name)){                
        if("token".equals(name) && null ==request.getHeader(name)){                    
          return;
        }
        urlBuilder.append('&');
        urlBuilder.append(name).append('=').append(request.getHeader(name));
      } else {
        urlBuilder.append('&');
        urlBuilder.append(name).append('=').append(request.getParameter(name));
      }
    });
    urlBuilder.deleteCharAt(0);
    logger.info("url : " + urlBuilder.toString());
    return urlBuilder;
  }
  @Override
  public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception { }
  @Override
  public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object o, Exception e) throws Exception { }
}

输入输出参数加密

为了保护数据,比如说防爬虫,需要对输入输出参数进行加密,客户端加密输入参数传回服务端,服务端解密输入参数执行请求;服务端返回数据时对其加密,客户端拿到数据后解密数据,获取最终的数据。这样,即便别人知道了参数地址,也无法模拟请求数据。

至此,基础框架就已经搭建完成,下篇我们将开始实现具体的需求。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 tuyrk@qq.com

文章标题:17-第16课:Spring Cloud 实例详解——基础框架搭建(三)

文章字数:2.8k

本文作者:神秘的小岛岛

发布时间:2020-07-06, 16:00:24

最后更新:2020-07-14, 23:05:24

原始链接:https://www.tuyrk.cn/gitchat/springcloud-quickly/17-framework-build-03/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录
×

喜欢就点赞,疼爱就打赏