超实用,Spring Security+JWT+Vue实现一个前后端分离无状态认证Demo
作者: 陕西颜值扛把子
https://zhuanlan.zhihu.com/p/95560389
精彩推荐
完整代码:
https://github.com/PuZhiweizuishuai/SpringSecurity-JWT-Vue-Deom
运行展示
后端
主要展示 Spring Security 与 JWT 结合使用构建后端 API 接口。
主要功能包括登陆(如何在 Spring Security 中添加验证码登陆),查找,创建,删除并对用户权限进行区分等等。
ps:由于只是 Demo,所以没有调用数据库,以上所说增删改查均在 HashMap 中完成。
前端
展示如何使用 Vue 构建前端后与后端的配合,包括跨域的设置,前端登陆拦截
并实现 POST,GET,DELETE 请求。包括如何在 Vue 中使用后端的 XSRF-TOKEN 防范 CSRF 攻击
技术栈
创建 Spring boot 项目,添加 JJWT 和 Spring Security 的项目依赖,这个非常简单,有很多的教程都有块内容,唯一需要注意的是,如果你使用的 Java 版本是 11,那么你还需要添加以下依赖,使用 Java8 则不需要。
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
要使用 Spring Security 实现对用户的权限控制,首先需要实现一个简单的 User 对象实现 UserDetails 接口,UserDetails 接口负责提供核心用户的信息,如果你只需要用户登陆的账号密码,不需要其它信息,如验证码等,那么你可以直接使用 Spring Security 默认提供的 User 类,而不需要自己实现。
public class User implements UserDetails {
private String username;
private String password;
private Boolean rememberMe;
private String verifyCode;
private String power;
private Long expirationTime;
private List<GrantedAuthority> authorities;
/**
* 省略其它的 get set 方法
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
User
这个就是我们要使用到的 User 对象,其中包含了 记住我,验证码等登陆信息,因为 Spring Security 整合 Jwt 本质上就是用自己自定义的登陆过滤器,去替换 Spring Security 原生的登陆过滤器,这样的话,原生的记住我功能就会无法使用,所以我在 User 对象里添加了记住我的信息,用来自己实现这个功能。
JWT 令牌认证工具
首先我们来新建一个 TokenAuthenticationHelper 类,用来处理认证过程中的验证和请求
public class TokenAuthenticationHelper {
/**
* 未设置记住我时 token 过期时间
* */
private static final long EXPIRATION_TIME = 7200000;
/**
* 记住我时 cookie token 过期时间
* */
private static final int COOKIE_EXPIRATION_TIME = 1296000;
private static final String SECRET_KEY = "ThisIsASpringSecurityDemo";
public static final String COOKIE_TOKEN = "COOKIE-TOKEN";
public static final String XSRF = "XSRF-TOKEN";
/**
* 设置登陆成功后令牌返回
* */
public static void addAuthentication(HttpServletRequest request, HttpServletResponse response, Authentication authResult) throws IOException {
// 获取用户登陆角色
Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
// 遍历用户角色
StringBuffer stringBuffer = new StringBuffer();
authorities.forEach(authority -> {
stringBuffer.append(authority.getAuthority()).append(",");
});
long expirationTime = EXPIRATION_TIME;
int cookExpirationTime = -1;
// 处理登陆附加信息
LoginDetails loginDetails = (LoginDetails) authResult.getDetails();
if (loginDetails.getRememberMe() != null && loginDetails.getRememberMe()) {
expirationTime = COOKIE_EXPIRATION_TIME * 1000;
cookExpirationTime = COOKIE_EXPIRATION_TIME;
}
String jwt = Jwts.builder()
// Subject 设置用户名
.setSubject(authResult.getName())
// 设置用户权限
.claim("authorities", stringBuffer)
// 过期时间
.setExpiration(new Date(System.currentTimeMillis() + expirationTime))
// 签名算法
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
.compact();
Cookie cookie = new Cookie(COOKIE_TOKEN, jwt);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(cookExpirationTime);
response.addCookie(cookie);
// 向前端写入数据
LoginResultDetails loginResultDetails = new LoginResultDetails();
ResultDetails resultDetails = new ResultDetails();
resultDetails.setStatus(HttpStatus.OK.value());
resultDetails.setMessage("登陆成功!");
resultDetails.setSuccess(true);
resultDetails.setTimestamp(LocalDateTime.now());
User user = new User();
user.setUsername(authResult.getName());
user.setPower(stringBuffer.toString());
user.setExpirationTime(System.currentTimeMillis() + expirationTime);
loginResultDetails.setResultDetails(resultDetails);
loginResultDetails.setUser(user);
loginResultDetails.setStatus(200);
response.setContentType("application/json; charset=UTF-8");
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(loginResultDetails));
out.flush();
out.close();
}
/**
* 对请求的验证
* */
public static Authentication getAuthentication(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, COOKIE_TOKEN);
String token = cookie != null ? cookie.getValue() : null;
if (token != null) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
// 获取用户权限
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("authorities").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
String userName = claims.getSubject();
if (userName != null) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userName, null, authorities);
usernamePasswordAuthenticationToken.setDetails(claims);
return usernamePasswordAuthenticationToken;
}
return null;
}
return null;
}
}
TokenAuthenticationHelper
-
addAuthentication 方法负责返回登陆成功的信息,使用 HTTP Only 的 Cookie 可以有效防止 XSS 攻击。 -
登陆成功后返回用户的权限,用户名,登陆过期时间,可以有效的帮助前端构建合适的用户界面。 -
getAuthentication 方法负责对用户的其它请求进行验证,如果用户的 JWT 解析正确,则向 Spring Security 返回 usernamePasswordAuthenticationToken 用户名密码验证令牌,告诉 Spring Security 用户所拥有的权限,并放到当前的 Context 中,然后执行过滤链使请求继续执行下去。
JWT 过滤器配置
-
一个是用户登录的过滤器,在用户的登录的过滤器中校验用户是否登录成功,如果登录成功,则生成一个 token 返回给客户端,登录失败则给前端一个登录失败的提示。 -
第二个过滤器则是当其他请求发送来,校验 token 的过滤器,如果校验成功,就让请求继续执行。
public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {
private final VerifyCodeService verifyCodeService;
private final LoginCountService loginCountService;
/**
* @param defaultFilterProcessesUrl 配置要过滤的地址,即登陆地址
* @param authenticationManager 认证管理器,校验身份时会用到
* @param loginCountService */
public JwtLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager,
VerifyCodeService verifyCodeService, LoginCountService loginCountService) {
super(new AntPathRequestMatcher(defaultFilterProcessesUrl));
this.loginCountService = loginCountService;
// 为 AbstractAuthenticationProcessingFilter 中的属性赋值
setAuthenticationManager(authenticationManager);
this.verifyCodeService = verifyCodeService;
}
/**
* 提取用户账号密码进行验证
* */
@Override
public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException, ServletException {
// 判断是否要抛出 登陆请求过快的异常
loginCountService.judgeLoginCount(httpServletRequest);
// 获取 User 对象
// readValue 第一个参数 输入流,第二个参数 要转换的对象
User user = new ObjectMapper().readValue(httpServletRequest.getInputStream(), User.class);
// 验证码验证
verifyCodeService.verify(httpServletRequest.getSession().getId(), user.getVerifyCode());
// 对 html 标签进行转义,防止 XSS 攻击
String username = user.getUsername();
username = HtmlUtils.htmlEscape(username);
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
username,
user.getPassword(),
user.getAuthorities()
);
// 添加验证的附加信息
// 包括验证码信息和是否记住我
token.setDetails(new LoginDetails(user.getRememberMe(), user.getVerifyCode()));
// 进行登陆验证
return getAuthenticationManager().authenticate(token);
}
/**
* 登陆成功回调
* */
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
loginCountService.cleanLoginCount(request);
// 登陆成功
TokenAuthenticationHelper.addAuthentication(request, response ,authResult);
}
/**
* 登陆失败回调
* */
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
// 错误请求次数加 1
loginCountService.addLoginCount(request, 1);
// 向前端写入数据
ErrorDetails errorDetails = new ErrorDetails();
errorDetails.setStatus(HttpStatus.UNAUTHORIZED.value());
errorDetails.setMessage("登陆失败!");
errorDetails.setError(failed.getLocalizedMessage());
errorDetails.setTimestamp(LocalDateTime.now());
errorDetails.setPath(request.getServletPath());
response.setContentType("application/json; charset=UTF-8");
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(errorDetails));
out.flush();
out.close();
}
}
-
自定义 JwtLoginFilter 继承自 AbstractAuthenticationProcessingFilter,并实现其中的三个默认方法,其中的 defaultFilterProcessesUrl 变量就是我们需要设置的登陆路径 -
attemptAuthentication 方法中,我们从登录参数中提取出用户名密码,然后调用 AuthenticationManager.authenticate() 方法去进行自动校验。 -
第二步如果校验成功,就会来到 successfulAuthentication 回调中,在 successfulAuthentication 方法中,使用之前已经写好的 addAuthentication 来生成 token,并使用 Http Only 的 cookie 写出到客户端。 -
第二步如果校验失败就会来到 unsuccessfulAuthentication 方法中,在这个方法中返回一个错误提示给客户端即可。

