• spring security 会话管理


    目录

    一、会话管理(Session)

    1、获取用户信息身份

    2、会话控制

    3、会话超时

    4、会话并发控制

    5、集群 session

    二、RememberMe 实现

    RememberMe 源码分析

    三、退出登录


    一、会话管理(Session)

    用户认证通过后,为了避免用户的每次操作都进行认证可将用户的信息保存在会话中。spring security 提供会话管理,认证通过后将身份信息放入SecurityContextHolder上下文,SecurityContext与当前线程进行绑定,方便获取用户身份。

    1、获取用户信息身份

    1. private String getUsername() {
    2. // 从 SecurityContext 中获取当前登录的用户信息
    3. Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    4. if (!authentication.isAuthenticated()) {
    5. return null;
    6. }
    7. Object principal = authentication.getPrincipal();
    8. String username = null;
    9. if (principal instanceof UserDetails) {
    10. username = ((UserDetails) principal).getUsername();
    11. } else {
    12. username = principal.toString();
    13. }
    14. return username;
    15. }

    2、会话控制

    我们可以通过以下选项准确控制会话何时创建以及Spring Security如何与之交互:

    机制描述
    always如果session不存在总是需要创建
    ifRequired如果需要就创建一个session(默认)登录时
    neverSpring Security 将不会创建session,但是如果应用中其他地方创建了session,那么Spring Security将会使用它
    statelessSpring Security将绝对不会创建session,也不使用session。并且它会暗示不使用 cookie,所以每个请求都需要重新进行身份验证。这种无状态架构适用于REST API 及其无状态认证机制。
    1. @Override
    2. protected void configure(HttpSecurity http) throws Exception {
    3. http.formLogin() //表单提交
    4. .successHandler(new MyAuthenticationSuccessHandler("/main.html"));
    5. http.sessionManagement() // session 策略
    6. .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
    7. http.authorizeRequests()
    8. .antMatchers("/error.html","/main.html").permitAll() // 不需要认证
    9. .anyRequest()
    10. .authenticated() // 认证拦截
    11. .and().csrf().disable(); //关闭csrf防护
    12. }

    默认情况下,Spring Security 会为每个登录成功的用户会新建一个Session,就是ifRequired 。在执行认证过程之前,spring security将运行SecurityContextPersistenceFilter过滤器负责存储安全请求上下文,上下文根据策略进行存储,默认为HttpSessionSecurityContextRepository ,其使用http session 作为存储器。

    3、会话超时

    可以在 sevlet 容器中设置 Session 的超时时间,如下设置 Session 有效期为 600s ;

    spring boot配置文件:

    1. server:
    2. servlet:
    3. session:
    4. timeout: 60s

    注意:session最低60s,参考源码 TomcatServletWebServerFactory#configureSession:

    1. private void configureSession(Context context) {
    2. // 设置超时时间
    3. long sessionTimeout = getSessionTimeoutInMinutes();
    4. context.setSessionTimeout((int) sessionTimeout);
    5. Boolean httpOnly = getSession().getCookie().getHttpOnly();
    6. if (httpOnly != null) {
    7. context.setUseHttpOnly(httpOnly);
    8. }
    9. if (getSession().isPersistent()) {
    10. Manager manager = context.getManager();
    11. if (manager == null) {
    12. manager = new StandardManager();
    13. context.setManager(manager);
    14. }
    15. configurePersistSession(manager);
    16. }
    17. else {
    18. context.addLifecycleListener(new DisablePersistSessionListener());
    19. }
    20. }

    设置超时时间,最小超时时间为 1 分钟

    1. private long getSessionTimeoutInMinutes() {
    2. Duration sessionTimeout = getSession().getTimeout();
    3. if (isZeroOrLess(sessionTimeout)) {
    4. return 0;
    5. }
    6. // 比较取最大值
    7. return Math.max(sessionTimeout.toMinutes(), 1);
    8. }

    session 超时之后,可以通过Spring Security 设置跳转的路径。

    1. http.sessionManagement()
    2. .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
    3. .invalidSessionUrl("/session/invalid");

    对应路径接口的代码

    1. @RestController
    2. @RequestMapping("/session")
    3. public class AdminController {
    4. @GetMapping("/invalid")
    5. @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    6. public String sessionInvalid() {
    7. return "session失效";
    8. }
    9. }

    4、会话并发控制

    用户在这个手机登录后,他又在另一个手机登录相同账户,对于之前登录的账户是否需要被挤兑,或者说在第二次登录时限制它登录,更或者像腾讯视频 VIP 账号一样,最多只能五个人同时登录,第六个人将限制登录。

    • maximumSessions:最大会话数量,设置为1表示一个用户只能有一个会话
    • expiredSessionStrategy:会话过期策略
    1. @Override
    2. protected void configure(HttpSecurity http) throws Exception {
    3. http.formLogin() //表单提交
    4. .successHandler(new MyAuthenticationSuccessHandler("/main.html"));
    5. http.sessionManagement()
    6. .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
    7. .maximumSessions(1) // 只能有一个session 在线, 最大会话数
    8. .expiredSessionStrategy(new MyExpiredSessionStrategy()); // session过期策略
    9. http.authorizeRequests()
    10. .antMatchers("/error.html","/main.html").permitAll() // 不需要认证
    11. .anyRequest()
    12. .authenticated() // 认证拦截
    13. .and().csrf().disable(); //关闭csrf防护
    14. }

    配置 session 失效拒绝策略

    1. import org.springframework.security.web.session.SessionInformationExpiredEvent;
    2. import org.springframework.security.web.session.SessionInformationExpiredStrategy;
    3. import javax.servlet.http.HttpServletResponse;
    4. import java.io.IOException;
    5. public class MyExpiredSessionStrategy implements SessionInformationExpiredStrategy {
    6. @Override
    7. public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException {
    8. HttpServletResponse response = event.getResponse();
    9. response.setContentType("application/json;charset=UTF-8");
    10. response.getWriter().write("您已被挤兑下线!");
    11. }
    12. }

    1. 使用chrome浏览器,先登录,再访问 http://localhost:8080/admin/test

    2. 使用ie浏览器,再登录,再访问 http://localhost:8080/admin/test

    3. 使用chrome浏览器,重新访问 http://localhost:8080/admin/test,会执行expiredSessionStrategy,页面上显示您已被挤兑下线!

    阻止用户第二次登录

    sessionManagement 也可以配置 maxSessionsPreventsLogin:boolean值,当达到maximumSessions 设置的最大会话个数时阻止登录。

    1. http.sessionManagement()
    2. .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
    3. .maximumSessions(1) // 只能有一个session 在线, 最大会话数
    4. .expiredSessionStrategy(new MyExpiredSessionStrategy()) // session过期策略
    5. .maxSessionsPreventsLogin(true); // 阻止 会话超过最大值,防止被踢

    当限制 session 个数为 1 时,同一个账号第二次登陆,将会被阻止

    5、集群 session

    实际场景中一个服务会至少有两台服务器在提供服务,在服务器前面会有一个nginx做负载均衡,用户访问 nginx,nginx 再决定去访问哪一台服务器。当一台服务宕机了之后,另一台服务器也可以继续提供服务,保证服务不中断。此时,用户登录的会话信息就不能再保存到 Web 服务器中,而是保存到一个单独的库(redis、mongodb、mysql等)中,所有服务器都访问同一个库,都从同一个库来获取用户的session信息。

    引入spring session依赖

    1. <dependency>
    2. <groupId>org.springframework.session</groupId>
    3. <artifactId>spring-session-data-redis</artifactId>
    4. </dependency>
    5. <dependency>
    6. <groupId>redis.clients</groupId>
    7. <artifactId>jedis</artifactId>
    8. </dependency>

     修改 application.yaml 配置,spring 就会自动把 session 存入到 redis 当中

    1. spring:
    2. datasource: # 数据库配置
    3. driver-class-name: com.mysql.jdbc.Driver
    4. url: jdbc:mysql://localhost:3306/security?useSSL=false
    5. password: root
    6. username: root
    7. session: # session 配置
    8. store-type: redis
    9. redis: # redis 配置
    10. host: localhost
    11. port: 6379
    12. server:
    13. servlet:
    14. session:
    15. timeout: 60s # session 过期时间

    redis 中存放的 session

     再次访问时,请求头中会带上 session 信息

    session 的自动存储源码

    找到 SessionRepositoryFilter.java 这个过滤器,SessionRepositoryFilter#doFilterInternal 方法源码如下

    1. @Override
    2. protected void doFilterInternal(HttpServletRequest request,
    3. HttpServletResponse response, FilterChain filterChain)
    4. throws ServletException, IOException {
    5. request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
    6. SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
    7. request, response, this.servletContext);
    8. SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
    9. wrappedRequest, response);
    10. try {
    11. filterChain.doFilter(wrappedRequest, wrappedResponse);
    12. }
    13. finally {
    14. // 提交session
    15. wrappedRequest.commitSession();
    16. }
    17. }

    其中 wrappedRequest.commitSession(); 便执行了 session 存储的逻辑

    1. private void commitSession() {
    2. HttpSessionWrapper wrappedSession = getCurrentSession();
    3. if (wrappedSession == null) {
    4. if (isInvalidateClientSession()) {
    5. SessionRepositoryFilter.this.httpSessionIdResolver.expireSession(this,
    6. this.response);
    7. }
    8. }
    9. else {
    10. S session = wrappedSession.getSession();
    11. clearRequestedSessionCache();
    12. // 存储 session
    13. SessionRepositoryFilter.this.sessionRepository.save(session);
    14. String sessionId = session.getId();
    15. if (!isRequestedSessionIdValid()
    16. || !sessionId.equals(getRequestedSessionId())) {
    17. SessionRepositoryFilter.this.httpSessionIdResolver.setSessionId(this,
    18. this.response, sessionId);
    19. }
    20. }
    21. }

    其中 sessionRepository ,就是类中的以下这个属性

    private final SessionRepository<S> sessionRepository;

    SessionRepository 接口的其中有一个实现就是 redis 的

    最终会调用 RedisOperationsSessionRepository#save 进行保存

    1. public void save(RedisOperationsSessionRepository.RedisSession session) {
    2. session.saveDelta();
    3. if (session.isNew()) {
    4. String sessionCreatedKey = this.getSessionCreatedChannel(session.getId());
    5. this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta);
    6. session.setNew(false);
    7. }
    8. }

    安全会话cookie

    我们可以使用 httpOnly 和 secure 标签来保护我们的会话 cookie:

    • httpOnly:如果为true,那么浏览器脚本将无法访问 cookie
    • secure:如果为true,则cookie将仅通过HTTPS连接发送

    spring boot配置文件:

    1. server.servlet.session.cookie.http-only=true
    2. server.servlet.session.cookie.secure=true

    二、RememberMe 实现

    Spring Security 中 Remember Me 为“记住我”功能,用户只需要在登录时添加 remember-me复选框,取值为true。Spring Security 会自动把用户信息存储到数据源中,以后就可以不登录进行访问。

    RememberMe 配置完整版

    1. import com.swadian.userdemo.filter.MyExpiredSessionStrategy;
    2. import com.swadian.userdemo.service.MyUserDetailsService;
    3. import org.springframework.beans.factory.annotation.Autowired;
    4. import org.springframework.context.annotation.Bean;
    5. import org.springframework.context.annotation.Configuration;
    6. import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    7. import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    8. import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    9. import org.springframework.security.config.http.SessionCreationPolicy;
    10. import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    11. import org.springframework.security.crypto.password.PasswordEncoder;
    12. import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
    13. import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
    14. import javax.sql.DataSource;
    15. /**
    16. * @author swadian
    17. */
    18. @Configuration // 标记为注解类
    19. public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    20. @Autowired
    21. private MyUserDetailsService userService;
    22. @Bean
    23. public PasswordEncoder passwordEncoder() {
    24. return new BCryptPasswordEncoder();
    25. }
    26. @Override
    27. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    28. //设置UserDetailsService的实现类
    29. auth.userDetailsService(userService);
    30. }
    31. @Override
    32. protected void configure(HttpSecurity http) throws Exception {
    33. http.formLogin() //表单提交
    34. .loginPage("/login.html") //自定义登录页面
    35. .loginProcessingUrl("/my-user/login");//登录访问路径,必须和表单提交接口一样
    36. // 记住我
    37. http.rememberMe().tokenRepository(persistentTokenRepository())//设置持久化仓库
    38. .tokenValiditySeconds(3600) //超时时间,单位s 默认两周
    39. .userDetailsService(userService); //设置自定义登录逻辑
    40. http.authorizeRequests()
    41. .antMatchers("/login.html", "/error.html", "/main.html").permitAll() // 不需要认证
    42. .anyRequest()
    43. .authenticated() // 认证拦截
    44. .and().csrf().disable(); //关闭csrf防护
    45. }
    46. @Autowired // rememberMe -> 需要引入数据源
    47. public DataSource dataSource;
    48. public PersistentTokenRepository persistentTokenRepository() {
    49. JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
    50. // rememberMe -> 设置数据源
    51. jdbcTokenRepository.setDataSource(dataSource);
    52. return jdbcTokenRepository;
    53. }
    54. }

    创建数据库表

    1. CREATE TABLE persistent_logins (
    2. username VARCHAR ( 64 ) NOT NULL,
    3. series VARCHAR ( 64 ) PRIMARY KEY,
    4. token VARCHAR ( 64 ) NOT NULL,
    5. last_used TIMESTAMP NOT NULL
    6. )

    在客户端登录页面 login.html 中添加 remember-me 的复选框,只要用户勾选了复选框下次就不需要进行登录了。

    1. <!DOCTYPE html>
    2. <html lang="en">
    3. <head>
    4. <meta charset="UTF-8">
    5. <title>Title</title>
    6. </head>
    7. <body>
    8. <form action="/my-user/login" method="post">
    9. 用户名:<input type="text" name="username"/><br/>
    10. 密码: <input type="password"name="password"/><br/>
    11. <input type="checkbox" name="remember-me" value="true"/><br/>
    12. <input type="submit" value="提交"/></form>
    13. </body>
    14. </html>

    成功登陆后,我们可以看到数据库表中多了一行记录

    RememberMe 源码分析

    spring security 很多功能都是基于过滤器实现的,因此我们可以去代码中找 RememberMe 过滤器的代码实现。

    在源码中可以找到这个方法 RememberMeAuthenticationFilter # doFilter 

    1. public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
    2. throws IOException, ServletException {
    3. HttpServletRequest request = (HttpServletRequest) req;
    4. HttpServletResponse response = (HttpServletResponse) res;
    5. if (SecurityContextHolder.getContext().getAuthentication() == null) {
    6. // 自动登陆
    7. Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
    8. response);
    9. if (rememberMeAuth != null) {
    10. // Attempt authenticaton via AuthenticationManager
    11. try {
    12. // 认证逻辑
    13. rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
    14. // Store to SecurityContextHolder
    15. SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
    16. onSuccessfulAuthentication(request, response, rememberMeAuth);
    17. if (logger.isDebugEnabled()) {
    18. logger.debug("SecurityContextHolder populated with remember-me token: '"
    19. + SecurityContextHolder.getContext().getAuthentication()
    20. + "'");
    21. }
    22. // Fire event
    23. if (this.eventPublisher != null) {
    24. eventPublisher
    25. .publishEvent(new InteractiveAuthenticationSuccessEvent(
    26. SecurityContextHolder.getContext()
    27. .getAuthentication(), this.getClass()));
    28. }
    29. if (successHandler != null) {
    30. successHandler.onAuthenticationSuccess(request, response,
    31. rememberMeAuth);
    32. return;
    33. }
    34. }
    35. catch (AuthenticationException authenticationException) {
    36. if (logger.isDebugEnabled()) {
    37. logger.debug(
    38. "SecurityContextHolder not populated with remember-me token, as "
    39. + "AuthenticationManager rejected Authentication returned by RememberMeServices: '"
    40. + rememberMeAuth
    41. + "'; invalidating remember-me token",
    42. authenticationException);
    43. }
    44. rememberMeServices.loginFail(request, response);
    45. onUnsuccessfulAuthentication(request, response,
    46. authenticationException);
    47. }
    48. }
    49. chain.doFilter(request, response);
    50. }
    51. else {
    52. if (logger.isDebugEnabled()) {
    53. logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
    54. + SecurityContextHolder.getContext().getAuthentication() + "'");
    55. }
    56. chain.doFilter(request, response);
    57. }
    58. }

    三、退出登录

    Spring security默认实现了 logout 退出,用户只需要向 Spring Security 项目中发送 /logout 退出请求即可。

    默认的退出 url 为 /logout ,退出成功后跳转到 /login?logout 。进入 LogoutConfigurer.java 可以看到如下配置

    自定义退出逻辑

    如果不希望使用默认值,可以通过下面的方法进行修改。 

    1. http.logout()
    2. .logoutUrl("/logout")
    3. .logoutSuccessUrl("/login.html"); // 退出后跳转到登陆页面

    执行 http://localhost:8080/logout 可以看到退出效果

    退出登录源码

    同样是从过滤器开始,LogoutFilter # doFilter

    1. public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
    2. throws IOException, ServletException {
    3. HttpServletRequest request = (HttpServletRequest) req;
    4. HttpServletResponse response = (HttpServletResponse) res;
    5. if (requiresLogout(request, response)) {
    6. // 1-获取用户信息
    7. Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    8. if (logger.isDebugEnabled()) {
    9. logger.debug("Logging out user '" + auth
    10. + "' and transferring to logout destination");
    11. }
    12. // 2-退出登陆 -> SecurityContextLogoutHandler#logout
    13. this.handler.logout(request, response, auth);
    14. // 3-拓展点,成功退出后的操作
    15. logoutSuccessHandler.onLogoutSuccess(request, response, auth);
    16. return;
    17. }
    18. chain.doFilter(request, response);
    19. }

    SecurityContextLogoutHandler 实现了 LogoutHandler 接口

    SecurityContextLogoutHandler # logout 实现了具体的退出逻辑

    当退出操作出发时,将发生:

    1. 销毁 httpSession 对象
    2. 清除认证状态
    3. 跳转到 /login.html -> 配置了 logoutSuccessUrl 的处理逻辑
    1. public void logout(HttpServletRequest request, HttpServletResponse response,
    2. Authentication authentication) {
    3. Assert.notNull(request, "HttpServletRequest required");
    4. if (invalidateHttpSession) {
    5. HttpSession session = request.getSession(false);
    6. if (session != null) {
    7. logger.debug("Invalidating session: " + session.getId());
    8. // 1-失效 session
    9. session.invalidate();
    10. }
    11. }
    12. if (clearAuthentication) {
    13. // 2-清空用户信息
    14. SecurityContext context = SecurityContextHolder.getContext();
    15. context.setAuthentication(null);
    16. }
    17. // 3-清空Security上下文
    18. SecurityContextHolder.clearContext();
    19. }

    至此,退出登陆分析结束。

  • 相关阅读:
    postgresql 授权
    Android 全栈的进击之路
    3、深入理解synchronized
    【HTML】HTML网页设计----动漫网站设计
    杰理之CMD_SET_BLE_VISIBILITY【篇】
    Java面试题以及答案(六)Jvm
    Sketch for mac v98.2最新版 修复了打开某些文档时导致 Sketch 崩溃的错误
    【漏洞复现】易思智能物流无人值守系统文件上传
    中级C++:AVL树
    自制操作系统日志——第十七天
  • 原文地址:https://blog.csdn.net/swadian2008/article/details/126566676