Vue前后端整合

vue-monitor作为前端应用,vue-admin作为后台提供标准接口,这是标准的前后端分离解决方案。本文主要讲解两个应用整合的过程。按理来说,也不存在什么整合的过程,这本就是两个独立的应用,只需要将两个应用分别启动就可以正常访问了,事实也确实如此,但在此之前需要先解决一个问题:跨域问题。

启动vue-monitor

进入vue-monitor跟目录,执行以下命令:

npm run dev

这样前端应用就启动了,这时候前端时完全依赖于node的。如果想不依赖于node也可以,可以执行

 npm run build 

这样就可以将前端应用打包成静态资源,然后将这些静态资源部署到nginx服务器,同样可以正常访问,这里用的时第一种方法。

启动vue-admin

其实就是启动一个spring-boot应用,启动方法很多种,这里是在IDEA中直接 运行的

至此,两个应用已经完全启动了。

前端调用后端接口

在我们的前端应用中,有一个登录界面,效果如下

注意,随着应用的开发,可能之后这些代码会被删除或者覆盖,这里只是 为了说明前端调用后端应用的一个示例。

当点击的登录按钮的时候,调用后台的登录接口,后台对应 的接口信息如下

我们期望的结果是:点击登录的时候,可以访问到后台这个接口。但是当我们点击登录按钮的时候,发现界面没有任何响应,后台也没有任何日志输出,这已经可以说明接口没有调用成功了。打开浏览器调试工具,发现有如下错误:

具体内容如下:

Failed to load http://localhost:8081/api/system/login: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:8082' is therefore not allowed access. The response had HTTP status code 403. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

这是一个跨域问题,我们前端应用的端口是 8002,后台应用的端口是8001,存在跨域问题。怎么解决?有很多方法:

  • nginx方向代理
  • CORS

这里用了CORS 解决跨域问题。第二种方法没有用过,之后补上吧。

CORS解决跨域

使用cors解决跨域问题,需要在后台将 前端域名 设置成可以访问。
先在后台添加一个 过滤器:

package com.hand.sxy.filter;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 不加 @Configuration 注解不生效
 *
 * @author spilledyear
 * @date 2018/4/21 18:42
 */
@Configuration
@WebFilter(urlPatterns = "/*")
public class CorsFilter implements Filter {
    private Logger logger = LoggerFactory.getLogger(CorsFilter.class);

    @Override
    public void init(FilterConfig arg0) throws ServletException {
    }


    @Override
    public void doFilter(ServletRequest request, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        logger.debug("跨域拦截");

        HttpServletResponse response = (HttpServletResponse) res;

        // 指定允许其他域名访问
        response.setHeader("Access-Control-Allow-Origin", "*");
        // 响应类型
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE");
        // 响应头设置
        response.setHeader("Access-Control-Allow-Headers", "token,Content-Type,Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Max-Age,authorization");
        response.setHeader("Access-Control-Max-Age", "3600");

        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {

    }
}

这里有一个需要注意的地方,需要添加 @Configuration注解,要不然这个filter 是不生效了。添加过滤器后,重新启动后台应用,再次点击登录按钮,发现还是报错了,报错信息如下:

Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. Origin 'http://localhost:8082' is therefore not allowed access.

大概意思是说, 后台中的响应头不能设置 Access-Control-Allow-Origin 的值为 通配符 *。这时候对后台应用该稍作修改

// 指定允许其他域名访问,因为前端应用域名是 http://localhost:8082
response.setHeader("Access-Control-Allow-Origin", "http://localhost:8082");

再次重新启动该后台应用,在前端再次访问,发现还是失败了,报错信息如下:

Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. Origin 'http://localhost:8082' is therefore not allowed access

提示当 request's credentials mode 的值是  'include' 的时候, Access-Control-Allow-Credentials 在响应头中必输设置成 true。

这让我想到一个问题,那就是在前端应用中 request 请求头设置问题,内容如下:

let request = {
  credentials: 'include',
  method: type,
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json'
  },
  mode: "cors",
  cache: "force-cache"
}