@Override
public void verify(String key, String code) {
String lastVerifyCodeWithTimestamp = verifyCodeRepository.find(key);
// 如果没有验证码,则随机生成一个
if (lastVerifyCodeWithTimestamp == null) {
lastVerifyCodeWithTimestamp = appendTimestamp(randomDigitString(verifyCodeUtil.getLen()));
}
String[] lastVerifyCodeAndTimestamp = lastVerifyCodeWithTimestamp.split("#");
String lastVerifyCode = lastVerifyCodeAndTimestamp[0];
long timestamp = Long.parseLong(lastVerifyCodeAndTimestamp[1]);
if (timestamp + VERIFY_CODE_EXPIRE_TIMEOUT < System.currentTimeMillis()) {
throw new VerifyFailedException("验证码已过期!");
} else if (!Objects.equals(code, lastVerifyCode)) {
throw new VerifyFailedException("验证码错误!");
}
}
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,登陆已过期");
}
}
}
Spring Security 配置
@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;
}
.csrf()
设置为 .disable()
, 这种设置虽然方便,但是不够安全,忽略了使用安全框架的初衷所以为了安全起见,我还是开启了这个功能,顺便学习一下如何使用 XSRF-TOKEN


public class CustomUserDetailsService implements UserDetailsService {
private List<UserDetails> userList = new ArrayList<>();
public CustomUserDetailsService() {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
UserDetails user = User.withUsername("user").password(passwordEncoder.encode("123456")).authorities(WebSecurityConfig.USER).build();
UserDetails admin = User.withUsername("admin").password(passwordEncoder.encode("123456")).authorities(WebSecurityConfig.ADMIN).build();
userList.add(user);
userList.add(admin);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
for (UserDetails userDetails : userList) {
if (userDetails.getUsername().equals(username)) {
// 此处我尝试过直接返回 user
// 但是这样的话,只有后台服务启动后第一次登陆会有效
// 推出后第二次登陆会出现 Empty encoded password 的错误,导致无法登陆
// 这样写就不会出现这种问题了
// 因为在第一次验证后,用户的密码会被清除,导致第二次登陆系统拿到的是空密码
// 所以需要new一个对象或将原对象复制一份
// 这个解决方案来自 https://stackoverflow.com/questions/43007763/spring-security-encoded-password-gives-me-bad-credentials/43046195#43046195
return new User(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
}
}
throw new UsernameNotFoundException("用户名不存在,请检查用户名或注册!");
}
}
前端搭建
依赖包
axios
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')
前端跨域配置
module.exports = {
// options...
devServer: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8088',
changeOrigin: true,
ws: true,
pathRewrite:{
'^/api':''
}
}
}
}
}
一些注意事项
"X-XSRF-TOKEN": this.$cookies.get('XSRF-TOKEN')
, 如果不带,那么哪怕你登陆了,后台也会返回 403 异常的。credentials: "include"
这句也不能少,这是携带 Cookie 所必须的语句。如果不加这一句,等于没有携带 Cookie,也就等于没有登陆了。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);
}
});
},
结束
END
我知道你 “在看”
原文始发于微信公众号(Java知音):超实用,Spring Security+JWT+Vue实现一个前后端分离无状态认证Demo