vue-monitor作為前端應(yīng)用庶骄,vue-admin作為后臺提供標(biāo)準(zhǔn)接口毁渗,這是標(biāo)準(zhǔn)的前后端分離解決方案。本文主要講解兩個應(yīng)用整合的過程单刁。按理來說灸异,也不存在什么整合的過程,這本就是兩個獨立的應(yīng)用羔飞,只需要將兩個應(yīng)用分別啟動就可以正常訪問了肺樟,事實也確實如此,但在此之前需要先解決一個問題:跨域問題逻淌。
啟動vue-monitor
進入vue-monitor跟目錄么伯,執(zhí)行以下命令:
npm run dev
這樣前端應(yīng)用就啟動了,這時候前端時完全依賴于node的卡儒。如果想不依賴于node也可以田柔,可以執(zhí)行
npm run build
這樣就可以將前端應(yīng)用打包成靜態(tài)資源俐巴,然后將這些靜態(tài)資源部署到nginx服務(wù)器,同樣可以正常訪問硬爆,這里用的時第一種方法欣舵。
啟動vue-admin
其實就是啟動一個spring-boot應(yīng)用,啟動方法很多種缀磕,這里是在IDEA中直接 運行的
至此缘圈,兩個應(yīng)用已經(jīng)完全啟動了。
前端調(diào)用后端接口
在我們的前端應(yīng)用中袜蚕,有一個登錄界面糟把,效果如下
注意,隨著應(yīng)用的開發(fā)廷没,可能之后這些代碼會被刪除或者覆蓋糊饱,這里只是 為了說明前端調(diào)用后端應(yīng)用的一個示例。
當(dāng)點擊的登錄按鈕的時候颠黎,調(diào)用后臺的登錄接口另锋,后臺對應(yīng) 的接口信息如下
我們期望的結(jié)果是:點擊登錄的時候,可以訪問到后臺這個接口狭归。但是當(dāng)我們點擊登錄按鈕的時候夭坪,發(fā)現(xiàn)界面沒有任何響應(yīng),后臺也沒有任何日志輸出过椎,這已經(jīng)可以說明接口沒有調(diào)用成功了室梅。打開瀏覽器調(diào)試工具,發(fā)現(xiàn)有如下錯誤:
具體內(nèi)容如下:
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.
這是一個跨域問題疚宇,我們前端應(yīng)用的端口是 8002亡鼠,后臺應(yīng)用的端口是8001,存在跨域問題敷待。怎么解決间涵?有很多方法:
- nginx方向代理
- CORS
這里用了CORS 解決跨域問題。第二種方法沒有用過榜揖,之后補上吧勾哩。
CORS解決跨域
使用cors解決跨域問題,需要在后臺將 前端域名 設(shè)置成可以訪問举哟。
先在后臺添加一個 過濾器:
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", "*");
// 響應(yīng)類型
response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE");
// 響應(yīng)頭設(shè)置
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 是不生效了妨猩。添加過濾器后潜叛,重新啟動后臺應(yīng)用,再次點擊登錄按鈕册赛,發(fā)現(xiàn)還是報錯了钠导,報錯信息如下:
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.
大概意思是說震嫉, 后臺中的響應(yīng)頭不能設(shè)置 Access-Control-Allow-Origin 的值為 通配符 *森瘪。這時候?qū)笈_應(yīng)用該稍作修改
// 指定允許其他域名訪問牡属,因為前端應(yīng)用域名是 http://localhost:8082
response.setHeader("Access-Control-Allow-Origin", "http://localhost:8082");
再次重新啟動該后臺應(yīng)用,在前端再次訪問扼睬,發(fā)現(xiàn)還是失敗了逮栅,報錯信息如下:
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
提示當(dāng) request's credentials mode 的值是 'include' 的時候, Access-Control-Allow-Credentials 在響應(yīng)頭中必輸設(shè)置成 true窗宇。
這讓我想到一個問題措伐,那就是在前端應(yīng)用中 request 請求頭設(shè)置問題,內(nèi)容如下:
let request = {
credentials: 'include',
method: type,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
mode: "cors",
cache: "force-cache"
}
果然設(shè)置了 credentials: 'include'军俊。
可是這個參數(shù)我并不太清楚是干嘛的侥加,解決方法有兩個:
1、在后臺添加一行代碼粪躬,設(shè)置 Access-Control-Allow-Credentials 的值為 true
response.setHeader("Access-Control-Allow-Credentials", "true");
2担败、在前端的請求頭中,去除 credentials: 'include' 這個設(shè)置
let request = {
method: type,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
mode: "cors",
cache: "force-cache"
}
至此镰官,跨域問題得到解決提前。再次點擊登錄按鈕,查看后臺日志:
從上圖中的日志中可以看出泳唠,成功的調(diào)用了 登錄接口狈网。
接收參數(shù)
跨域問題是成功解決了,但是參數(shù)還沒有傳過來笨腥。為了將用戶名和密碼傳到后臺測試拓哺,對后臺代碼稍作修改。
//對查詢方法稍作修改
<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;
}
}
修改之后镶奉,重啟后臺系統(tǒng)础淤,在前端界面輸入用戶名和密碼,點擊登錄按鈕哨苛,用瀏覽器調(diào)試工具發(fā)現(xiàn)前端調(diào)用了請求鸽凶,參數(shù)也傳了,但是在后臺打斷點發(fā)現(xiàn)參數(shù)沒有穿過來建峭。
解決方法:方法加上 @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部分?jǐn)?shù)據(jù),使用系統(tǒng)默認配置的HttpMessageConverter進行解析亿蒸,然后把相應(yīng)的數(shù)據(jù)綁定到要返回的對象上凑兰,然后再把HttpMessageConverter返回的對象數(shù)據(jù)綁定到 controller中方法的參數(shù)上掌桩。
@RequestBody注解是否必須要,根據(jù)request header Content-Type的值來判斷
GET姑食、POST方式提時
- application/x-www-form-urlencoded波岛, 可選(即非必須,因為這種情況的數(shù)據(jù)@RequestParam, @ModelAttribute也可以處理音半,當(dāng)然@RequestBody也能處理)
- multipart/form-data, 不能處理(即使用@RequestBody不能處理這種格式的數(shù)據(jù))
- 其他格式则拷, 必須(其他格式包括application/json, application/xml等。這些格式的數(shù)據(jù)曹鸠,必須使用@RequestBody來處理)
PUT方式提交時
- application/x-www-form-urlencoded煌茬, 必須
- multipart/form-data, 不能處理
- 其他格式, 必須
說明:request的body部分的數(shù)據(jù)編碼格式由header部分的Content-Type指定彻桃;
@ResponseBody用于將Controller的方法返回的對象坛善,通過適當(dāng)?shù)腍ttpMessageConverter轉(zhuǎn)換為指定格式后,寫入到Response對象的body數(shù)據(jù)區(qū)邻眷。 在Controller中返回的數(shù)據(jù)不是html標(biāo)簽的頁面眠屎,而是其他某種格式的數(shù)據(jù)時(如json、xml等)使用耗溜。
整合echarts
echarts是百度的组力,現(xiàn)在已經(jīng)到了版本4了,很強大的一個圖表框架抖拴。而且上手特別簡單燎字,打開官網(wǎng)看看就知道怎么用, 至于具體的細節(jié)阿宅,那就是靠查API了候衍,下面解釋怎么在vue應(yīng)用中使用 echarts。
安裝echarts
npm install echarts --save
這樣就已經(jīng)安裝好了洒放。
引用echarts
引用方式有兩種:一種是全局引用蛉鹿;一種根據(jù)需求引用你要用到的部分。這里使用的是全局引用往湿。
在 main.js 文件中妖异,添加以下內(nèi)容
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是什么意思,你可以把它當(dāng)作是配置领追,實際上它就是個配置他膳,你需要顯示什么圖形,需要什么數(shù)據(jù)绒窑,都是在這里面配置棕孙,它是一個json對象,也就說,echarts的對象就是一個echarts對象蟀俊。如果你實在不知道這個option是什么钦铺,有一個最簡單的辦法,你去官網(wǎng)上找一個例子肢预,然后它代碼拷貝出來就好了矛洞。至于具體顯示什么圖形,就得去查API了误甚。
JWT交互
vue-admin后臺已經(jīng)整合了JWT這一方面的內(nèi)容缚甩,那么前后端怎么通過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
基礎(chǔ)用法冈钦,簡單看看,不說了李请,不清楚的時候看看文檔把瞧筛。
安裝 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工具類
整體上比較簡單,就是在請求和響應(yīng)上設(shè)置一個攔截器导盅。在請求的適合较幌,判斷前端是否已經(jīng)獲取過token,如果有token白翻,就將它設(shè)置在請求頭中乍炉。子啊響應(yīng)的時,判斷一下響應(yīng)狀態(tài)滤馍,做一些處理岛琼。
import axios from 'axios'
import { Message } from 'element-ui'
import store from '@/store'
import { TOKEN_KEY, getToken } from '@/utils/auth'
/**
* 創(chuàng)建一個 axios 實例
*/
const service = axios.create({
baseURL: process.env.BASE_API,
timeout: 50000
})
// 請求 攔截器,在請求之前執(zhí)行一些邏輯
service.interceptors.request.use(request => {
if (store.getters.token) {
// 讓每個請求攜帶{TOKEN_KEY: xxx} TOKEN_KEY為自定義key巢株,在 @/utils/auth 中定義槐瑞, 請根據(jù)實際情況自行修改
request.headers[TOKEN_KEY] = getToken();
}
return request
}, error => {
Promise.reject(error)
});
// 響應(yīng) 攔截器,返回結(jié)果之后處理一些親求
service.interceptors.response.use(
response => {
/**
* 下面的注釋為通過response自定義code來標(biāo)示請求狀態(tài)阁苞,當(dāng)code返回如下情況為權(quán)限有問題困檩,登出并返回到登錄頁
* 如通過xmlhttprequest 狀態(tài)碼標(biāo)識 邏輯可寫在下面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('你已被登出,可以取消繼續(xù)留在該頁面那槽,或者重新登錄', '確定登出', {
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
測試
啟動后臺應(yīng)用: localhost:8081
啟動前端應(yīng)用: localhost:8082
打開前端的的登錄界面悼沿,輸入用戶名密碼,點擊登錄倦炒。通過chrom調(diào)試工具觀察network請求显沈;同時觀察后臺的自定義過濾器。
這個接口返回的信息, 可以看到拉讯,已經(jīng)返回的token涤浇。
同時,利用vue開發(fā)者調(diào)試工具觀察狀態(tài)情況
可以看到魔慷,已經(jīng)把值存進去了只锭。
登錄成功之后,跳到了首頁院尔。
這時候再請求一個用戶列表蜻展,觀察前后端情況
可以看到,后臺已經(jīng)拿到了 token
同時邀摆,前端也返回了我們想要的用戶信息
整合Quartz
大概流程如下:在 vue-admin 中提供一個可視化管理Job的界面纵顾,可以查看Job信息,添加Job栋盹、停止或啟動Job施逾。同時這里引入另外一個東西,查看Job的執(zhí)行記錄例获,在后臺引入一張 sys_job_record表汉额,用于記錄Job的執(zhí)行里歷史記錄。有關(guān)于執(zhí)行歷史記錄在 vue-monitor 這一塊的實現(xiàn)榨汤,大概上就是添加了一個 JobListern蠕搜,監(jiān)聽Job,在Job執(zhí)行的時候?qū)?執(zhí)行記錄插入到 sys_job_record表中收壕。然后對應(yīng)的妓灌,前端也提供一個界面用于展示Job執(zhí)行記錄。
關(guān)鍵代碼
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() {
}
}
后臺每執(zhí)行一次job啼器,都會將 執(zhí)行記錄寫入到 sys_job_record表中旬渠,并在前端展示。
有關(guān)于后端JWT的處理
整合Spring Security
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Springboot2的變化還是很多的端壳,其中有一個就是密碼加密方式問題告丢。
要想更靈活的使用spring-securiy,你必須理解它的工作流程损谦,我覺得理解它的過濾器鏈?zhǔn)亲钪匾尼猓还沧詭Я?1個過濾器鏈,但是關(guān)鍵的只有那么幾個照捡,如果你知道了 各個過濾器的作用颅湘,就可以根據(jù)自己的需求在合適的位置插入自己的過濾器。其實主要來看栗精,spring-security基礎(chǔ)就分兩個部分:一個是用戶認證闯参;一個是判斷請求的資源是否有權(quán)限訪問瞻鹏,也可以認為是權(quán)限和資源的關(guān)系,這兩部分可以分開來看鹿寨。在我們實際項目中新博,一般客戶還的是前半部分,也就是用戶的認證過程脚草。比如最常用的通過數(shù)據(jù)庫的方式實現(xiàn)認證:實現(xiàn) UserDetailsService 接口赫悄,在loadUserByUsername 方法中從數(shù)據(jù)庫加載用戶信息,然后封裝成UserDetails對象交接給spring-securitys馏慨」』矗可以發(fā)現(xiàn),我們要做的就是實現(xiàn)一個loadUserByUsername 方法写隶,簡單到不能再簡單倔撞。但如果你不理解spring-security的工作流程,你寫完這個代碼之后其實是沒底的樟澜。又比如:你想在修改認證攔截误窖,可以在UsernamePasswordAuthenticationFilter 過濾器前加自己的過濾器,你想自定義資源與權(quán)限的關(guān)系秩贰,可以繼承 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)
/** 不創(chuàng)建 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 過濾器,然后才是權(quán)限校驗愈魏。
* 這里在 UsernamePasswordAuthenticationFilter過濾器之前 自定義一個過濾器觅玻,這樣就可以提前根據(jù)token將authenticate信息
* 維護進speing security上下文,然后在 UsernamePasswordAuthenticationFilter 得到的就已經(jīng)是通過校驗的用戶了培漏。
*/
JwtAuthorizationTokenFilter authenticationTokenFilter = new JwtAuthorizationTokenFilter(customUserService(), jwtTokenUtil, tokenHeader);
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
/**
* disable page caching
*
* 下面這行代碼巨玄乎溪厘,加了這個之后,前端應(yīng)用就無法正常訪問了(也就是說需要開發(fā)/api/**權(quán)限才能正常 訪問)
*/
// 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 源碼如下蹋宦,其實就是根據(jù)用戶名從數(shù)據(jù)庫哪用戶信息
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)結(jié)構(gòu)的有效內(nèi)容或JSON Web加密(JWE)結(jié)構(gòu)的明文,使得聲明能夠被:數(shù)字簽名、或利用消息認證碼(MAC)保護完整性思灌、加密碰镜。
JWT構(gòu)成
一個JWT實際上就是一個字符串,它由三部分組成习瑰,頭部绪颖、載荷與 簽名依順序用點號(".")鏈接而成:header,payload甜奄,signature
Header
頭部(Header)里面說明類型和使用的算法柠横,比如:
{
"alg": "HS256",
"typ": "JWT"
}
說明是JWT(JSON web token)類型,使用了HMAC SHA 算法课兄。然后將頭部進行base64加密(該加密是可以對稱解密的),構(gòu)成了第一部分.
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
Payload
載荷(Payload)載荷就是存放有效信息的地方,含三個部分:
1牍氛、標(biāo)準(zhǔn)中注冊的聲明
2、公共的聲明
3烟阐、私有的聲明
標(biāo)準(zhǔn)中注冊的聲明搬俊,建議但不強制使用
iss: jwt簽發(fā)者
sub: jwt所面向的用戶
aud: 接收jwt的一方
exp: jwt的過期時間,這個過期時間必須要大于簽發(fā)時間
nbf: 定義在什么時間之前蜒茄,該jwt都是不可用的
iat: jwt的簽發(fā)時間
jti: jwt的唯一身份標(biāo)識唉擂,主要用來作為一次性token,從而回避重放攻擊公共的聲明
公共的聲明可以添加任何的信息,一般添加用戶的相關(guān)信息或其他業(yè)務(wù)需要的必要信息.但不建議添加敏感信息檀葛,因為該部分在客戶端可解密.私有的聲明
私有聲明是提供者和消費者所共同定義的聲明玩祟,一般不建議存放敏感信息,因為base64是對稱解密的屿聋,意味著該部分信息可以歸類為明文信息空扎。
定義一個payload:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后將其進行base64加密,得到Jwt的第二部分润讥。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
把場景2的操作描述成一個json對象转锈。其中添加了一些其他的信息,幫助今后收到這個JWT的服務(wù)器理解這個JWT楚殿。 當(dāng)然撮慨,你還可以往載荷放非敏感的用戶信息,比如uid
signature
這個部分需要base64加密后的header和base64加密后的payload使用.連接組成的字符串勒魔,然后通過header中聲明的加密方式進行加鹽secret組合加密甫煞,然后就構(gòu)成了jwt的第三部分。
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
var signature = HMACSHA256(encodedString, 'secret');
//TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
注意secret是保存在服務(wù)器端的冠绢,jwt的簽發(fā)生成也是在服務(wù)器端的抚吠,secret就是用來進行jwt的簽發(fā)和jwt的驗證,所以弟胀,它就是你服務(wù)端的私鑰楷力,在任何場景都不應(yīng)該流露出去喊式。一旦客戶端得知這個secret, 那就意味著客戶端是可以自我簽發(fā)jwt了。
將這三部分用.連接成一個完整的字符串,構(gòu)成了最終的jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
應(yīng)用場景
應(yīng)用場景:
1萧朝、瀏覽器將用戶名和密碼以post請求的方式發(fā)送給服務(wù)器岔留。
2蚯瞧、服務(wù)器接受后驗證通過是己,用一個密鑰生成一個JWT。
3颠焦、服務(wù)器將這個生成的JWT返回給瀏覽器何址。
4里逆、瀏覽器存儲JWT并在使用時將JWT包含在authorization header里面,然后發(fā)送請求給服務(wù)器用爪。
5原押、服務(wù)器可以在JWT中提取用戶相關(guān)信息。進行驗證偎血。
6诸衔、服務(wù)器驗證完成后,發(fā)送響應(yīng)結(jié)果給瀏覽器颇玷。
好吹就是無狀態(tài)笨农,在前后端分離的應(yīng)用中,后臺不需要存儲狀態(tài)亚隙,減輕服務(wù)器的壓力磁餐。
整合
這個又是借鑒了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 的認證渣淳?就是說,當(dāng)前端的請求頭中伴箩,包含了token信息入愧,就通過自定義過濾器邏輯走認證過程,認證 通過之后嗤谚,把認證信息交給spring-security上下文棺蛛,然后繼續(xù)走spring-security的流程,就相當(dāng)于如果前端請求頭總有token信息巩步,并且后臺校驗這個token信息是沒有問題的旁赊,就讓spring-security認為這個請求時已經(jīng)通過校驗的,很巧妙椅野。如果沒有包含token终畅,就執(zhí)行 spring-security標(biāo)準(zhǔn)的認證流程籍胯。然后開放一個用于前端請求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)
/** 不創(chuàng)建 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 過濾器笋除,然后才是權(quán)限校驗斜友。
* 這里在 UsernamePasswordAuthenticationFilter過濾器之前 自定義一個過濾器,這樣就可以提前根據(jù)token將authenticate信息
* 維護進speing security上下文垃它,然后在 UsernamePasswordAuthenticationFilter 得到的就已經(jīng)是通過校驗的用戶了鲜屏。
*/
JwtAuthorizationTokenFilter authenticationTokenFilter = new JwtAuthorizationTokenFilter(customUserService(), jwtTokenUtil, tokenHeader);
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
/**
* disable page caching
*
* 下面這行代碼巨玄乎,加了這個之后国拇,前端應(yīng)用就無法正常訪問了(也就是說需要開發(fā)/api/**權(quán)限才能正常 訪問)
*/
// 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");
}
}
這表示當(dāng)你權(quán)限認證失敗的時候洛史,執(zhí)行 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 {
/**
* 通過調(diào)用 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);
}
/**
* 根據(jù)用戶名從數(shù)據(jù)庫獲取用戶信息捆毫,然后生成 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);
}
}
這個是關(guān)鍵的過濾器
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已經(jīng)失效了", e);
}
} else {
logger.warn("請求頭中未發(fā)現(xiàn) Token, 將執(zhí)行Spring Security正常的驗證流程");
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
logger.debug("security context was null, so authorizating user");
// 也可以將用戶信息保存在token中,這時候就可以不用查數(shù)據(jù)庫
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("用戶 '{}' 授權(quán)成功, 賦值給 SecurityContextHolder 上下文", username);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
}