简介
完整代码 https://github.com/PuZhiweizuishuai/SpringSecurity-JWT-Vue-Deom
运行展示
![](https://img.haomeiwen.com/i11467662/716809a2e638da62.jpg)
![](https://img.haomeiwen.com/i11467662/35ec98e7778f97e7.jpg)
![](https://img.haomeiwen.com/i11467662/b671b217d7c04f72.jpg)
后端
主要展示 Spring Security 与 JWT 结合使用构建后端 API 接口。
主要功能包括登陆(如何在 Spring Security 中添加验证码登陆),查找,创建,删除并对用户权限进行区分等等。
ps:由于只是 Demo,所以没有调用数据库,以上所说增删改查均在 HashMap 中完成。
前端
展示如何使用 Vue 构建前端后与后端的配合,包括跨域的设置,前端登陆拦截
并实现 POST,GET,DELETE 请求。包括如何在 Vue 中使用后端的 XSRF-TOKEN 防范 CSRF 攻击
技术栈
组件 | 技术 |
---|---|
前端 | Vue.js 2 |
后端 (REST API) | SpringBoot (Java) |
安全 | Token Based (Spring Security, JJWT, CSRF) |
前端脚手架 | vue-cli3, Webpack, NPM |
后端构建 | Maven |
实现细节
后端搭建
基础配置
创建 Spring boot 项目,添加 JJWT 和 Spring Security 的项目依赖,这个非常简单,有很多的教程都有块内容,唯一需要注意的是,如果你使用的 Java 版本是 11,那么你还需要添加以下依赖,使用 Java8 则不需要。
<pre style="margin: 0px; padding: 0px; white-space: pre-wrap; word-wrap: break-word; font-family: "Courier New" !important; font-size: 12px !important;"> <dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency></pre>
要使用 Spring Security 实现对用户的权限控制,首先需要实现一个简单的 User 对象实现 UserDetails 接口,UserDetails 接口负责提供核心用户的信息,如果你只需要用户登陆的账号密码,不需要其它信息,如验证码等,那么你可以直接使用 Spring Security 默认提供的 User 类,而不需要自己实现。
![](https://img.haomeiwen.com/i11467662/f106110631244730.gif)
User
这个就是我们要使用到的 User 对象,其中包含了 记住我,验证码等登陆信息,因为 Spring Security 整合 Jwt 本质上就是用自己自定义的登陆过滤器,去替换 Spring Security 原生的登陆过滤器,这样的话,原生的记住我功能就会无法使用,所以我在 User 对象里添加了记住我的信息,用来自己实现这个功能。
JWT 令牌认证工具
首先我们来新建一个 TokenAuthenticationHelper 类,用来处理认证过程中的验证和请求
![](https://img.haomeiwen.com/i11467662/c049095e1c10f1bf.gif)
TokenAuthenticationHelper
-
addAuthentication 方法负责返回登陆成功的信息,使用 HTTP Only 的 Cookie 可以有效防止 XSS 攻击。
-
登陆成功后返回用户的权限,用户名,登陆过期时间,可以有效的帮助前端构建合适的用户界面。
-
getAuthentication 方法负责对用户的其它请求进行验证,如果用户的 JWT 解析正确,则向 Spring Security 返回 usernamePasswordAuthenticationToken 用户名密码验证令牌,告诉 Spring Security 用户所拥有的权限,并放到当前的 Context 中,然后执行过滤链使请求继续执行下去。
至此,我们的基本登陆与验证所需要的方法就写完了
ps:其中的 LoginResultDetails 类和 ResultDetails 请看项目源码,篇幅所限,此处不在赘述。
JWT 过滤器配置
众所周知,Spring Security 是借助一系列的 Servlet Filter 来来实现提供各种安全功能的,所以我们要使用 JWT 就需要自己实现两个和 JWT 有关的过滤器
-
一个是用户登录的过滤器,在用户的登录的过滤器中校验用户是否登录成功,如果登录成功,则生成一个 token 返回给客户端,登录失败则给前端一个登录失败的提示。
-
第二个过滤器则是当其他请求发送来,校验 token 的过滤器,如果校验成功,就让请求继续执行。
这两个过滤器,我们分别来看,先看第一个:
在项目下新建一个包,名为 filter, 在 filter 下新建一个类名为 JwtLoginFilter,并使其继承 AbstractAuthenticationProcessingFilter 类,这个类是一个基于浏览器的基于 HTTP 的身份验证请求的抽象处理器。
![](https://img.haomeiwen.com/i11467662/2b1e50fade723529.gif)
JwtLoginFilter
这个类主要有以下几个作用
-
自定义 JwtLoginFilter 继承自 AbstractAuthenticationProcessingFilter,并实现其中的三个默认方法,其中的 defaultFilterProcessesUrl 变量就是我们需要设置的登陆路径
-
attemptAuthentication 方法中,我们从登录参数中提取出用户名密码,然后调用 AuthenticationManager.authenticate()方法去进行自动校验。
-
第二步如果校验成功,就会来到 successfulAuthentication 回调中,在 successfulAuthentication 方法中,使用之前已经写好的 addAuthentication 来生成 token,并使用 Http Only 的 cookie 写出到客户端。
-
第二步如果校验失败就会来到 unsuccessfulAuthentication 方法中,在这个方法中返回一个错误提示给客户端即可。
ps:其中的 verifyCodeService 与 loginCountService 方法与本文关系不大,其中的代码实现请看源码
唯一需要注意的就是
验证码异常需要继承 AuthenticationException 异常,
![](https://img.haomeiwen.com/i11467662/a90de65b83b12e8b.png)
可以看到这是一个 Spring Security 各种异常的父类,写一个验证码异常类继承 AuthenticationException,然后直接将验证码异常抛出就好。
以下完整代码位于 com.bugaugaoshu.security.service.impl.DigitsVerifyCodeServiceImpl 类下
![](https://img.haomeiwen.com/i11467662/643df4f7d4f24452.gif)
DigitsVerifyCodeServiceImpl
异常代码在 com.bugaugaoshu.security.exception.VerifyFailedException 类下
第二个用户过滤器
[![](https://img.haomeiwen.com/i11467662/a6c0f57bf7902779.gif)
](javascript:void(0); "复制代码")
<pre style="margin: 0px; padding: 0px; white-space: pre-wrap; word-wrap: break-word; font-family: "Courier New" !important; font-size: 12px !important;">public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { try {
Authentication authentication = TokenAuthenticationHelper.getAuthentication(httpServletRequest); // 对用 token 获取到的用户进行校验
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(httpServletRequest, httpServletResponse);
} catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token expired,登陆已过期");
}
}
}</pre>
![](https://img.haomeiwen.com/i11467662/8244d9c566468e22.gif)
](javascript:void(0); "复制代码")
这个就很简单了,将拿到的用户 Token 进行解析,如果正确,就将当前用户加入到 SecurityContext 的上下文中,授予用户权限,否则返回 Token 过期的异常
Spring Security 配置
接下来我们来配置 Spring Security,代码如下:
[![](https://img.haomeiwen.com/i11467662/02505dd6f6932b9a.gif)
](javascript:void(0); "复制代码")
<pre style="margin: 0px; padding: 0px; white-space: pre-wrap; word-wrap: break-word; font-family: "Courier New" !important; font-size: 12px !important;">@Configuration
@EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { public static String ADMIN = "ROLE_ADMIN"; public static String USER = "ROLE_USER"; private final VerifyCodeService verifyCodeService; private final LoginCountService loginCountService; /** * 开放访问的请求 /
private final static String[] PERMIT_ALL_MAPPING = { "/api/hello", "/api/login", "/api/home", "/api/verifyImage", "/api/image/verify", "/images/*" }; public WebSecurityConfig(VerifyCodeService verifyCodeService, LoginCountService loginCountService) { this.verifyCodeService = verifyCodeService; this.loginCountService = loginCountService;
}
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder();
} /** * 跨域配置 */ @Bean public CorsConfigurationSource corsConfigurationSource() { // 允许跨域访问的 URL
List<String> allowedOriginsUrl = new ArrayList<>();
allowedOriginsUrl.add("http://localhost:8080");
allowedOriginsUrl.add("http://127.0.0.1:8080");
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); // 设置允许跨域访问的 URL
config.setAllowedOrigins(allowedOriginsUrl);
config.addAllowedHeader("");
config.addAllowedMethod("");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config); return source;
}
@Override protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers(PERMIT_ALL_MAPPING)
.permitAll()
.antMatchers("/api/user/**", "/api/data", "/api/logout") // USER 和 ADMIN 都可以访问
.hasAnyAuthority(USER, ADMIN)
.antMatchers("/api/admin/**") // 只有 ADMIN 才可以访问
.hasAnyAuthority(ADMIN)
.anyRequest()
.authenticated()
.and() // 添加过滤器链,前一个参数过滤器, 后一个参数过滤器添加的地方 // 登陆过滤器
.addFilterBefore(new JwtLoginFilter("/api/login", authenticationManager(), verifyCodeService, loginCountService), UsernamePasswordAuthenticationFilter.class) // 请求过滤器
.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) // 开启跨域
.cors()
.and() // 开启 csrf
.csrf() // .disable();
.ignoringAntMatchers(PERMIT_ALL_MAPPING)
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
@Override public void configure(WebSecurity web) throws Exception { super.configure(web);
}
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // 在内存中写入用户数据
auth.
authenticationProvider(daoAuthenticationProvider()); //.inMemoryAuthentication(); // .withUser("user") // .password(passwordEncoder().encode("123456")) // .authorities("ROLE_USER") // .and() // .withUser("admin") // .password(passwordEncoder().encode("123456")) // .authorities("ROLE_ADMIN") // .and() // .withUser("block") // .password(passwordEncoder().encode("123456")) // .authorities("ROLE_USER") // .accountLocked(true);
}
@Bean public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setHideUserNotFoundExceptions(false);
provider.setPasswordEncoder(passwordEncoder());
provider.setUserDetailsService(new CustomUserDetailsService()); return provider;
}</pre>
[
![](https://img.haomeiwen.com/i11467662/8648d87a5e07a0a3.gif)
](javascript:void(0); "复制代码")
以上代码的注释很详细,我就不多说了,重点说一下两个地方一个是 csrf 的问题,另一个就是 inMemoryAuthentication 在内存中写入用户的部分。
首先说 csrf 的问题:我看了看网上有很多 Spring Security 的教程,都会将 .csrf()
设置为 .disable()
,这种设置虽然方便,但是不够安全,忽略了使用安全框架的初衷所以为了安全起见,我还是开启了这个功能,顺便学习一下如何使用 XSRF-TOKEN
因为这个项目是一个 Demo,不涉及数据库部分,所以我选择了在内存中直接写入用户,网上的向内存中写入用户如上代码注释部分,这样写虽然简单,但是有一些问题,在打个断点我们就能知道种方式调用的是 Spring Security 的是 ProviderManager 这个方法,这种方法不方便我们抛出入用户名不存在或者其异常,它都会抛出 Bad Credentials 异常,不会提示其它错误,如下图所示。
![](https://img.haomeiwen.com/i11467662/b241e2f843f052ed.png)
![](https://img.haomeiwen.com/i11467662/cddf771becf9f992.png)
![](https://img.haomeiwen.com/i11467662/7dc38c0d62501ddd.png)
Spring Security 为了安全考虑,会把所有的登陆异常全部归结为 Bad Credentials 异常,所以为了能抛出像用户名不存在的这种异常,如果采用 Spring Security 默认的登陆方式的话,可以采用像GitHub项目Vhr里的这种处理方式,但是因为这个项目使用 Jwt 替换掉了默认的登陆方式,想要实现详细的异常信息抛出就比较复杂了,我找了好久也没找到比较简单且合适的方法。如果你有好的方法,欢迎分享。
最后我的解决方案是使用 Spring Security 的 DaoAuthenticationProvider 这个类来成为认证提供者,这个类实现了 AbstractUserDetailsAuthenticationProvider 这一个抽象的用户详细信息身份验证功能,查看注释我们可以知道 AbstractUserDetailsAuthenticationProvider 提供了 A base AuthenticationProvider that allows subclasses to override and work with UserDetails objects. The class is designed to respond to UsernamePasswordAuthenticationToken authentication requests.(允许子类重写和使用 UserDetails 对象的基本身份验证提供程序。该类旨在响应 UsernamePasswordAuthenticationToken 身份验证请求。)
通过配置自定义的用户查询实现类,我们可以直接在 CustomUserDetailsService 里抛出没有发现用户名的异常,然后再设置 hideUserNotFoundExceptions 为 false 这样就可以区别是密码错误,还是用户名不存在的错误了,
但是这种方式还是有一个问题,不能抛出像账户被锁定这种异常,理论上这种功能可以继承 AbstractUserDetailsAuthenticationProvider 这个抽象类然后自己重写的登陆方法来实现,我看了看好像比较复杂,一个 Demo 没必要,我就放弃了。
另外据说安全信息暴露的越少越好,所以暂时就先这样吧。(算是给自己找个理由)
用户查找服务
![](https://img.haomeiwen.com/i11467662/b4a13c4260e13825.gif)
CustomUserDetailsService
这部分就比较简单了,唯一的注意点我在注释中已经写的很清楚了,当然你要是使用连接数据库的话,这个问题就不存在了。
UserDetailsService 这个接口就是 Spring Security 为其它的数据访问策略做支持的。
至此,一个基本的 Spring Security + JWT 登陆的后端就完成了,你可以写几个 controller 然后用 postman 测试功能了。
其它部分的代码因为比较简单,你可以参照源码自行实现你需要的功能。
前端搭建
创建 Vue 项目的方式网上有很多,此处也不再赘述,我只说一点,过去 Vue 项目创建完成后,在项目目录下会生成一个 config 文件夹,用来存放 vue 的配置,但现在默认创建的项目是不会生成这个文件夹的,需要你手动在项目根目录下创建 vue.config.js 作为配置文件。
此处请参考:Vue CLI 官方文档,配置参考部分
依赖包
前后端数据传递我使用了更为简单的 fetch api, 当然你也可以选择兼容性更加好的 axios
Ui 为 ElementUI
为了获取 XSRF-TOKEN,还需要 VueCookies
最后为了在项目的首页展示介绍,我还引入了 mavonEditor,一个基于 vue 的 Markdown 插件
引入以上包之后,你与要修改 src 目录下的 main.js 文件如下。
[![](https://img.haomeiwen.com/i11467662/a8e944bbcb95aca2.gif)
](javascript:void(0); "复制代码")
<pre style="margin: 0px; padding: 0px; white-space: pre-wrap; word-wrap: break-word; font-family: "Courier New" !important; font-size: 12px !important;">import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' import mavonEditor from 'mavon-editor';
import 'mavon-editor/dist/css/index.css';
import VueCookies from 'vue-cookies' import axios from 'axios'
// 让ajax携带cookie
axios.defaults.withCredentials=true; // 注册 axios 为全局变量
Vue.prototype.mount('#app')</pre>
![](https://img.haomeiwen.com/i11467662/8861427b197f2c3d.gif)
](javascript:void(0); "复制代码")
前端跨域配置
在创建 vue.config.js 完成后,你需要在里面输入以下内容,用来完成 Vue 的跨域配置
[![](https://img.haomeiwen.com/i11467662/41a1cc87d6efe213.gif)
](javascript:void(0); "复制代码")
<pre style="margin: 0px; padding: 0px; white-space: pre-wrap; word-wrap: break-word; font-family: "Courier New" !important; font-size: 12px !important;">module.exports = { // options...
devServer: {
proxy: { '/api': {
target: 'http://127.0.0.1:8088',
changeOrigin: true,
ws: true,
pathRewrite:{ '^/api':'' }
}
}
}
}</pre>
![](https://img.haomeiwen.com/i11467662/013fddda18596333.gif)
](javascript:void(0); "复制代码")
一些注意事项
页面设计这些没有什么可写的了,需要注意的一点就是在对后端服务器进行 POST,DELETE,PUT 等操作时,请在请求头中带上 "X-XSRF-TOKEN": this.$cookies.get('XSRF-TOKEN')
,如果不带,那么哪怕你登陆了,后台也会返回 403 异常的。
credentials: "include"
这句也不能少,这是携带 Cookie 所必须的语句。如果不加这一句,等于没有携带 Cookie,也就等于没有登陆了。
举个例子:
[![](https://img.haomeiwen.com/i11467662/be9a40518a698f3f.gif)
](javascript:void(0); "复制代码")
<pre style="margin: 0px; padding: 0px; white-space: pre-wrap; word-wrap: break-word; font-family: "Courier New" !important; font-size: 12px !important;"> deleteItem(data) {
fetch(this.SERVER_API_URL + "/admin/data/" + data.id, {
headers: { "Content-Type": "application/json; charset=UTF-8", "X-XSRF-TOKEN": this.message({
message: '删除成功',
type: 'success' });
} else {
window.console.log(json); this.$message.error(json.message);
}
});
},</pre>
![](https://img.haomeiwen.com/i11467662/0551c16aefe520f9.gif)
](javascript:void(0); "复制代码")
网友评论