果然设置了 credentials: 'include'。
可是这个参数我并不太清楚是干嘛的,解决方法有两个:
1、在后台添加一行代码,设置 Access-Control-Allow-Credentials 的值为 true

response.setHeader("Access-Control-Allow-Credentials", "true");

2、在前端的请求头中,去除 credentials: 'include' 这个设置

let request = {
  method: type,
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json'
  },
  mode: "cors",
  cache: "force-cache"
}

至此,跨域问题得到解决。再次点击登录按钮,查看后台日志:

从上图中的日志中可以看出,成功的调用了 登录接口。

接收参数

跨域问题是成功解决了,但是参数还没有传过来。为了将用户名和密码传到后台测试,对后台代码稍作修改。

//对查询方法稍作修改
<select id="query" resultMap="BaseResultMap" parameterType="com.hand.sxy.account.dto.User">
    SELECT * FROM USER WHERE USERNAME = #{username} AND PASSWORD = #{password}
</select>

然后其它地方也稍作修改,具体的请看源码,controller 中的代码如下

package com.hand.sxy.system.controller;

@RestController
public class LoginController {
    private Logger logger = LoggerFactory.getLogger(LoginController.class);

    @Autowired
    private ILoginService loginService;

    @RequestMapping(value = "/api/system/login", method = RequestMethod.POST)
    public Result login(HttpServletRequest request, User user) {

        List<User> userList = loginService.login(user);
        Result result = new Result(userList);

        if (userList == null || userList.isEmpty()) {
            logger.info("登录失败,用户名或密码错误");
            result.setSuccess(false);
            result.setMessage("用户名或密码错误");
        }
        logger.info("登录成功");
        return result;
    }

}

修改之后,重启后台系统,在前端界面输入用户名和密码,点击登录按钮,用浏览器调试工具发现前端调用了请求,参数也传了,但是在后台打断点发现参数没有穿过来。
解决方法:方法加上 @RequestBody 注解

package com.hand.sxy.system.controller;

@RestController
public class LoginController {
    private Logger logger = LoggerFactory.getLogger(LoginController.class);

    @Autowired
    private ILoginService loginService;

    @RequestMapping(value = "/api/system/login", method = RequestMethod.POST)
    public Result login(HttpServletRequest request, @RequestBody User user) {

        List<User> userList = loginService.login(user);
        Result result = new Result(userList);

        if (userList == null || userList.isEmpty()) {
            logger.info("登录失败,用户名或密码错误");
            result.setSuccess(false);
            result.setMessage("用户名或密码错误");
        }
        logger.info("登录成功");
        return result;
    }

}

@RequestBody用于读取Request请求的body部分数据,使用系统默认配置的HttpMessageConverter进行解析,然后把相应的数据绑定到要返回的对象上,然后再把HttpMessageConverter返回的对象数据绑定到 controller中方法的参数上。

@RequestBody注解是否必须要,根据request header Content-Type的值来判断
GET、POST方式提时

  • application/x-www-form-urlencoded, 可选(即非必须,因为这种情况的数据@RequestParam, @ModelAttribute也可以处理,当然@RequestBody也能处理)
  • multipart/form-data, 不能处理(即使用@RequestBody不能处理这种格式的数据)
  • 其他格式, 必须(其他格式包括application/json, application/xml等。这些格式的数据,必须使用@RequestBody来处理)

PUT方式提交时

  • application/x-www-form-urlencoded, 必须
  • multipart/form-data, 不能处理
  • 其他格式, 必须

说明:request的body部分的数据编码格式由header部分的Content-Type指定;

@ResponseBody用于将Controller的方法返回的对象,通过适当的HttpMessageConverter转换为指定格式后,写入到Response对象的body数据区。 在Controller中返回的数据不是html标签的页面,而是其他某种格式的数据时(如json、xml等)使用。

整合echarts

echarts是百度的,现在已经到了版本4了,很强大的一个图表框架。而且上手特别简单,打开官网看看就知道怎么用, 至于具体的细节,那就是靠查API了,下面解释怎么在vue应用中使用 echarts。

安装echarts

npm install echarts --save

这样就已经安装好了。

引用echarts

