Spring Security中可以使用session management进行会话管理,设置concurrency control控制单个用户并行会话数量,并且可以通过代码将用户的某个会话置为失效状态以达到踢用户下线的效果。
本次实践的前提是已使用spring3+Spring Security 3.1.x实现基础认证授权。
1.简单实现
要实现会话管理,必须先启用HttpSessionEventPublisher监听器。
修改web.xml加入以下配置
<listener> <listener-class>org.springframework.security.web.session.HttpSessionEventPublisher</listener-class> </listener>
如果spring security是简单的配置,如
<http use-expressions="true" access-denied-page="/login/noRight.jsp" auto-config="true"> <form-login login-page="/login/login.jsp" default-target-url="/inde.jsp" authentication-failure-url="/login/login.jsp" always-use-default-target="true"/> ... </http>
且没有使用自定义的entry-point和custom-filter,只要在<http></http>标签中添加<session-management>就可以是实现会话管理和并行控制功能,配置如下
<!-- 会话管理 --> <session-management invalid-session-url="/login/logoff.jsp"> <!-- 并行控制 --> <concurrency-control max-sessions="1" error-if-maximum-exceeded="true"/> </session-management>
其中invalid-session-url是配置会话失效转向地址;max-sessions是设置单个用户最大并行会话数;error-if-maximum-exceeded是配置当用户登录数达到最大时是否报错,设置为true时会报错且后登录的会话不能登录,默认为false不报错且将前一会话置为失效。
配置完后使用不同浏览器登录系统,就可以看到同一用户后来的会话不能登录或将已登录会话踢掉。
2.自定义配置
如果spring security的一段<http/>中使用了自定义过滤器<custom-filter/>(特别是FORM_LOGIN_FILTER),或者配置了AuthenticationEntryPoint,或者使用了自定义的UserDetails、AccessDecisionManager、AbstractSecurityInterceptor、FilterInvocationSecurityMetadataSource、UsernamePasswordAuthenticationFilter等,上面的简单配置可能就不会生效了,Spring Security Reference Documentation里面3.3.3 Session Management是这样说的:
If you are using a customized authentication filter for form-based login, then you have to configure concurrent session control support explicitly. More details can be found in the Session Management chapter.
按照文章第12.3章中说明,auto-config已经失效,就需要自行配置ConcurrentSessionFilter、ConcurrentSessionControlStrategy和SessionRegistry,虽然配置内容和缺省一致。配置如下:
<http use-expressions="true" access-denied-page="/login/noRight.jsp" ... auto-config="false"> <!-- 登录fliter配置 --> <custom-filter position="CONCURRENT_SESSION_FILTER" ref="concurrencyFilter" /> <custom-filter position="FORM_LOGIN_FILTER" ref="myUsernamePasswordAuthenticationFilter" /> <session-management session-authentication-strategy-ref="sessionAuthenticationStrategy" invalid-session-url="/login/logoff.jsp"/> ... </http> ... <beans:bean id="myUsernamePasswordAuthenticationFilter" class="com.sunbin.login.security.MyUsernamePasswordAuthenticationFilter"> <beans:property name="sessionAuthenticationStrategy" ref="sessionAuthenticationStrategy" /> <beans:property name="authenticationManager" ref="authenticationManager" /> </beans:bean> <!-- sessionManagementFilter --> <beans:bean id="concurrencyFilter" class="org.springframework.security.web.session.ConcurrentSessionFilter"> <beans:property name="sessionRegistry" ref="sessionRegistry" /> <beans:property name="expiredUrl" value="/login/logoff.jsp" /> </beans:bean> <beans:bean id="sessionAuthenticationStrategy" class="org.springframework.security.web.authentication.session.ConcurrentSessionControlStrategy"> <beans:constructor-arg name="sessionRegistry" ref="sessionRegistry" /> <beans:property name="maximumSessions" value="1" /> </beans:bean> <beans:bean id="sessionRegistry" class="org.springframework.security.core.session.SessionRegistryImpl" />
如果没有什么问题,配置完成后就可以看到会话管理的效果了。
需要和简单配置一样启用HttpSessionEventPublisher监听器。
3.会话管理
很多人做完第二步以后可能会发现,使用不同浏览器先后登录会话还是不受影响,这是怎么回事呢?是配置的问题还是被我忽悠了?我配置的时候也出现过这个问题,调试时看到确实走到了配置的sessionRegistry里却没有效果,在网上找了很久也没有找到答案,最后还是只能出动老办法:查看源码。
ConcurrentSessionControlStrategy源码部分如下:
public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) { checkAuthenticationAllowed(authentication, request); // Allow the parent to create a new session if necessary super.onAuthentication(authentication, request, response); sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal()); } private void checkAuthenticationAllowed(Authentication authentication, HttpServletRequest request) throws AuthenticationException { 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); } ... protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions, SessionRegistry registry) throws SessionAuthenticationException { if (exceptionIfMaximumExceeded || (sessions == null)) { throw new SessionAuthenticationException(messages.getMessage("ConcurrentSessionControlStrategy.exceededAllowed", new Object[] {Integer.valueOf(allowableSessions)}, "Maximum sessions of {0} for this principal exceeded")); } // Determine least recently used session, and mark it for invalidation SessionInformation leastRecentlyUsed = null; for (SessionInformation session : sessions) { if ((leastRecentlyUsed == null) || session.getLastRequest().before(leastRecentlyUsed.getLastRequest())) { leastRecentlyUsed = session; } } leastRecentlyUsed.expireNow(); }
checkAuthenticationAllowed是在用户认证的时候被onAuthentication调用,该方法首先调用SessionRegistryImpl.getAllSessions(authentication.getPrincipal(), false)获得用户已登录会话。如果已登录会话数小于最大允许会话数,或最大允许会话数为-1(不限制),或相同用户在已登录会话中重新登录(有点绕口,但有时候会有这种用户自己在同一会话中重复登录的情况,不注意就会重复计数),就调用SessionRegistry.registerNewSession注册新会话信息,允许本次会话登录;否则调用
allowableSessionsExceeded方法抛出异常或最老的会话置为失效。
接下来看SessionRegistryImpl类的源码,关键就是getAllSessions方法:
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<SessionInformation>(sessionsUsedByPrincipal.size()); for (String sessionId : sessionsUsedByPrincipal) { SessionInformation sessionInformation = getSessionInformation(sessionId); if (sessionInformation == null) { continue; } if (includeExpiredSessions || !sessionInformation.isExpired()) { list.add(sessionInformation); } } return list; }
SessionRegistryImpl自己维护一个private final ConcurrentMap<Object,Set<String>> principals,并以用户信息principal作为key来保存某一用户所有已登录会话编号。
再次调试代码时发现,principals中明明有该用户principal但principals.get(principal)取到的是null,然后认证成功,又往principals里面put了一个新的principal对象为key。查看debug控制台发现principals中两次登录的principal内容一致,但却无法从map中取得,这说明新登录的principal和旧的不相等。
再查看ConcurrentHashMap.get(Object key)方法源码就能找到问题了。我们知道Map中取值的时候都是要逻辑上相等的,即hash值相等且equals。如果两次登录的principal逻辑上不相等,自然被认为是两个用户,不会受最大会话数限制了。
这里会话管理不生效的原因是在自定义的UserDetails。一般配置Spring Security都会自己实现用户信息接口
public class User implements UserDetails, Serializable
并实现几个主要方法isAccountNonExpired()、getAuthorities()等,但却忘记重写继承自Object类的equals()和hashCode()方法,导致用户两次登录的信息无法被认为是同一个用户。
查看Spring Security的用户类org.springframework.security.core.userdetails.User源码
/** * 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(); }
只要把这两个方法加到自己实现的UserDetails类里面去就可以解决问题了。
4.自己管理会话
以下部分内容参考wei_ya_wen的 http://blog.csdn.net/wei_ya_wen/article/details/8455415这篇文章。
管理员踢出一个账号的实现参考如下:
@RequestMapping(value = "logout.html") public String logout(String sessionId, String sessionRegistryId, String name, HttpServletRequest request, ModelMap model){ List<Object> userList=sessionRegistry.getAllPrincipals(); for(int i=0; i<userList.size(); i++){ User userTemp=(User) userList.get(i); if(userTemp.getName().equals(name)){ List<SessionInformation> sessionInformationList = sessionRegistry.getAllSessions(userTemp, false); if (sessionInformationList!=null) { for (int j=0; j<sessionInformationList.size(); j++) { sessionInformationList.get(j).expireNow(); sessionRegistry.removeSessionInformation(sessionInformationList.get(j).getSessionId()); String remark=userTemp.getName()+"被管理员"+SecurityHolder.getUsername()+"踢出"; loginLogService.logoutLog(userTemp, sessionId, remark); //记录注销日志和减少在线用户1个 logger.info(userTemp.getId()+" "+userTemp.getName()+"用户会话销毁," + remark); } } } } return "auth/onlineUser/onlineUserList.html"; }
如果想彻底删除, 需要加上
sessionRegistry.removeSessionInformation(sessionInformationList.get(j).getSessionId());
不需要删除用户,因为SessionRegistryImpl在removeSessionInformation时会自动判断用户是否无会话并删除用户,源码如下
if (sessionsUsedByPrincipal.isEmpty()) { // No need to keep object in principals Map anymore if (logger.isDebugEnabled()) { logger.debug("Removing principal " + info.getPrincipal() + " from registry"); } principals.remove(info.getPrincipal()); }