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
- Works in all browsers
- Accepts any character
- Heavily tested
- No dependency
- Unobtrusive JSON support
- Supports AMD/CommonJS
- RFC 6265 compliant
- Useful Wiki
- Enable custom encoding/decoding
- ~900 bytes gzipped!
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
基础用法,简单看看,不说了,不清楚的时候看看文档把。
安装 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执行记录。
关键代码
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实际上就是一个字符串,它由三部分组成,头部、载荷与 签名依顺序用点号(".")链接而成:header,payload,signature
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);
}
}