【Spring Security系列】Spring Security会话管理

    技术2022-07-11  109

    只需在两个浏览器中用同一个账号登录就会发现,到目前为止,系统尚未有任何会话并发限制。一个账户能多处同时登录可不是一个好的策略。事实上,Spring Security已经为我们提供了完善的会话管理功能,包括会话固定攻击、会话超时检测以及会话并发控制。

    1.什么是会话

    会话(session)就是无状态的 HTTP 实现用户状态可维持的一种解决方案。HTTP 本身的无状态使得用户在与服务器的交互过程中,每个请求之间都没有关联性。这意味着用户的访问没有身份记录,站点也无法为用户提供个性化的服务。session的诞生解决了这个难题,服务器通过与用户约定每个请求都携带一个id类的信息,从而让不同请求之间有了关联,而id又可以很方便地绑定具体用户,所以我们可以把不同请求归类到同一用户。基于这个方案,为了让用户每个请求都携带同一个id,在不妨碍体验的情况下,cookie是很好的载体。当用户首次访问系统时,系统会为该用户生成一个sessionId,并添加到cookie中。在该用户的会话期内,每个请求都自动携带该cookie,因此系统可以很轻易地识别出这是来自哪个用户的请求。

    尽管cookie非常有用,但有时用户会在浏览器中禁用它,可能是出于安全考虑,也可能是为了保护个人隐私。在这种情况下,基于cookie实现的sessionId自然就无法正常使用了。因此,有些服务还支持用URL重写的方式来实现类似的体验,例如:URL重写原本是为了兼容禁用cookie的浏览器而设计的,但也容易被黑客利用。黑客只需访问一次系统,将系统生成的sessionId提取并拼凑在URL上,然后将该URL发给一些取得信任的用户。只要用户在session有效期内通过此URL进行登录,该sessionId就会绑定到用户的身份,黑客便可以轻松享有同样的会话状态,完全不需要用户名和密码,这就是典型的会话固定攻击。

    http://www.example.com?JSESSIONID=XXX

    URL重写原本是为了兼容禁用cookie的浏览器而设计的,但也容易被黑客利用。黑客只需访问一次系统,将系统生成的sessionId提取并拼凑在URL上,然后将该URL发给一些取得信任的用户。只要用户在session有效期内通过此URL进行登录,该sessionId就会绑定到用户的身份,黑客便可以轻松享有同样的会话状态,完全不需要用户名和密码,这就是典型的会话固定攻击。

    2.防御会话固定攻击

    防御会话固定攻击的方法非常简单,只需在用户登录之后重新生成新的session即可。在继承WebSecurityConfigurerAdapter时,Spring Security已经启用了该配置。

    protected final HttpSecurity getHttp() throws Exception { if (http != null) { return http; } AuthenticationEventPublisher eventPublisher = getAuthenticationEventPublisher(); localConfigureAuthenticationBldr.authenticationEventPublisher(eventPublisher); AuthenticationManager authenticationManager = authenticationManager(); authenticationBuilder.parentAuthenticationManager(authenticationManager); Map<Class<?>, Object> sharedObjects = createSharedObjects(); http = new HttpSecurity(objectPostProcessor, authenticationBuilder, sharedObjects); if (!disableDefaults) { // @formatter:off http .csrf().and() .addFilter(new WebAsyncManagerIntegrationFilter()) .exceptionHandling().and() .headers().and() .sessionManagement().and() .securityContext().and() .requestCache().and() .anonymous().and() .servletApi().and() .apply(new DefaultLoginPageConfigurer<>()).and() .logout(); // @formatter:on ClassLoader classLoader = this.context.getClassLoader(); List<AbstractHttpConfigurer> defaultHttpConfigurers = SpringFactoriesLoader.loadFactories(AbstractHttpConfigurer.class, classLoader); for (AbstractHttpConfigurer configurer : defaultHttpConfigurers) { http.apply(configurer); } } configure(http); return http; }

    sessionManagement是一个会话管理的配置器,其中,防御会话固定攻击的策略有四种:

    none:不做任何变动,登录之后沿用旧的session。 newSession:登录之后创建一个新的session。 migrateSession:登录之后创建一个新的session,并将旧的session中的数据复制过来。 changeSessionId:不创建新的会话,而是使用由Servlet容器提供的会话固定保护。

    默认已经启用migrateSession策略,如有必要,可以做出修改。

    @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/admin/api/**").hasAuthority("ROLE_ADMIN") .antMatchers("/user/api/**").hasRole("USER") .antMatchers("app/api/**").permitAll() .anyRequest().authenticated() .and() .csrf().disable() .formLogin() .and() .sessionManagement() .sessionFixation().none(); }

    在 Spring Security 中,即便没有配置,也大可不必担心会话固定攻击。这是因为Spring Security的HTTP防火墙会帮助我们拦截不合法的URL,当我们试图访问带session的URL时,实际上会被重定向到一个错误页。具体细节可以翻看Spring Security源码,该部分内容在StrictHttpFirewall类中实现。

    3.会话过期

    除防御会话固定攻击外,还可以通过Spring Security配置一些会话过期策略。例如,会话过期时跳转到某个URL。

    .sessionManagement() .invalidSessionUrl("/session/invalid")

    或者完全自定义过期策略。

    public class MyInvalidSessionStrategy implements InvalidSessionStrategy { @Override public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); response.getWriter().write("session无效"); } } @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/admin/api/**").hasAuthority("ROLE_ADMIN") .antMatchers("/user/api/**").hasRole("USER") .antMatchers("app/api/**").permitAll() .anyRequest().authenticated() .and() .csrf().disable() .formLogin() .and() .sessionManagement() // 配置session失效策略 .invalidSessionStrategy(new MyInvalidSessionStrategy()); }

    默认情况下,只要该会话在30分钟内没有活动便会失效,失效后再尝试发起访问,将会显示session无效。

    当然,我们可以手动修改会话的过期时间。

    server: servlet: session: timeout: 60 # 单位秒

    会话的过期时间最少为1分钟,所以即便设置小于60秒也会被修正为1分钟,这属于Spring Boot的配置策略。

    5.会话并发控制

    固定会话攻击和会话过期策略都很简单,在Spring Security中,会话管理最完善的是会话并发控制,但会话并发控制存在一些用法陷阱,应当多加注意,下面来看看详细用法。

    一个最简单的控制会话并发数的配置如下(为了避开陷阱,先启用基于内存的用户配置)。

    @Bean public UserDetailsService userDetailsService() { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); manager.createUser(User.withUsername("user").password("123").roles("USER").build()); manager.createUser(User.withUsername("admin").password("123").roles("ADMIN").build()); return manager; } @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/admin/api/**").hasRole("ADMIN") .antMatchers("/user/api/**").hasRole("USER") .antMatchers("/app/api/**").permitAll() .anyRequest().authenticated() .and() .formLogin() .and() .sessionManagement() // 最大会话数设置为1 .maximumSessions(1); } }

    maximumSessions 用于设置单个用户允许同时在线的最大会话数,如果没有额外配置,那么新登录的会话会踢掉旧的会话。

     

    具体的实现细节在ConcurrentSessionControlAuthenticationStrategy类中可以看到。

    public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy { protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); private final SessionRegistry sessionRegistry; private boolean exceptionIfMaximumExceeded = false; private int maximumSessions = 1; /** * @param sessionRegistry the session registry which should be updated when the * authenticated session is changed. */ public ConcurrentSessionControlAuthenticationStrategy(SessionRegistry sessionRegistry) { Assert.notNull(sessionRegistry, "The sessionRegistry cannot be null"); this.sessionRegistry = sessionRegistry; } /** * In addition to the steps from the superclass, the sessionRegistry will be updated * with the new session information. */ public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) { final List<SessionInformation> sessions = sessionRegistry.getAllSessions( authentication.getPrincipal(), false); int sessionCount = sessions.size(); int allowedSessions = getMaximumSessionsForThisUser(authentication); if (sessionCount < allowedSessions) { // They haven't got too many login sessions running at present return; } if (allowedSessions == -1) { // We permit unlimited logins return; } if (sessionCount == allowedSessions) { HttpSession session = request.getSession(false); if (session != null) { // Only permit it though if this request is associated with one of the // already registered sessions for (SessionInformation si : sessions) { if (si.getSessionId().equals(session.getId())) { return; } } } // If the session is null, a new one will be created by the parent class, // exceeding the allowed number } allowableSessionsExceeded(sessions, allowedSessions, sessionRegistry); } /** * Method intended for use by subclasses to override the maximum number of sessions * that are permitted for a particular authentication. The default implementation * simply returns the <code>maximumSessions</code> value for the bean. * * @param authentication to determine the maximum sessions for * * @return either -1 meaning unlimited, or a positive integer to limit (never zero) */ protected int getMaximumSessionsForThisUser(Authentication authentication) { return maximumSessions; } /** * Allows subclasses to customise behaviour when too many sessions are detected. * * @param sessions either <code>null</code> or all unexpired sessions associated with * the principal * @param allowableSessions the number of concurrent sessions the user is allowed to * have * @param registry an instance of the <code>SessionRegistry</code> for subclass use * */ protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions, SessionRegistry registry) throws SessionAuthenticationException { if (exceptionIfMaximumExceeded || (sessions == null)) { throw new SessionAuthenticationException(messages.getMessage( "ConcurrentSessionControlAuthenticationStrategy.exceededAllowed", new Object[] {allowableSessions}, "Maximum sessions of {0} for this principal exceeded")); } // Determine least recently used sessions, and mark them for invalidation sessions.sort(Comparator.comparing(SessionInformation::getLastRequest)); int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1; List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy); for (SessionInformation session: sessionsToBeExpired) { session.expireNow(); } } /** * Sets the <tt>exceptionIfMaximumExceeded</tt> property, which determines whether the * user should be prevented from opening more sessions than allowed. If set to * <tt>true</tt>, a <tt>SessionAuthenticationException</tt> will be raised which means * the user authenticating will be prevented from authenticating. if set to * <tt>false</tt>, the user that has already authenticated will be forcibly logged * out. * * @param exceptionIfMaximumExceeded defaults to <tt>false</tt>. */ public void setExceptionIfMaximumExceeded(boolean exceptionIfMaximumExceeded) { this.exceptionIfMaximumExceeded = exceptionIfMaximumExceeded; } /** * Sets the <tt>maxSessions</tt> property. The default value is 1. Use -1 for * unlimited sessions. * * @param maximumSessions the maximimum number of permitted sessions a user can have * open simultaneously. */ public void setMaximumSessions(int maximumSessions) { Assert.isTrue( maximumSessions != 0, "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); this.maximumSessions = maximumSessions; } /** * Sets the {@link MessageSource} used for reporting errors back to the user when the * user has exceeded the maximum number of authentications. */ public void setMessageSource(MessageSource messageSource) { Assert.notNull(messageSource, "messageSource cannot be null"); this.messages = new MessageSourceAccessor(messageSource); } }

    如果我们需要在会话数达到最大数时,阻止新会话建立,而不是踢掉旧的会话,则可以像下面这样配置。

    .sessionManagement() // 最大会话数设置为1 .maximumSessions(1) // 阻止新会话登录,默认为false .maxSessionsPreventsLogin(true);

    实际运行之后貌似没有问题,当建立新会话时,确实被阻止了。

    如果此时庆幸Spring Security的会话并发控制如此简单,那未免高兴得过早。怎么回事呢?我们首先尝试将已登录的旧会话注销(通常是访问/logout),理论上应该可以继续登录了,但很遗憾,Spring Security依然提示我们超过了最大会话数。事实上,除非重启服务,否则该用户将很难再次登录系统。 这是因为Spring Security是通过监听session的销毁事件来触发会话信息表相关清理工作的,但我们并没有注册过相关的监听器,导致Spring Security无法正常清理过期或已注销的会话。

    在Servlet中,监听session相关事件的方法是实现HttpSessionListener接口,并在系统中注册该监听器。Spring Security在HttpSessionEventPublisher类中实现HttpSessionListener接口,并转化成Spring的事件机制。

    public class HttpSessionEventPublisher implements HttpSessionListener { // ~ Static fields/initializers // ===================================================================================== private static final String LOGGER_NAME = HttpSessionEventPublisher.class.getName(); // ~ Methods // ======================================================================================================== ApplicationContext getContext(ServletContext servletContext) { return SecurityWebApplicationContextUtils.findRequiredWebApplicationContext(servletContext); } /** * Handles the HttpSessionEvent by publishing a {@link HttpSessionCreatedEvent} to the * application appContext. * * @param event HttpSessionEvent passed in by the container */ public void sessionCreated(HttpSessionEvent event) { HttpSessionCreatedEvent e = new HttpSessionCreatedEvent(event.getSession()); Log log = LogFactory.getLog(LOGGER_NAME); if (log.isDebugEnabled()) { log.debug("Publishing event: " + e); } getContext(event.getSession().getServletContext()).publishEvent(e); } /** * Handles the HttpSessionEvent by publishing a {@link HttpSessionDestroyedEvent} to * the application appContext. * * @param event The HttpSessionEvent pass in by the container */ public void sessionDestroyed(HttpSessionEvent event) { HttpSessionDestroyedEvent e = new HttpSessionDestroyedEvent(event.getSession()); Log log = LogFactory.getLog(LOGGER_NAME); if (log.isDebugEnabled()) { log.debug("Publishing event: " + e); } getContext(event.getSession().getServletContext()).publishEvent(e); } }

    在Spring事件机制中,事件的发布、订阅都交由Spring容器来托管,我们可以很方便地通过注册bean的方式来订阅关心的事件。

    显然,前面的配置无法正常工作是因为缺少了事件源(会话清理订阅的Spring事件,而非原生的HttpSessionEvent),另外,还需要把HttpSessionEventPublisher注册到IoC容器中,才能将Java事件转化成Spring事件。

    @Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); }

    这个问题在新会话剔除旧会话的策略中就已存在,只是没有明显暴露出来,因此只要我们使用会话管理功能,就应该同时配置HttpSessionEventPublisher。

    至此,我们已经基本掌握了如何配置Spring Security的会话管理,但在真实场景中,可能还会碰到一些陷阱。前面提到过,为了避开陷阱,可以先启用基于内存的用户配置。陷阱是什么?为什么基于内存的用户配置就可以避开呢? 我们沿用前面博客自定义数据库结构认证的方式,先实现UserDetails。

    /** * 用户实体 */ public class User implements UserDetails { /** id */ private Integer id; /** 用户名 */ private String username; /** 密码 */ private String password; /** 是否可用 */ private Integer enable; /** 角色名称 */ private String roles; /** 持有的权限对象 */ private List<GrantedAuthority> authorities; @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 enable == 1; } public String getRoles() { return roles; } public void setAuthorities(List<GrantedAuthority> authorities) { this.authorities = authorities; } }

    接着实现UserDetailService

    @Service public class MyUserDetailService implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userMapper.findByUsername(username); if (null == user) { throw new UsernameNotFoundException("用户不存在"); } /* * 将数据形式的roles解析为UserDetails的权限集。 * AuthorityUtils.commaSeparatedStringToAuthorityList是Spring Security提供的, * 该方法用于将逗号隔开的权限集字符串切割成可用权限对象列表。 * 当然也可以自己实现,如用分号隔开,示例:generateAuthorities */ List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()); user.setAuthorities(authorities); return user; } /** * 将以分号分隔的字符串权健集转换成UserDetails的权限集 * @param roles 字符串 * @return List<GrantedAuthority> */ private List<GrantedAuthority> generateAuthorities(String roles) { List<GrantedAuthority> authorities = new ArrayList<>(); String[] rolesArr = roles.split(";"); if (!StringUtils.isEmpty(roles)) { for (String role : rolesArr) { authorities.add(new SimpleGrantedAuthority(role)); } } return authorities; } }

    把基于内存的用户配置去掉,确保仅有一个UserDetailsService对象可注入。不出意外,我们已经将验证的数据源切换到了数据库。登录功能正常,但会话并发控制似乎并不起作用,这是怎么回事呢?要想理解这个问题,还需要深入了解Spring Security关于会话管理的设计方式。

    Spring Security为了实现会话并发控制,采用会话信息表来管理用户的会话状态,具体实现见SessionRegistryImpl类。

    public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> { // ~ Instance fields // ================================================================================================ protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class); /** <principal:Object,SessionIdSet> */ private final ConcurrentMap<Object, Set<String>> principals; /** <sessionId:Object,SessionInformation> */ private final Map<String, SessionInformation> sessionIds; // ~ Methods // ======================================================================================================== public SessionRegistryImpl() { this.principals = new ConcurrentHashMap<>(); this.sessionIds = new ConcurrentHashMap<>(); } public SessionRegistryImpl(ConcurrentMap<Object, Set<String>> principals, Map<String, SessionInformation> sessionIds) { this.principals=principals; this.sessionIds=sessionIds; } public List<Object> getAllPrincipals() { return new ArrayList<>(principals.keySet()); } public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) { final Set<String> sessionsUsedByPrincipal = principals.get(principal); if (sessionsUsedByPrincipal == null) { return Collections.emptyList(); } List<SessionInformation> list = new ArrayList<>( sessionsUsedByPrincipal.size()); for (String sessionId : sessionsUsedByPrincipal) { SessionInformation sessionInformation = getSessionInformation(sessionId); if (sessionInformation == null) { continue; } if (includeExpiredSessions || !sessionInformation.isExpired()) { list.add(sessionInformation); } } return list; } public SessionInformation getSessionInformation(String sessionId) { Assert.hasText(sessionId, "SessionId required as per interface contract"); return sessionIds.get(sessionId); } public void onApplicationEvent(SessionDestroyedEvent event) { String sessionId = event.getId(); removeSessionInformation(sessionId); } public void refreshLastRequest(String sessionId) { Assert.hasText(sessionId, "SessionId required as per interface contract"); SessionInformation info = getSessionInformation(sessionId); if (info != null) { info.refreshLastRequest(); } } public void registerNewSession(String sessionId, Object principal) { Assert.hasText(sessionId, "SessionId required as per interface contract"); Assert.notNull(principal, "Principal required as per interface contract"); if (getSessionInformation(sessionId) != null) { removeSessionInformation(sessionId); } if (logger.isDebugEnabled()) { logger.debug("Registering session " + sessionId + ", for principal " + principal); } sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date())); principals.compute(principal, (key, sessionsUsedByPrincipal) -> { if (sessionsUsedByPrincipal == null) { sessionsUsedByPrincipal = new CopyOnWriteArraySet<>(); } sessionsUsedByPrincipal.add(sessionId); if (logger.isTraceEnabled()) { logger.trace("Sessions used by '" + principal + "' : " + sessionsUsedByPrincipal); } return sessionsUsedByPrincipal; }); } public void removeSessionInformation(String sessionId) { Assert.hasText(sessionId, "SessionId required as per interface contract"); SessionInformation info = getSessionInformation(sessionId); if (info == null) { return; } if (logger.isTraceEnabled()) { logger.debug("Removing session " + sessionId + " from set of registered sessions"); } sessionIds.remove(sessionId); principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> { if (logger.isDebugEnabled()) { logger.debug("Removing session " + sessionId + " from principal's set of registered sessions"); } sessionsUsedByPrincipal.remove(sessionId); if (sessionsUsedByPrincipal.isEmpty()) { // No need to keep object in principals Map anymore if (logger.isDebugEnabled()) { logger.debug("Removing principal " + info.getPrincipal() + " from registry"); } sessionsUsedByPrincipal = null; } if (logger.isTraceEnabled()) { logger.trace("Sessions used by '" + info.getPrincipal() + "' : " + sessionsUsedByPrincipal); } return sessionsUsedByPrincipal; }); } }

    值得一提的是,principals采用了以用户信息为key的设计。我们知道,在hashMap中,以对象为key必须覆写hashCode和equals两个方法(具体原因可以查阅hashMap的设计),但我们实现UserDetails时并没有这么做,这导致同一个用户每次登录注销时计算得到的key都不相同,所以每次登录都会向principals中添加一个用户,而注销时却从来不能有效移除。在这种情况下,不仅达不到会话并发控制的效果,还会引发内存泄露。

    理解了缘由之后,要避开陷阱自然不难,为自定义的用户类覆写hashCode和equals两个方法即可。

    @Override public int hashCode() { return username.hashCode(); } @Override public boolean equals(Object obj) { return obj instanceof User && username.equals(((User) obj).username); }

    为什么基于内存的用户配置不会触发陷阱,主要也是基于这一点。因为在几乎所有我们可以见到的会话管理配置示例中,包括官方示例,它们通常是可以正常工作的,示例程序很少会提供相对复杂的自定义UserDetails方案,大多使用简单的基于内存的用户配置,这些都沿用了Spring Security内部实现的UserDetails,自然不会出现问题。

    public class User implements UserDetails, CredentialsContainer { private final String username; /** * Returns {@code true} if the supplied object is a {@code User} instance with the * same {@code username} value. * <p> * In other words, the objects are equal if they have the same username, representing * the same principal. */ @Override public boolean equals(Object rhs) { if (rhs instanceof User) { return username.equals(((User) rhs).username); } return false; } /** * Returns the hashcode of the {@code username}. */ @Override public int hashCode() { return username.hashCode(); } }

     

    Processed: 0.008, SQL: 9