用Spring Security, JWT, Vue实现一个前后端分离无状态认证Demo

简介

完整代码 https://github.com/PuZhiweizuishuai/SpringSecurity-JWT-Vue-Deom

运行展示

image
image
image

后端

主要展示 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 类,而不需要自己实现。

image

User

这个就是我们要使用到的 User 对象,其中包含了 记住我,验证码等登陆信息,因为 Spring Security 整合 Jwt 本质上就是用自己自定义的登陆过滤器,去替换 Spring Security 原生的登陆过滤器,这样的话,原生的记住我功能就会无法使用,所以我在 User 对象里添加了记住我的信息,用来自己实现这个功能。

JWT 令牌认证工具

首先我们来新建一个 TokenAuthenticationHelper 类,用来处理认证过程中的验证和请求

image

TokenAuthenticationHelper

  1. addAuthentication 方法负责返回登陆成功的信息,使用 HTTP Only 的 Cookie 可以有效防止 XSS 攻击。

  2. 登陆成功后返回用户的权限,用户名,登陆过期时间,可以有效的帮助前端构建合适的用户界面。

  3. getAuthentication 方法负责对用户的其它请求进行验证,如果用户的 JWT 解析正确,则向 Spring Security 返回 usernamePasswordAuthenticationToken 用户名密码验证令牌,告诉 Spring Security 用户所拥有的权限,并放到当前的 Context 中,然后执行过滤链使请求继续执行下去。

至此,我们的基本登陆与验证所需要的方法就写完了

ps:其中的 LoginResultDetails 类和 ResultDetails 请看项目源码,篇幅所限,此处不在赘述。

JWT 过滤器配置

众所周知,Spring Security 是借助一系列的 Servlet Filter 来来实现提供各种安全功能的,所以我们要使用 JWT 就需要自己实现两个和 JWT 有关的过滤器

  1. 一个是用户登录的过滤器,在用户的登录的过滤器中校验用户是否登录成功,如果登录成功,则生成一个 token 返回给客户端,登录失败则给前端一个登录失败的提示。

  2. 第二个过滤器则是当其他请求发送来,校验 token 的过滤器,如果校验成功,就让请求继续执行。

这两个过滤器,我们分别来看,先看第一个:

在项目下新建一个包,名为 filter, 在 filter 下新建一个类名为 JwtLoginFilter,并使其继承 AbstractAuthenticationProcessingFilter 类,这个类是一个基于浏览器的基于 HTTP 的身份验证请求的抽象处理器。

image

JwtLoginFilter

这个类主要有以下几个作用

  1. 自定义 JwtLoginFilter 继承自 AbstractAuthenticationProcessingFilter,并实现其中的三个默认方法,其中的 defaultFilterProcessesUrl 变量就是我们需要设置的登陆路径

  2. attemptAuthentication 方法中,我们从登录参数中提取出用户名密码,然后调用 AuthenticationManager.authenticate()方法去进行自动校验。

  3. 第二步如果校验成功,就会来到 successfulAuthentication 回调中,在 successfulAuthentication 方法中,使用之前已经写好的 addAuthentication 来生成 token,并使用 Http Only 的 cookie 写出到客户端。

  4. 第二步如果校验失败就会来到 unsuccessfulAuthentication 方法中,在这个方法中返回一个错误提示给客户端即可。

ps:其中的 verifyCodeService 与 loginCountService 方法与本文关系不大,其中的代码实现请看源码

唯一需要注意的就是

验证码异常需要继承 AuthenticationException 异常,

image

可以看到这是一个 Spring Security 各种异常的父类,写一个验证码异常类继承 AuthenticationException,然后直接将验证码异常抛出就好。

以下完整代码位于 com.bugaugaoshu.security.service.impl.DigitsVerifyCodeServiceImpl 类下

image

DigitsVerifyCodeServiceImpl

异常代码在  com.bugaugaoshu.security.exception.VerifyFailedException 类下

第二个用户过滤器

