spring boot(学习笔记第十三课)
- 传统后端开发模式和前后端分离模式的不同,Spring Security的logout,invalidateHttpSession不好用,bug?
学习内容:
- 传统后端开发模式 vs 前后端分离模式
- Spring Security的logout功能
- invalidateHttpSession不好用,bug?原来还是功力不够!
1. 传统后端开发模式 vs 前后端分离模式
- 传统后端开发模式
上面主要练习传统后端开发模式,在这种模式下,页面的渲染都是请求后端,在后端完成页面的渲染。认证的页面都是通过https://localhost:8080/loginPage进行用户名和密码的form填写,之后重定向到需要认证的资源的页面。
正如[spring boot(学习笔记第十二课)](https://blog.csdn.net/s
ealaugh1980/article/details/140224760)的练习的那样,在传统后端开发模式,需要配置各种页面..formLogin(form -> form.loginPage("/loginPage") .loginProcessingUrl("/doLogin")//这里的url不用使用controller进行相应,spring security自动处理 .usernameParameter("uname")//页面上form的用户名 .passwordParameter("passwd") .defaultSuccessUrl("/index")//默认的认证之后的页面 .failureForwardUrl("/loginPasswordError"))//默认的密码失败之后的页面 .exceptionHandling(exceptionHandling -> exceptionHandling.accessDeniedHandler(new CustomizeAccessDeniedHandler())) - 前后端分离开发模式
现在web application的已经过渡到了前后端分离开发模式,而spring boot security也兼容这种模式。

接下来通过使用postman,模拟下前后端分离模式的spring security开发和使用场景。- 指定认证成功和失败的
handler
注意,这里一定要去掉.loginPage("/loginPage").formLogin(form -> form.loginProcessingUrl("/loginProcess")//这里对于前后端分离,提供的非页面访问url .usernameParameter("uname") .passwordParameter("passwd") .successHandler(new SuccessHandler()) .failureHandler(new FailureHandler())) - 定义认证成功和失败的
handler//success handler private static class SuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess( HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication ) throws IOException { Object principal = authentication.getPrincipal(); httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter printWriter = httpServletResponse.getWriter(); httpServletResponse.setStatus(200); Map<String, Object> map = new HashMap<>(); map.put("status", 200); map.put("msg", principal); ObjectMapper om = new ObjectMapper(); printWriter.write(om.writeValueAsString(map)); printWriter.flush(); printWriter.close(); } } //failure handler private static class FailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure( HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException authenticationException ) throws IOException { httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter printWriter = httpServletResponse.getWriter(); httpServletResponse.setStatus(401); Map<String, Object> map = new HashMap<>(); map.put("status", 401); if (authenticationException instanceof LockedException) { map.put("msg", "账户被锁定,登陆失败"); } else if (authenticationException instanceof BadCredentialsException) { map.put("msg", "账户输入错误,登陆失败"); } else { map.put("msg", authenticationException.toString()); } ObjectMapper om = new ObjectMapper(); printWriter.write(om.writeValueAsString(map)); printWriter.flush(); printWriter.close(); } - 一定要将
/loginProcess的permitAll打开。注意,这里的习惯是将认证相关的url都定义成login开头的,并且一起进行/login*的permitAll设定@Bean SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { httpSecurity.authorizeHttpRequests(auth -> auth.requestMatchers("/login*") .permitAll() - 使用
postman进行认证测试。pattern-1正确的密码和用户名
这里使用http://localhost:8080/loginProcess?uname=finlay_user&passwd=123456进行访问。注意,一定要是用post,不能使用get。
这里看到SuccessHandler

pattern-2错误的密码和用户名

- 认证成功,但是访问资源权限不够,需要设置
exceptionHandling。- 设置
exceptionHandling.accessDeniedHandler
.exceptionHandling(exceptionHandling -> exceptionHandling.accessDeniedHandler(new CustomizeAccessDeniedHandler()))- 定义
exceptionHandler
注意,在上一课传统后端开发模式的时候,定义的是redirect到画面,但是前后端分离模式,定义JSON返回值- 传统后端开发模式
// 传统后端开发模式 private static class CustomizeAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { response.sendRedirect("/loginNoPermissionError"); } }- 传统前后端分离开发模式(
JSON返回)
// 传统前后端开发模式 private static class CustomizeAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { response.sendRedirect("/loginNoPermissionError"); } }- 访问
/loginProcess,使用finlay_user(ROLE==user)进行登录

- 访问
/db/hello,这里需要ROLE==DBA)进行登录,但是目前的httpSession不满足条件。
- 设置
- 指定认证成功和失败的
2. Spring Security的logout功能
这里httpSession的如果需要logout,这里练习如何进行logout动作。
传统后端开发模式如何开发logout
注意,这里传统后端开发模式需要将successHandler,failureHandler和logoutSuccessHandler都注释掉,否则,这个的对应的url设置都会无效.formLogin(form -> form.loginProcessingUrl("/loginProcess")//这里对于前后端分离,提供的非页面访问url .usernameParameter("uname") .passwordParameter("passwd") .loginPage("/loginPage") .failureForwardUrl("/loginPasswordError") .successForwardUrl("/index")) // .successHandler(new SuccessHandler()) // .failureHandler(new FailureHandler())) .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutUrl("/logout") .clearAuthentication(true) .invalidateHttpSession(true) .logoutSuccessUrl("/loginPage")) // .logoutSuccessHandler(new MyLogoutHandler())) .exceptionHandling(exceptionHandling -> exceptionHandling.accessDeniedHandler(new CustomizeAccessDeniedHandler())) .csrf(csrf -> csrf.disable())//csrf跨域访问无效 .sessionManagement(session -> session .maximumSessions(-1) .maxSessionsPreventsLogin(true));- 设置logout处理的
url
.logoutUrl(“/logout”),这里的/logouot不需要进行对应,spring boot security会进行响应处理。 - 对logout进行处理
.logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutUrl("/logout") .clearAuthentication(true) .invalidateHttpSession(true) .logoutSuccessUrl("/loginPage"))clearAuthentication是Spring Security中的一个方法,用于清除当前用户的认证信息,即使当前用户注销登录。在SecurityContextHolder中保存的SecurityContext对象将被清除,这意味着在下一次调用SecurityContextHolder.getContext()时,将不再有认证信息。.invalidateHttpSession(true)是将httpSession删除,彻底进行logout。.logoutSuccessUrl("/loginPage"))调用将重定向到行的页面/logoutPage,这里是使用登录的页面。注意,这里如果调用.logoutSuccessHandler(new MyLogoutHandler())进行设定的话,就是使用前后端分离开发模式,logoutSuccessUrl("/loginPage")即便设置也会无效。
- 设置logout处理页面(
controller) 在页面上表示登录用户的用户名@GetMapping("/logoutPage") public String logoutPage(Model model) { String userName = "anonymous"; Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.isAuthenticated()) { if (authentication.getName() != null) { userName = authentication.getName(); } } model.addAttribute("login_user",userName); return "logout"; } - 设置logout处理页面(
html)<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>logout</title> </head> <body> <div th:text="${login_user}"></div> <form th:action="@{/logout}" method="post"> <button type="submit" class="btn">Logout</button> </form> </body> </html> - 使用
logout功能进行logout