引用方式有两种:一种是全局引用;一种根据需求引用你要用到的部分。这里使用的是全局引用。
在 main.js 文件中,添加以下内容

import echarts from 'echarts'
Vue.prototype.$echarts = echarts 

然后使用的时候

<template>
  <div class="line1">
    <div id="line1" class="" style="width: 100%;height:680px;"></div>
  </div>
</template>

<script>
  export default {
    mounted() {
      this.myChart = this.$echarts.init(document.getElementById('line1'));
      this.initData();
    },
    methods: {
      initData() {
          const  option = xxx
          this.myChart.setOption(option);
    }
</script>

不要好奇那个 const option = xxx是什么意思,你可以把它当作是配置,实际上它就是个配置,你需要显示什么图形,需要什么数据,都是在这里面配置,它是一个json对象,也就说,echarts的对象就是一个echarts对象。如果你实在不知道这个option是什么,有一个最简单的办法,你去官网上找一个例子,然后它代码拷贝出来就好了。至于具体显示什么图形,就得去查API了。

JWT交互

vue-admin后台已经整合了JWT这一方面的内容,那么前后端怎么通过JWT交互呢?

安装Js-Cookie

js-cookie是干嘛的,看一下这个

A simple, lightweight JavaScript API for handling cookies

If you're viewing this at https://github.com/js-cookie/js-cookie, you're reading the documentation for the master branch. View documentation for the latest release.

没错,就是帮助我们操作cookie的,npm上的链接 js-cookie
基础用法,简单看看,不说了,不清楚的时候看看文档把。

image.png

安装 js-cookie
//auth.js

npm install js-cookie --save

封装一个工具类

import Cookies from 'js-cookie'

export const TOKEN_KEY = 'Authorization';

export function getToken() {
  return Cookies.get(TOKEN_KEY)
}

export function setToken(token) {
  return Cookies.set(TOKEN_KEY, token)
}

export function removeToken() {
  return Cookies.remove(TOKEN_KEY)
}

axios工具类

整体上比较简单,就是在请求和响应上设置一个拦截器。在请求的适合,判断前端是否已经获取过token,如果有token,就将它设置在请求头中。子啊响应的时,判断一下响应状态,做一些处理。

import axios from 'axios'
import { Message } from 'element-ui'
import store from '@/store'
import { TOKEN_KEY, getToken } from '@/utils/auth'

/**
 * 创建一个 axios 实例
 */
const service = axios.create({
  baseURL: process.env.BASE_API,
  timeout: 50000
})

// 请求 拦截器,在请求之前执行一些逻辑
service.interceptors.request.use(request => {
  if (store.getters.token) {
    // 让每个请求携带{TOKEN_KEY: xxx} TOKEN_KEY为自定义key,在 @/utils/auth 中定义, 请根据实际情况自行修改
    request.headers[TOKEN_KEY] = getToken();
  }
  return request
}, error => {
  Promise.reject(error)
});

// 响应 拦截器,返回结果之后处理一些亲求
service.interceptors.response.use(
  response => {
    /**
     * 下面的注释为通过response自定义code来标示请求状态,当code返回如下情况为权限有问题,登出并返回到登录页
     * 如通过xmlhttprequest 状态码标识 逻辑可写在下面error中 
     */
    const res = response.data;
    if (response.status !== 200) {
      Message({
        message: res.message,
        type: 'error',
        duration: 5 * 1000
      });
      // 50008:非法的token; 50012:其他客户端登录了; 50014:Token 过期了;
      if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
        MessageBox.confirm('你已被登出,可以取消继续留在该页面,或者重新登录', '确定登出', {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          store.dispatch('FedLogOut').then(() => {
            // 为了重新实例化vue-router对象 避免bug
            location.reload(); 
          });
        })
      }
      return Promise.reject('error');
    } else {
      return response.data;
    }
  },
  error => {
    console.log('error' + error);
    Message({
      message: error.message,
      type: 'error',
      duration: 5 * 1000
    })
    return Promise.reject(error)
});

export default service

测试

启动后台应用: localhost:8081
启动前端应用: localhost:8082

打开前端的的登录界面,输入用户名密码,点击登录。通过chrom调试工具观察network请求;同时观察后台的自定义过滤器。

这个接口返回的信息, 可以看到,已经返回的token。

同时,利用vue开发者调试工具观察状态情况


可以看到,已经把值存进去了。

登录成功之后,跳到了首页。

这时候再请求一个用户列表,观察前后端情况

可以看到,后台已经拿到了 token


同时,前端也返回了我们想要的用户信息


整合Quartz

大概流程如下:在 vue-admin 中提供一个可视化管理Job的界面,可以查看Job信息,添加Job、停止或启动Job。同时这里引入另外一个东西,查看Job的执行记录,在后台引入一张 sys_job_record表,用于记录Job的执行里历史记录。有关于执行历史记录在 vue-monitor 这一块的实现,大概上就是添加了一个 JobListern,监听Job,在Job执行的时候将 执行记录插入到 sys_job_record表中。然后对应的,前端也提供一个界面用于展示Job执行记录。

i

关键代码

JobRecordListener

package com.hand.sxy.job.listener;

import com.hand.sxy.job.dto.JobRecord;
import com.hand.sxy.job.service.IJobRecordService;
import org.quartz.*;
import org.quartz.listeners.JobListenerSupport;
import org.springframework.context.ApplicationContext;

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Date;

public class JobRecordListener extends JobListenerSupport {

    private static final String VETOED = "Vetoed";

    private static final String FINISH = "Finish";

    private static final String FAILED = "Failed";


    private ApplicationContext applicationContext;

    public JobRecordListener(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }


    @Override
    public String getName() {
        return "JobRecordListener";
    }


    /**
     * @param context
     * @param jobException
     */
    @Override
    public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) {
        JobRecord dto = getRecord(context);
        if (jobException != null) {
            String errMsg = jobException.getMessage();
            dto.setJobStatusMessage(errMsg);
            dto.setJobStatus(FAILED);
        } else {
            dto.setJobStatus(FINISH);
        }
        this.insert(dto);
        Job job = context.getJobInstance();
        if (job instanceof JobListener) {
            context.put("JOB_RUNNING_INFO_ID", dto.getJobRecordId());
            ((JobListener) job).jobWasExecuted(context, jobException);
        }
    }
......
}

