原文連接:http://sb33060418.iteye.com/blog/1953515javascript
在開發系統認證受權時,常常會碰到須要控制單個用戶重複登陸次數或者手動踢掉登陸用戶的需求。若是使用Spring Security 3.1.x該如何實現呢?
Spring Security中可使用session management進行會話管理,設置concurrency control控制單個用戶並行會話數量,而且能夠經過代碼將用戶的某個會話置爲失效狀態以達到踢用戶下線的效果。
本次實踐的前提是已使用spring3+Spring Security 3.1.x實現基礎認證受權。
1.簡單實現
要實現會話管理,必須先啓用HttpSessionEventPublisher監聽器。
修改web.xml加入如下配置
Java代碼 html
- <listener>
- <listener-class>org.springframework.security.web.session.HttpSessionEventPublisher</listener-class>
- </listener>
若是spring security是簡單的配置,如
Java代碼 java
- <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>就能夠是實現會話管理和並行控制功能,配置以下
Java代碼 web
- <!-- 會話管理 -->
- <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是這樣說的:
Java代碼 spring
- 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代碼 express
- <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源碼部分以下:
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<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方法:
Java代碼 session
- 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都會本身實現用戶信息接口
Java代碼 app
- public class User implements UserDetails, Serializable
並實現幾個主要方法isAccountNonExpired()、getAuthorities()等,但卻忘記重寫繼承自Object類的equals()和hashCode()方法,致使用戶兩次登陸的信息沒法被認爲是同一個用戶。
查看Spring Security的用戶類org.springframework.security.core.userdetails.User源碼
Java代碼 jsp
- /**
- * 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這篇文章。
管理員踢出一個帳號的實現參考以下:
Java代碼
- @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";
- }
若是想完全刪除, 須要加上
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());
- }