在显示logout按钮的同时,也显示出了Authentication authentication = SecurityContextHolder.getContext().getAuthentication();取出来的login_user名字。 - 点击
logout按钮,成功后返回.logoutSuccessUrl("/loginPage"))
- 设置logout处理的
前后端分离开发模式如何开发logout-
将
.logoutSuccessUrl("/loginPage"))替换成.logoutSuccessHandler(new MyLogoutHandler())).logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutUrl("/logout") .clearAuthentication(true) .invalidateHttpSession(true) // .logoutSuccessUrl("/loginPage")) .logoutSuccessHandler(new MyLogoutHandler())) -
定义
MyLogoutHandler将logout结果包装成JSON格式,传给前端。private static class MyLogoutHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request , HttpServletResponse response , Authentication authentication) throws IOException { HttpSession session = request.getSession(false); if (session != null) { // 使会话失效 session.invalidate(); } response.setContentType("application/json;charset=utf-8"); PrintWriter printWriter = response.getWriter(); response.setStatus(200); Map<String, Object> map = new HashMap<>(); map.put("status", 200); map.put("msg", "logout OK"); ObjectMapper om = new ObjectMapper(); printWriter.write(om.writeValueAsString(map)); printWriter.flush(); printWriter.close(); } } -
如果
logout完毕了,没有有效httpSession,那么访问/db/hello资源的话,怎么让spring security返回JSON,让前端框架接收到呢。这里需要AuthenticationEntryPoint。- 设定
AuthenticationEntryPoint.logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutUrl("/logout") .clearAuthentication(true) .invalidateHttpSession(true) // .logoutSuccessUrl("/loginPage")) .logoutSuccessHandler(new MyLogoutHandler())) .exceptionHandling(exceptionHandling -> exceptionHandling .accessDeniedHandler(new CustomizeAccessDeniedHandler()) .authenticationEntryPoint(new RestAuthenticationEntryPoint())) - 定义
AuthenticationEntryPointprivate static class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); String body = "{\"error\":\"Not Authenticated\"}"; OutputStream out = response.getOutputStream(); out.write(body.getBytes()); out.flush(); } }
- 设定
-
使用
postman模拟前端进行login。
-
模拟前端调用
/logout进行logout。
-
模拟前端调用
/db/hello进行没有httpSession的访问,期待返回authenciationError的JSON应答。