RcordSchedulerPlugin

package com.hand.sxy.job.plugin;

import com.hand.sxy.job.listener.JobRecordListener;
import com.hand.sxy.job.listener.SchedulerRecordListener;
import org.quartz.ListenerManager;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.impl.matchers.EverythingMatcher;
import org.quartz.spi.ClassLoadHelper;
import org.quartz.spi.SchedulerPlugin;
import org.springframework.context.ApplicationContext;

/**
 * @author spilledyear
 */
public class RcordSchedulerPlugin implements SchedulerPlugin {

    private ApplicationContext applicationContext;

    private Scheduler scheduler;


    @Override
    public void initialize(String name, Scheduler scheduler, ClassLoadHelper loadHelper) throws SchedulerException {
        this.scheduler = scheduler;
    }


    @Override
    public void start() {
        try {
            applicationContext = (ApplicationContext) scheduler.getContext().get("applicationContext");
            ListenerManager listenerManager = scheduler.getListenerManager();
            listenerManager.addJobListener(new JobRecordListener(applicationContext), EverythingMatcher.allJobs());
            listenerManager.addSchedulerListener(new SchedulerRecordListener(applicationContext));

        } catch (SchedulerException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void shutdown() {

    }

}

后台每执行一次job,都会将 执行记录写入到 sys_job_record表中,并在前端展示。

有关于后端JWT的处理

整合Spring Security

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

Springboot2的变化还是很多的,其中有一个就是密码加密方式问题。
要想更灵活的使用spring-securiy,你必须理解它的工作流程,我觉得理解它的过滤器链是最重要的,一共自带了11个过滤器链,但是关键的只有那么几个,如果你知道了 各个过滤器的作用,就可以根据自己的需求在合适的位置插入自己的过滤器。其实主要来看,spring-security基础就分两个部分:一个是用户认证;一个是判断请求的资源是否有权限访问,也可以认为是权限和资源的关系,这两部分可以分开来看。在我们实际项目中,一般客户还的是前半部分,也就是用户的认证过程。比如最常用的通过数据库的方式实现认证:实现 UserDetailsService 接口,在loadUserByUsername 方法中从数据库加载用户信息,然后封装成UserDetails对象交接给spring-securitys。可以发现,我们要做的就是实现一个loadUserByUsername 方法,简单到不能再简单。但如果你不理解spring-security的工作流程,你写完这个代码之后其实是没底的。又比如:你想在修改认证拦截,可以在UsernamePasswordAuthenticationFilter 过滤器前加自己的过滤器,你想自定义资源与权限的关系,可以继承 FilterSecurityInterceptor 拦截器等。

添加一个 配置类

package com.hand.sxy.config;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationEntryPoint unauthorizedHandler;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Value("${jwt.header}")
    private String tokenHeader;

    @Value("${jwt.route.authentication.path}")
    private String authenticationPath;

    @Autowired
    private MyFilterSecurityInterceptor myFilterSecurityInterceptor;


    /**
     * 通过这种方式注入 authenticationManagerBean ,然后在别的地方也可以用
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 注册 UserDetailsService 的 Bean
     *
     * @return
     */
    @Bean
    UserDetailsService customUserService() {
        return new CustomUserService();
    }

    /**
     * Sring5 中密码加密新方式
     *
     * @return
     */
    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
//        httpSecurity.rememberMe().rememberMeServices(rememberMeServices());
//        httpSecurity.addFilterBefore(myFilterSecurityInterceptor, FilterSecurityInterceptor.class);

        httpSecurity
                .cors().and()
                .csrf().disable()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler)