[
复制代码

](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>

[
复制代码

](javascript:void(0); "复制代码")

这个就很简单了,将拿到的用户 Token 进行解析,如果正确,就将当前用户加入到 SecurityContext 的上下文中,授予用户权限,否则返回 Token 过期的异常

Spring Security 配置

接下来我们来配置 Spring Security,代码如下:

[
复制代码

](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>

[
复制代码

](javascript:void(0); "复制代码")

以上代码的注释很详细,我就不多说了,重点说一下两个地方一个是 csrf 的问题,另一个就是 inMemoryAuthentication 在内存中写入用户的部分。

首先说 csrf 的问题:我看了看网上有很多 Spring Security 的教程,都会将 .csrf()设置为 .disable() ,这种设置虽然方便,但是不够安全,忽略了使用安全框架的初衷所以为了安全起见,我还是开启了这个功能,顺便学习一下如何使用 XSRF-TOKEN

因为这个项目是一个 Demo,不涉及数据库部分,所以我选择了在内存中直接写入用户,网上的向内存中写入用户如上代码注释部分,这样写虽然简单,但是有一些问题,在打个断点我们就能知道种方式调用的是 Spring Security 的是 ProviderManager 这个方法,这种方法不方便我们抛出入用户名不存在或者其异常,它都会抛出 Bad Credentials 异常,不会提示其它错误,如下图所示。

image
image
image

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 没必要,我就放弃了。

另外据说安全信息暴露的越少越好,所以暂时就先这样吧。(算是给自己找个理由)

用户查找服务

image

CustomUserDetailsService

这部分就比较简单了,唯一的注意点我在注释中已经写的很清楚了,当然你要是使用连接数据库的话,这个问题就不存在了。

UserDetailsService 这个接口就是 Spring Security 为其它的数据访问策略做支持的。

至此,一个基本的 Spring Security + JWT 登陆的后端就完成了,你可以写几个 controller 然后用 postman 测试功能了。

其它部分的代码因为比较简单,你可以参照源码自行实现你需要的功能。

前端搭建

创建 Vue 项目的方式网上有很多,此处也不再赘述,我只说一点,过去 Vue 项目创建完成后,在项目目录下会生成一个 config 文件夹,用来存放 vue 的配置,但现在默认创建的项目是不会生成这个文件夹的,需要你手动在项目根目录下创建 vue.config.js 作为配置文件。

此处请参考:Vue CLI 官方文档,配置参考部分

附:使用 Vue CIL 创建 Vue 项目

依赖包

前后端数据传递我使用了更为简单的 fetch api, 当然你也可以选择兼容性更加好的 axios

Ui 为 ElementUI

为了获取 XSRF-TOKEN,还需要 VueCookies

最后为了在项目的首页展示介绍,我还引入了 mavonEditor,一个基于 vue 的 Markdown 插件

引入以上包之后,你与要修改 src 目录下的 main.js 文件如下。

[
复制代码

](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.axios = axios // 使用 vue cookie Vue.use(VueCookies) Vue.config.productionTip = false // 使用 ElementUI 组件 Vue.use(ElementUI) // markdown 解析编辑工具 Vue.use(mavonEditor) // 后台服务地址 Vue.prototype.SERVER_API_URL = "http://127.0.0.1:8088/api"; new Vue({ router, store, render: h => h(App) }).mount('#app')</pre>

[
复制代码

](javascript:void(0); "复制代码")

前端跨域配置

在创建 vue.config.js 完成后,你需要在里面输入以下内容,用来完成 Vue 的跨域配置

[
复制代码

](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>

[
复制代码

](javascript:void(0); "复制代码")

一些注意事项

页面设计这些没有什么可写的了,需要注意的一点就是在对后端服务器进行 POST,DELETE,PUT 等操作时,请在请求头中带上 "X-XSRF-TOKEN": this.$cookies.get('XSRF-TOKEN'),如果不带,那么哪怕你登陆了,后台也会返回 403 异常的。

credentials: "include" 这句也不能少,这是携带 Cookie 所必须的语句。如果不加这一句,等于没有携带 Cookie,也就等于没有登陆了。

举个例子:

[
复制代码

](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.cookies.get('XSRF-TOKEN') }, method: "DELETE", credentials: "include" }).then(response => response.json()) .then(json => { if (json.status === 200) { this.systemDataList.splice(data.id, 1); this.message({
message: '删除成功',
type: 'success' });
} else {
window.console.log(json); this.$message.error(json.message);
}
});
},</pre>

[
复制代码

](javascript:void(0); "复制代码")

暂时就先写这些吧,如果你有什么问题或者好的建议,欢迎在评论区提出。

参考文档

Spring Security Reference

Vue.js

依赖工具

mavonEditor

element ui

原文链接:https://www.cnblogs.com/puzhiwei/p/11989946.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容