目录
Spring Security前后端分离最佳实践(登录+JWT)
现在前后端分离的Web应用越来越流行,后端提供一系列API,前端通过调用这些API完成系统的各个需求的呈现。那么这篇文章主要讲一讲在前后端分离的开发场景中,后端使用Spring Security如何实现登录以及API请求时的身份和权限校验。
Spring Security的核心就是各种Filter的运用,我们需要设计两个主要的Filter
LoginAuthenticationFilter
- 限定只支持
POST
的登录请求 - 用于处理登录逻辑
- 限定只支持
JwtTokenAuthenticationFilter
- 支持忽略不需要校验的path请求
- 从请求头获取JWT进行校验
开发环境
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.3</version>
</parent>
jar包依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
Spring Security配置
继承WebSecurityConfigurerAdapter
为了便于大家理解,我尽量让演示代码精简。定义一个类继承WebSecurityConfigurerAdapter
,完整代码如下:
@Component
public class DemoWen3Security extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 需要忽略的请求,不校验,直接放行
.requestMatchers(new AntPathRequestMatcher("/**/test")).permitAll()
.anyRequest().authenticated();
// 登录过滤器
http.addFilterBefore(new LoginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// token校验过滤器
http.addFilterBefore(new JwtTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 禁用表单提交
http.formLogin().disable();
// 禁用注销
http.logout().disable();
// 禁用csrf功能,方便使用curl或postman测试
http.csrf().disable();
}
}
- 这个配置项还有非常丰富的功能,比如未登录状态下请求了受保护的资源的处理逻辑
- 还有权限不足时的处理逻辑
- 示例代码没有把所有的配置项逐一演示,有兴趣的小伙伴欢迎继续沟通交流
登录过滤器LoginAuthenticationFilter
代码中已经有关键注释对逻辑进行说明,完成代码如下:
public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public LoginAuthenticationFilter() {
// 根据需要自定义处理登录请求的Action
setFilterProcessesUrl("/user/login");
// 校验用户名密码
setAuthenticationManager(new AuthenticationManager() {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken)authentication;
if("xxx".equals(token.getPrincipal()) &&
"xxx".equals(token.getCredentials())) {
return new TestingAuthenticationToken("xxx", "xxx");
}
throw new BadCredentialsException("用户名密码错误");
}
});
// 登录成功处理逻辑
setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.getWriter().write("login successfully, token:" + UUID.randomUUID().toString());
}
});
// 登录失败处理逻辑
setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.getWriter().write("login fail:" + exception.getMessage());
}
});
}
}
- Spring Security提供了非常丰富的功能,校验用户名密码有很多种实现方式,示例代码只是其中的一种
- 登录成功和失败可以根据需要返回自定义JSON数据
token校验过滤器JwtTokenAuthenticationFilter
代码中已经有关键注释对逻辑进行说明,完成代码如下:
public class JwtTokenAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public JwtTokenAuthenticationFilter() {
// token校验逻辑
setAuthenticationManager(new AuthenticationManager() {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
TestingAuthenticationToken token = (TestingAuthenticationToken)authentication;
String accessToken = (String)token.getCredentials();
// 校验token逻辑,这里演示只是判空
if(StringUtils.isNoneBlank(accessToken)) {
return new TestingAuthenticationToken("xxx", "xxx", AuthorityUtils.createAuthorityList(new String[]{"admin"}));
}
throw new BadCredentialsException("token is empty");
}
});
// 校验失败处理逻辑
setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("401 token is invalid:" + exception.getMessage());
}
});
}
// 获取token并触发校验
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 从请求头获取token
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
if(StringUtils.isBlank(token)) {
throw new BadCredentialsException("token is empty");
}
TestingAuthenticationToken testingAuthenticationToken = new TestingAuthenticationToken(token, token);
return this.getAuthenticationManager().authenticate(testingAuthenticationToken);
}
// 判断当前请求是否需要进行token校验
@Override
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
// 不需要校验的请求直接忽略放行
if(new AntPathRequestMatcher("/**/test").matches(request)) {
return false;
}
// 如果已经校验通过,不重复校验
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(null!=authentication && authentication.isAuthenticated()) {
return false;
}
return true;
}
// 校验通过后的处理逻辑
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 把身份数据放入上下文,后续代码可以随时通过静态方法获取SecurityContextHolder.getContext().getAuthentication()
SecurityContextHolder.getContext().setAuthentication(authResult);
// 继续往下进入controller或其它被访问的资源
chain.doFilter(request, response);
}
}
- Spring Security对token的校验也提供了多种校验方式
- 示例代码只是展示了一种最简单直接的方式
main()函数启动类
@SpringBootApplication
public class DemoSpringSecurityApplication {
public static void main(String[] args) {
SpringApplication.run(DemoSpringSecurityApplication.class, args);
}
}
测试验证
- 使用curl命令进行测试
curl -ik -XPOST "http://localhost:8080/user/login?username=xxx&password=xxx"
- 响应结果
$ curl -ik -XPOST "http://localhost:8080/user/login?username=xxx&password=xxx"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=D5240B930D22093D35DFC10A5860CD14; Path=/; HttpOnly
Content-Length: 62
Date: Thu, 31 Mar 2022 15:29:22 GMT
login successfully, token:44a3e490-06ef-4b18-aec5-ad3e7511e3bd
- 登录成功,并返回了token
- 继续请求,带token请求API
curl -ik -XGET -H "Authorization: 44a3e490-06ef-4b18-aec5-ad3e7511e3bd" "http://localhost:8080/getUser"
- 请求成功,响应如下
HTTP/1.1 404
Set-Cookie: JSESSIONID=E701BA24983F2463C4FFD0AC7EEF72E4; Path=/; HttpOnly
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 31 Mar 2022 15:36:47 GMT
{"timestamp":"2022-03-31T15:36:47.504+00:00","status":404,"error":"Not Found","path":"/getUser"}
- 为什么返回
404
呢?因为我们并没有定义/getUser
这个API,因为是演示token的校验机制,所以可以看到请求的校验也是通过了的 - 试试不带token的请求
curl -ik -XGET "http://localhost:8080/getUser"
- 后端的响应如下
HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 35
Date: Thu, 31 Mar 2022 15:41:00 GMT
401 token is invalid:token is empty
- 返回了401,符合我们的预期
- 至此,我们对登录、token的校验的演示就全部完成了
总结
Spring Security功能很强大,对安全认证这一块提供了非常丰富的支持,篇幅有限,只是演示了非常基础的功能。如果大家在进行前后端分离的开发时,后端也想集成Spring Security,相信通过这篇文章的介绍,可以帮助大家解决一定的困惑。
本人对Spring Security的研究非常深入,几乎翻看了底层所有的源码,熟悉Spring Security运行的机制、原理,如果大家在使用Spring Security的过程中遇到什么难题,欢迎进一步沟通、交流。