                /** 不创建 session **/
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                .antMatchers("/*.html", "/**/*.html", "/**/*.js", "/**/*.css").permitAll()
                .antMatchers("/login", "/register", "/auth", "/oauth/*").permitAll()
                .antMatchers("/api/role/query").hasRole("ADMIN")
                .anyRequest().authenticated()

                .and()
                .formLogin().loginPage("/login").loginProcessingUrl("/api/system/login").usernameParameter("username").passwordParameter("password").permitAll()

                .and()
                .logout().logoutUrl("/logout").logoutSuccessUrl("/api/system/logout").permitAll();


        /**
         * spring security过滤器链中,真正的用户信息校验是 UsernamePasswordAuthenticationFilter 过滤器,然后才是权限校验。
         * 这里在 UsernamePasswordAuthenticationFilter过滤器之前 自定义一个过滤器,这样就可以提前根据token将authenticate信息
         * 维护进speing security上下文,然后在 UsernamePasswordAuthenticationFilter 得到的就已经是通过校验的用户了。
         */
        JwtAuthorizationTokenFilter authenticationTokenFilter = new JwtAuthorizationTokenFilter(customUserService(), jwtTokenUtil, tokenHeader);
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        /**
         * disable page caching
         *
         * 下面这行代码巨玄乎,加了这个之后,前端应用就无法正常访问了(也就是说需要开发/api/**权限才能正常 访问)
         */
//        httpSecurity.headers().frameOptions().sameOrigin().cacheControl();

    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserService()).passwordEncoder(passwordEncoder());
    }

    @Override
    public void configure(WebSecurity webSecurity) {
        webSecurity
                .ignoring().antMatchers(HttpMethod.POST, "/login", "/auth")

                .and()
                .ignoring().antMatchers("/**/*.html", "/**/*.js", "/**/*.css");
    }
}

那个密码加密方式是sringboot2中的改变。使用这种方式加密之后,在后台存的不仅仅是 密码的hash指,还包括hash加密方法的名字(用{}包起来),如下:

{bcrypt}$2a$10$PurQWIEtutmzpPXFQS9G6eRMSj5kAtTQY58APfrY1CJD.grxqA6DK

CustomUserService 源码如下,其实就是根据用户名从数据库哪用户信息

package com.hand.sxy.security;

@Service
public class CustomUserService implements UserDetailsService {
    private Logger logger = LoggerFactory.getLogger(CustomUserService.class);

