原文链接:http://sb33060418.iteye.com/blog/1953515
在开发系统认证授权时,经常会碰到需要控制单个用户重复登录次数或者手动踢掉登录用户的需求。如果使用Spring Security 3.1.x该如何实现呢?
Spring Security中可以使用session management进行会话管理,设置concurrency control控制单个用户并行会话数量,并且可以通过代码将用户的某个会话置为失效状态以达到踢用户下线的效果。
本次实践的前提是已使用spring3+Spring Security 3.1.x实现基础认证授权。
1.简单实现
要实现会话管理,必须先启用HttpSessionEventPublisher监听器。
修改web.xml加入以下配置
Java代码
-
org.springframework.security.web.session.HttpSessionEventPublisher
如果spring security是简单的配置,如
Java代码
- auto-config="true">
- authentication-failure-url="/login/login.jsp" always-use-default-target="true"/>
- ...
且没有使用自定义的entry-point和custom-filter,只要在
Java代码
其中invalid-session-url是配置会话失效转向地址;max-sessions是设置单个用户最大并行会话数;error-if-maximum-exceeded是配置当用户登录数达到最大时是否报错,设置为true时会报错且后登录的会话不能登录,默认为false不报错且将前一会话置为失效。
配置完后使用不同浏览器登录系统,就可以看到同一用户后来的会话不能登录或将已登录会话踢掉。
2.自定义配置
如果spring security的一段
Java代码
- 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,虽然配置内容和缺省一致。配置如下:
Java代码
- auto-config="false">
- ref="myUsernamePasswordAuthenticationFilter" />
- session-authentication-strategy-ref="sessionAuthenticationStrategy"
- invalid-session-url="/login/logoff.jsp"/>
- ...
- ...
- class="com.sunbin.login.security.MyUsernamePasswordAuthenticationFilter">
- ref="sessionAuthenticationStrategy" />
- class="org.springframework.security.web.session.ConcurrentSessionFilter">
- class="org.springframework.security.web.authentication.session.ConcurrentSessionControlStrategy">
- ref="sessionRegistry" />
- class="org.springframework.security.core.session.SessionRegistryImpl" />
如果没有什么问题,配置完成后就可以看到会话管理的效果了。
需要和简单配置一样启用HttpSessionEventPublisher监听器。
3.会话管理
很多人做完第二步以后可能会发现,使用不同浏览器先后登录会话还是不受影响,这是怎么回事呢?是配置的问题还是被我忽悠了?我配置的时候也出现过这个问题,调试时看到确实走到了配置的sessionRegistry里却没有效果,在网上找了很久也没有找到答案,最后还是只能出动老办法:查看源码。
ConcurrentSessionControlStrategy源码部分如下:
Java代码
- 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
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
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方法:
Java代码
- public List
getAllSessions(Object principal, boolean includeExpiredSessions) { - final Set
sessionsUsedByPrincipal = principals.get(principal); - if (sessionsUsedByPrincipal == null) {
- return Collections.emptyList();
- }
- List
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;
- }
SessionRegistryImpl自己维护一个private final ConcurrentMap
- public class User implements UserDetails, Serializable
并实现几个主要方法isAccountNonExpired()、getAuthorities()等,但却忘记重写继承自Object类的equals()和hashCode()方法,导致用户两次登录的信息无法被认为是同一个用户。
查看Spring Security的用户类org.springframework.security.core.userdetails.User源码
Java代码
- /**
- * Returns {@code true} if the supplied object is a {@code User} instance with the
- * same {@code username} value.
- *
- * 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这篇文章。
管理员踢出一个账号的实现参考如下:
Java代码
- @RequestMapping(value = "logout.html")
- public String logout(String sessionId, String sessionRegistryId, String name, HttpServletRequest request, ModelMap model){
- List
- for(int i=0; i
- User userTemp=(User) userList.get(i);
- if(userTemp.getName().equals(name)){
- List
sessionInformationList = sessionRegistry.getAllSessions(userTemp, false); - if (sessionInformationList!=null) {
- for (int j=0; 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";
- }
如果想彻底删除, 需要加上
Java代码
- sessionRegistry.removeSessionInformation(sessionInformationList.get(j).getSessionId());
不需要删除用户,因为SessionRegistryImpl在removeSessionInformation时会自动判断用户是否无会话并删除用户,源码如下
Java代码
- 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());
- }