-
3. invalidateHttpSession不好用,bug?原来还是功力不够!
sessionManagement的设定
在之前的设定中,一直设定的是.sessionManagement(session -> session .maximumSessions(1) .maxSessionsPreventsLogin(true));.maximumSessions(-1),这个参数的意思是同一个用户同时登录spring boot security应用的数量,-1代表是没有限制,任意多个。在真正的系统中,一般会设定为1,意味着如果这个用户在另一个终端登录另外一个httpSession,那么当前的httpSession会被挤掉。
那也意味着某一个用户执行,login->logout->login是能够在第二个login能够成功的,因为这里中间的logout已经invalidateHttpSession(true)了,但是试试果真如此吗?sessionManagement的设定maximumSessions(1),之后进行postman测试- 使用
finlay_dba用户进行认证
这里没有问题,认证OK。

- 访问
http://localhost:8080:logout用户进行logout
这里的logout也没有问题,成功。
- 访问
http://localhost:8080/loginProcess用户进行再次login
期待能够正常再次login,但是很遗憾,这里返回exception,Maximum sessions of 1 for this principal exceeded。

- 使用
- 如何解决问题
- 问题在于尽管如下代码,在
logout的时候进行了处理,但是和期待不同
spring boot security不会将httpSession彻底无效化,调用了之后,spring boot security还是认为有httpSession正在登录,并没有过期expired。.logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutUrl("/logout") .clearAuthentication(true) .invalidateHttpSession(true) - 在一个csdn旺枝大师文章中,给出了解决方法。
spring boot security使用SessionRegistry对httpSession进行管理,所以需要这里Autowired出来SessionRegistry的java bean,使用这个java bean在LogoutSuccessHandler里面进行session的expireNow的调用。- 首先配置
SessionRegistry
注意,这里的@Configuration public class SessionRegistryConfig { @Bean public SessionRegistry getSessionRegistry(){ return new SessionRegistryImpl(); } }SessionRegistryImpl是spring boot security的内部类,直接使用,不需要定义。 - 在
SecurityConfig里面直接Autowired@Configuration public class SecurityConfig { @Bean PasswordEncoder passwordEncoder() { return NoOpPasswordEncoder.getInstance(); } @Autowired private SessionRegistry sessionRegistry; - 在
SecurityConfig里面的MyLogoutHandler增加处理,调用expireNow()private static class MyLogoutHandler implements LogoutSuccessHandler { private SecurityConfig securityConfig = null; public MyLogoutHandler(SecurityConfig securityConfig) { this.securityConfig = securityConfig; } @Override public void onLogoutSuccess(HttpServletRequest request , HttpServletResponse response , Authentication authentication) throws IOException { HttpSession session = request.getSession(false); if (session != null) { // 使会话失效 session.invalidate(); } List<Object> o = securityConfig.sessionRegistry.getAllPrincipals(); //退出成功后删除当前用户session for (Object principal : o) { if (principal instanceof User) { final User loggedUser = (User) principal; if (authentication.getName().equals(loggedUser.getUsername())) { List<SessionInformation> sessionsInfo = securityConfig.sessionRegistry.getAllSessions(principal, false); if (null != sessionsInfo && sessionsInfo.size() > 0) { for (SessionInformation sessionInformation : sessionsInfo) { sessionInformation.expireNow(); } } } } } response.setContentType("application/json;charset=utf-8"); PrintWriter printWriter = response.getWriter(); response.setStatus(200); Map<String, Object> map = new HashMap<>(); map.put("status", 200); map.put("msg", "logout OK"); ObjectMapper om = new ObjectMapper(); printWriter.write(om.writeValueAsString(map)); printWriter.flush(); printWriter.close(); } }
- 首先配置
- 进行
login->logout->login的动作验证- 首先
login

- 其次访问
http://localhost:8080/logout
- 最后再次访问
http://localhost:8080/loginProcess
到此为止,完美的动作确认结束!
- 首先
- 问题在于尽管如下代码,在