    @Autowired
    UserMapper userMapper;

    @Autowired
    RoleMapper roleMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (StringUtils.isEmpty(username)) {
            logger.error("username is empty");
            throw new UsernameNotFoundException("username is empty");
        }

        User user = userMapper.selectByUserName(username);
        if (null == user) {
            logger.error("get user is null, userName:{}", username);
            throw new UsernameNotFoundException("username is empty");
        }


        List<Role> roleList = roleMapper.queryByUser(user);
        Set<GrantedAuthority> authorities = new HashSet<>();
        if (!CollectionUtils.isEmpty(roleList)) {
            for (Role role : roleList) {
//                GrantedAuthority grantedAuthority = new MyGrantedAuthority(permission.getUrl(), permission.getMethod());
                GrantedAuthority auth = new SimpleGrantedAuthority(role.getRoleCode());
                authorities.add(auth);
            }
        }

        UserDetails userDetails = new CustomUser(user.getUserId(), user.getUsername(), user.getPassword(),
                true, true, true, true, authorities, null);
        return userDetails;
    }

}

整合JWT

JWT是JSON Web Token的缩写,即JSON Web令牌。JSON Web令牌(JWT)是一种紧凑的、URL安全的方式,用来表示要在双方之间传递的“声明”。JWT中的声明被编码为JSON对象,用作JSON Web签名(JWS)结构的有效内容或JSON Web加密(JWE)结构的明文,使得声明能够被:数字签名、或利用消息认证码(MAC)保护完整性、加密。

JWT构成

一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与 签名依顺序用点号(".")链接而成:headerpayloadsignature

Header

头部(Header)里面说明类型和使用的算法,比如:

{
  "alg": "HS256",
  "typ": "JWT"
}

说明是JWT(JSON web token)类型,使用了HMAC SHA 算法。然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分.

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
Payload

载荷(Payload)载荷就是存放有效信息的地方,含三个部分:
1、标准中注册的声明
2、公共的声明
3、私有的声明

  • 标准中注册的声明,建议但不强制使用
    iss: jwt签发者
    sub: jwt所面向的用户
    aud: 接收jwt的一方
    exp: jwt的过期时间,这个过期时间必须要大于签发时间
    nbf: 定义在什么时间之前,该jwt都是不可用的
    iat: jwt的签发时间
    jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击

  • 公共的声明
    公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.

  • 私有的声明
    私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
    定义一个payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然后将其进行base64加密,得到Jwt的第二部分。

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

把场景2的操作描述成一个json对象。其中添加了一些其他的信息,帮助今后收到这个JWT的服务器理解这个JWT。 当然,你还可以往载荷放非敏感的用户信息,比如uid

signature

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret');
//TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

注意secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

将这三部分用.连接成一个完整的字符串,构成了最终的jwt:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

应用场景

应用场景:
1、浏览器将用户名和密码以post请求的方式发送给服务器。

2、服务器接受后验证通过,用一个密钥生成一个JWT。

3、服务器将这个生成的JWT返回给浏览器。

4、浏览器存储JWT并在使用时将JWT包含在authorization header里面,然后发送请求给服务器。

5、服务器可以在JWT中提取用户相关信息。进行验证。

6、服务器验证完成后,发送响应结果给浏览器。

好吹就是无状态,在前后端分离的应用中,后台不需要存储状态,减轻服务器的压力。

整合

这个又是借鉴了github上一位大神的代码,其实我在好几个地方看大了那个代码了,需要引入一个jar,用于处理JWT的一些操作

<!-- JWT支持 -->
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

整体思路如下:
在 UsernamePasswordAuthenticationFilter 过滤器前添加一个过滤器,该过滤器主要用是针对 JWT 的认证?就是说,当前端的请求头中,包含了token信息,就通过自定义过滤器逻辑走认证过程,认证 通过之后,把认证信息交给spring-security上下文,然后继续走spring-security的流程,就相当于如果前端请求头总有token信息,并且后台校验这个token信息是没有问题的,就让spring-security认为这个请求时已经通过校验的,很巧妙。如果没有包含token,就执行 spring-security标准的认证流程。然后开放一个用于前端请求token的接口,这个接口不需要认证,认证通过之后,返回前端一个token,前端可以保存到 localstorage里面,也可以保存到cookie里面。然后下次请求的时候,在请求头中带上这个token信息。

security配置类

package com.hand.sxy.config;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationEntryPoint unauthorizedHandler;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Value("${jwt.header}")
    private String tokenHeader;

    @Value("${jwt.route.authentication.path}")
    private String authenticationPath;

    @Autowired
    private MyFilterSecurityInterceptor myFilterSecurityInterceptor;


    /**
     * 通过这种方式注入 authenticationManagerBean ,然后在别的地方也可以用
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 注册 UserDetailsService 的 Bean
     *
     * @return
     */
    @Bean
    UserDetailsService customUserService() {
        return new CustomUserService();
    }

    /**
     * Sring5 中密码加密新方式
     *
     * @return
     */
    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
//        httpSecurity.rememberMe().rememberMeServices(rememberMeServices());
//        httpSecurity.addFilterBefore(myFilterSecurityInterceptor, FilterSecurityInterceptor.class);

        httpSecurity
                .cors().and()
                .csrf().disable()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler)

                /** 不创建 session **/
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                .antMatchers("/*.html", "/**/*.html", "/**/*.js", "/**/*.css").permitAll()
                .antMatchers("/login", "/register", "/auth", "/oauth/*").permitAll()
                .antMatchers("/api/role/query").hasRole("ADMIN")
                .anyRequest().authenticated()

                .and()
                .formLogin().loginPage("/login").loginProcessingUrl("/api/system/login").usernameParameter("username").passwordParameter("password").permitAll()

                .and()
                .logout().logoutUrl("/logout").logoutSuccessUrl("/api/system/logout").permitAll();


        /**
         * spring security过滤器链中,真正的用户信息校验是 UsernamePasswordAuthenticationFilter 过滤器,然后才是权限校验。
         * 这里在 UsernamePasswordAuthenticationFilter过滤器之前 自定义一个过滤器,这样就可以提前根据token将authenticate信息
         * 维护进speing security上下文,然后在 UsernamePasswordAuthenticationFilter 得到的就已经是通过校验的用户了。
         */
        JwtAuthorizationTokenFilter authenticationTokenFilter = new JwtAuthorizationTokenFilter(customUserService(), jwtTokenUtil, tokenHeader);
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        /**
         * disable page caching
         *
         * 下面这行代码巨玄乎,加了这个之后,前端应用就无法正常访问了(也就是说需要开发/api/**权限才能正常 访问)
         */
//        httpSecurity.headers().frameOptions().sameOrigin().cacheControl();

    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserService()).passwordEncoder(passwordEncoder());
    }

    @Override
    public void configure(WebSecurity webSecurity) {
        webSecurity
                .ignoring().antMatchers(HttpMethod.POST, "/login", "/auth")

                .and()
                .ignoring().antMatchers("/**/*.html", "/**/*.js", "/**/*.css");
    }
}

注意上面的

.exceptionHandling().authenticationEntryPoint(unauthorizedHandler)
package com.hand.sxy.jwt;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Serializable;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {

    private static final long serialVersionUID = -8970718410437077606L;

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // This is invoked when user tries to access a secured REST resource without supplying any credentials
        // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

这表示当你权限认证失败的时候,执行 JwtAuthenticationEntryPoint 里面的 commence方法,返回给前端,用于自定义适合客户阅读的提示。

这个就是开放的接口,用于前端请求token信息。

    /**
     * 认证接口,用于前端获取 JWT 的接口
     *
     * @param user
     * @return
     * @throws AuthenticationException
     */
    @RequestMapping(value = "${jwt.route.authentication.path}", method = RequestMethod.POST)
    @ResponseBody
    public ResultResponse obtainToken(@RequestBody User user) throws AuthenticationException {

        /**
         * 通过调用 spring security 中的 authenticationManager 对用户进行验证
         */
        Objects.requireNonNull(user.getUsername());
        Objects.requireNonNull(user.getPassword());
        try {
            authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()));
        } catch (DisabledException e) {
            throw new AuthenticationException("该已被被禁用,请检查", e);
        } catch (BadCredentialsException e) {
            throw new AuthenticationException("无效的密码,请检查", e);
        }

        /**
         * 根据用户名从数据库获取用户信息,然后生成 token
         */
        final UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUsername());
        final String token = jwtTokenUtil.generateToken(userDetails);

        List<User> userList = userService.query(user);
        ResultResponse resultSet = new ResultResponse(true, token);
        resultSet.setRows(userList);
        return resultSet;
    }

这个是jwt工具类

package com.hand.sxy.jwt;

@Component
public class JwtTokenUtil implements Serializable {
    private static final long serialVersionUID = -3301605591108950415L;

    private Clock clock = DefaultClock.INSTANCE;

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }

    public Date getIssuedAtDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getIssuedAt);
    }

    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }

    private Boolean isTokenExpired(String token) {
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(clock.now());
    }

    private Boolean isCreatedBeforeLastPasswordReset(Date created, Date lastPasswordReset) {
        return (lastPasswordReset != null && created.before(lastPasswordReset));
    }

    private Boolean ignoreTokenExpiration(String token) {
        // here you specify tokens, for that the expiration is ignored
        return false;
    }

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return doGenerateToken(claims, userDetails.getUsername());
    }

    private String doGenerateToken(Map<String, Object> claims, String subject) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Boolean canTokenBeRefreshed(String token, Date lastPasswordReset) {
        final Date created = getIssuedAtDateFromToken(token);
        return !isCreatedBeforeLastPasswordReset(created, lastPasswordReset)
                && (!isTokenExpired(token) || ignoreTokenExpiration(token));
    }

    public String refreshToken(String token) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        final Claims claims = getAllClaimsFromToken(token);
        claims.setIssuedAt(createdDate);
        claims.setExpiration(expirationDate);

        return Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Boolean validateToken(String token, UserDetails userDetails) {
        CustomUser customUser = (CustomUser) userDetails;
        final String username = getUsernameFromToken(token);
        final Date created = getIssuedAtDateFromToken(token);
        //final Date expiration = getExpirationDateFromToken(token);
        return (
                username.equals(customUser.getUsername()) && !isTokenExpired(token) && !isCreatedBeforeLastPasswordReset(created, customUser.getLastPasswordResetDate())
        );
    }

    private Date calculateExpirationDate(Date createdDate) {
        return new Date(createdDate.getTime() + expiration * 1000);
    }
}

这个是关键的过滤器

package com.hand.sxy.jwt;

public class JwtAuthorizationTokenFilter extends OncePerRequestFilter {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    private UserDetailsService userDetailsService;
    private JwtTokenUtil jwtTokenUtil;
    private String tokenHeader;

    public JwtAuthorizationTokenFilter(UserDetailsService userDetailsService, JwtTokenUtil jwtTokenUtil, String tokenHeader) {
        this.userDetailsService = userDetailsService;
        this.jwtTokenUtil = jwtTokenUtil;
        this.tokenHeader = tokenHeader;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        logger.debug("processing authentication for '{}'", request.getRequestURL());

        final String token = request.getHeader(this.tokenHeader);

        String username = null;
        if (token != null && !"".equals(token)) {
            try {
                username = jwtTokenUtil.getUsernameFromToken(token);
            } catch (IllegalArgumentException e) {
                logger.error("从Token中获取用户名失败", e);
            } catch (ExpiredJwtException e) {
                logger.warn("这个Token已经失效了", e);
            }
        } else {
            logger.warn("请求头中未发现 Token, 将执行Spring Security正常的验证流程");
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            logger.debug("security context was null, so authorizating user");

            // 也可以将用户信息保存在token中,这时候就可以不用查数据库
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            // 校验前端传过来的Token是否有问题
            if (jwtTokenUtil.validateToken(token, userDetails)) {
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                logger.info("用户 '{}' 授权成功, 赋值给 SecurityContextHolder 上下文", username);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        chain.doFilter(request, response);
    }
}

推荐阅读更多精彩内容