Spring Security学习笔记之UsernamePasswordAuthenticationFilter, ConcurrentSessionFilter

UsernamePasswordAuthenticationFilter主要用来处理用户登录时的验证操作. 它的一般用法请参考Spring Security学习笔记之整体配置

ConcurrentSessionFilter的作用比较简单, 它会对每一个请求都作判断:
1) 如果session没过期, 就会更新session里的"last update" date/time;
2) 如果session过期, 就会调用logout handlers(一般是LogoutFilter)去销毁session, 然后跳转到expiredUrl;

(注意这里的session是指储存在SessionRegistry里的SessionInformation实例, 不是HttpSession)

ConcurrentSessionFilter的构造函数需要两个参数(第二个可以省略)
sessionRegistry: 一般是SessionRegistryImpl的实例
expiredUrl: session过期后跳转的页面



这里主要介绍一下如何使用这两个过滤器来防止用户重复登录的问题.

UsernamePasswordAuthenticationFilter的父类AbstractAuthenticationProcessingFilter有一个属性sessionStrategy, 就是用它来指定具体的防止重复登录的策略. 它的默认值是NullAuthenticatedSessionStrategy. NullAuthenticatedSessionStrategy只是一个抽象类, 不做任何操作, 源码如下:

public final class NullAuthenticatedSessionStrategy implements SessionAuthenticationStrategy {

    public void onAuthentication(Authentication authentication, HttpServletRequest request,
            HttpServletResponse response) {
    }
}


我们一般会把sessionStrategy绑定到一个CompositeSessionAuthenticationStrategy的实例. CompositeSessionAuthenticationStrategy只是一个代理类, 也不做具体的操作. 具体的操作会交给它的delegateStrategies属性所指定的 所有SessionAuthenticationStrategy实例来操作, 常用的有RegisterSessionAuthenticationStrategy和ConcurrentSessionControlAuthenticationStrategy.


注意, delegateStrategies 是一个集合, 可绑定多个SessionAuthenticationStrategy的实例:

public class CompositeSessionAuthenticationStrategy implements SessionAuthenticationStrategy {
    private final Log logger = LogFactory.getLog(getClass());
    private final List<SessionAuthenticationStrategy> delegateStrategies;
	...
}

绑定的配置如下:

<bean id="loginAuthenticationFilter" class="org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter">  
    ...  
    <property name="sessionAuthenticationStrategy" ref="compositeSessionAuthenticationStrategy"></property>  
</bean>  
  
<bean id="compositeSessionAuthenticationStrategy" class="org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy">  
    <constructor-arg>  
        <list>  
            <bean id="registerSessionAuthenticationStrategy" class="org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy">  
                <constructor-arg ref="sessionRegistry"/>  
            </bean>  
            <bean id="concurrentSessionControlAuthenticationStrategy" class="org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy">  
                <constructor-arg ref="sessionRegistry"/>  
                <property name="maximumSessions" value="1"></property> <!-- 同一个用户最多允许好多少个session -->  
				
		<!-- exceptionIfMaximumExceeded, 当超过最大session数时:
			true: 不允许新session, 保持旧session
			false: 销毁旧session, 新session生效
		-->
                <property name="exceptionIfMaximumExceeded" value="true"></property>
            </bean>  
        </list>  
    </constructor-arg>  
</bean>  
  
<bean id="sessionRegistry" class="org.springframework.security.core.session.SessionRegistryImpl"/> 

可以看到, RegisterSessionAuthenticationStrategy和ConcurrentSessionControlAuthenticationStrategy都需要有一个SessionRegistry的实例来作为构造函数的参数. 一般用SessionRegistryImpl就可以了. 至于SessionRegistryImpl具体做了什么, 后面会介绍.


现在让我们先来看看AbstractAuthenticationProcessingFilter在用户登录时做了什么.

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
		throws IOException, ServletException {

	HttpServletRequest request = (HttpServletRequest) req;
	HttpServletResponse response = (HttpServletResponse) res;

	if (!requiresAuthentication(request, response)) {
		chain.doFilter(request, response);

		return;
	}

	if (logger.isDebugEnabled()) {
		logger.debug("Request is to process authentication");
	}

	Authentication authResult;

	try {
		// 这里调用了子类UsernamePasswordAuthenticationFilter.attemptAuthentication()方法来验证用户是否存在
		authResult = attemptAuthentication(request, response);
		if (authResult == null) {
			// return immediately as subclass has indicated that it hasn't completed authentication
			return;
		}
		
		// 如果用户信息正确, 则继续做下一步验证(如防止重复登录验证)
		sessionStrategy.onAuthentication(authResult, request, response);
	} catch(InternalAuthenticationServiceException failed) {
		logger.error("An internal error occurred while trying to authenticate the user.", failed);
		unsuccessfulAuthentication(request, response, failed);

		return;
	}
	catch (AuthenticationException failed) {
		// Authentication failed
		unsuccessfulAuthentication(request, response, failed);

		return;
	}

	// Authentication success
	if (continueChainBeforeSuccessfulAuthentication) {
		chain.doFilter(request, response);
	}

	successfulAuthentication(request, response, chain, authResult);
}

这里主要看上面的注释部分. 在验证完用户信息后, 它会调用轮流调用上面我们配置的sessionStrategy(即RegisterSessionAuthenticationStrategy和ConcurrentSessionControlAuthenticationStrategy)的onAuthentication()方法来做下一步验证.

先看RegisterSessionAuthenticationStrategy的onAuthentication()方法:

public void onAuthentication(Authentication authentication, HttpServletRequest request,
            HttpServletResponse response) {
        sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal());
    }


它只是简单的调用了实现类SessionRegistryImpl的registerNewSession()方法:

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 (logger.isDebugEnabled()) {
		logger.debug("Registering session " + sessionId +", for principal " + principal);
	}

	if (getSessionInformation(sessionId) != null) {
		removeSessionInformation(sessionId);
	}

	sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));

	Set<String> sessionsUsedByPrincipal = principals.get(principal);

	if (sessionsUsedByPrincipal == null) {
		sessionsUsedByPrincipal = new CopyOnWriteArraySet<String>();
		
		// 把用户信息和session信息作对应关系, 并保存起来
		Set<String> prevSessionsUsedByPrincipal = principals.putIfAbsent(principal, sessionsUsedByPrincipal);
		if (prevSessionsUsedByPrincipal != null) {
			sessionsUsedByPrincipal = prevSessionsUsedByPrincipal;
		}
	}

	sessionsUsedByPrincipal.add(sessionId);

	if (logger.isTraceEnabled()) {
		logger.trace("Sessions used by '" + principal + "' : " + sessionsUsedByPrincipal);
	}
}

它主要的作用是把当前的用户信息和当前的session信息作了对应关系, 然后存在principals属性里. 如果该用户再次登录的话, 那么这个用户信息就会有两个对应的session信息了, 如此类推.

接着看ConcurrentSessionControlAuthenticationStrategy的onAuthentication()方法:

public void onAuthentication(Authentication authentication, HttpServletRequest request,
		HttpServletResponse response) {

	// 找出当前用户所对应的所有session信息
	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);
}

逻辑很简单, 它会找出当前用户所对应的所有session信息, 如果数量大于最大的允许数, 就抛出异常(如果exceptionIfMaximumExceeded=true).


但这里有一个问题, 它是怎样找出当前用户所对应的所有session信息的呢? 上面我们说到, 它把对应信息存在了SessionRegistryImpl的principals属性里. 下面我们看看SessionRegistryImpl的getAllSessions()方法, 通过它来找出用户的所有session信息.

public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
	// 找出用户的所有session信息
	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;
}

principals是一个ConcurrentHashMap的实例, 它的key是我们定义的User类的实例. 如果我们的User类没有重写equals()和hashCode()方法, 那么即使一个人登录了两次, 在他第二次登录的时候, 这里principals.get(principal)只会拿到他第二次登录的session信息, 而不是第一次和第二次的session信息, 所以他第二次还能正常登录.

所以, 我们要重写User类的equals()和hashCode()方法, 来区分什么情况下是同一个用户在登录(这里我判断如果用相同的username和password来登录, 就是同一个用户).

@Override
public boolean equals(Object obj) {
	if (obj instanceof User && this.hashCode() == obj.hashCode()) {
		return true;
	} else {
		return false;
	}
}

@Override
public int hashCode() {
	return this.userName.hashCode() + this.password.hashCode();
}

写到这里, 防止重复登录的功能基本上就实现了. 但官方文档上有一个提示 21.3 Concurrency Control, 一定要在web.xml加上下面这个监听器:

<listener>
	<listener-class>org.springframework.security.web.session.HttpSessionEventPublisher</listener-class>
</listener>
理由是:

<quote>

Adding the listener to web.xml causes an ApplicationEvent to be published to the Spring ApplicationContext every time a HttpSession commences or terminates. This is critical, as it allows the SessionRegistryImpl to be notified when a session ends. Without it, a user will never be able to log back in again once they have exceeded their session allowance, even if they log out of another session or it times out.

</quote>

意思是说, 每当一个session结束的时候, 该监听器都会通知SessionRegistryImpl来删除这个session的信息. 这是因为SessionRegistryImpl实现了ApplicationListener<SessionDestroyedEvent>接口. 当一个session结束时, Spring容器会自动通知它, 然后它会调用onApplicationEvent()方法来删除对应的session信息.

public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> {
	...
	
	public void onApplicationEvent(SessionDestroyedEvent event) {
        String sessionId = event.getId();
        removeSessionInformation(sessionId);
    }
}
如果不加这个监听器, 那么当一个用户的session超时后, 他将永远不能再登录.

现在, 防止用户重复登录的功能已经实现了. 但是,, 上面我们设置ConcurrentSessionControlAuthenticationStrategy的属性exceptionIfMaximumExceeded为true:

<bean id="concurrentSessionControlAuthenticationStrategy" class="org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy">
	<constructor-arg ref="sessionRegistry"/>
	<property name="exceptionIfMaximumExceeded" value="false"/>
</bean>
意思是, 当用户第一次登录的session未过期时, 他的第二次登录会失败. 那么会引发一个问题: 如果用户没有点击Logout按钮进行登出, 而是直接关闭浏览器. 那么, 在他第一个session超时之前, 他都不能正常登录...

解决的方法是, 把exceptionIfMaximumExceeded设为false. 它的意思是, 允许用户进行第二次登录, 登录成功后, 会销毁第一次登录的session, 以保证每个用户同一时间只有一个有效的session. 

但是, 如果把exceptionIfMaximumExceeded设为false, 我们要额外设置多一个过滤器 -- ConcurrentSessionFilter:

<sec:http entry-point-ref="myAuthenticationEntryPoint">
	...
	<sec:custom-filter ref="concurrencySessionFilter" position="CONCURRENT_SESSION_FILTER"/>
</sec:http>

<bean id="concurrencySessionFilter" class="org.springframework.security.web.session.ConcurrentSessionFilter">
	<constructor-arg name="sessionRegistry" ref="sessionRegistry" />
	<constructor-arg name="expiredUrl" value="/login" />
</bean>
这个过滤器会销毁第一次登录时的session.


那么它是如何实现的呢? 通过观察上面ConcurrentSessionControlAuthenticationStrategy的onAuthentication()方法我们发现, 如果当前用户的session数大于设置的最大数时, 它会调用allowableSessionsExceeded() 方法:

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[] {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;
		}
	}

	// 把旧的session设成过期
	leastRecentlyUsed.expireNow();
}
这个方法会把旧的session设成过期. 

(注意这里的leastRecentlyUsed是SessionInformation的实例, 不是HttpSession的实例, 所以我们才要配多一个ConcurrentSessionFilter. 如果不配的话, 则旧的session会仍然有效)

下面是ConcurrentSessionFilter的doFilter()方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
		throws IOException, ServletException {
	HttpServletRequest request = (HttpServletRequest) req;
	HttpServletResponse response = (HttpServletResponse) res;

	HttpSession session = request.getSession(false);

	if (session != null) {
		SessionInformation info = sessionRegistry.getSessionInformation(session.getId());

		if (info != null) {
			// 如果session已过期, 则logout, 并且跳转到expiredUrl
			if (info.isExpired()) {
				// Expired - abort processing
				doLogout(request, response);

				String targetUrl = determineExpiredUrl(request, info);

				if (targetUrl != null) {
					redirectStrategy.sendRedirect(request, response, targetUrl);

					return;
				} else {
					response.getWriter().print("This session has been expired (possibly due to multiple concurrent " +
							"logins being attempted as the same user).");
					response.flushBuffer();
				}

				return;
			} else {
				// Non-expired - update last request date/time
				sessionRegistry.refreshLastRequest(info.getSessionId());
			}
		}
	}

	chain.doFilter(request, response);
}
logout操作会交给我们配置的LogoutFilter来做, 它会把对应的session销毁. 具体请参考 Spring Security学习笔记之LogoutFilter

-------------------------------------------

补充

接口Authentication和UserDetails的区别:

Authentication: 它存储安全实体的标识, 密码以及认证请求的上下文信息. 它还包含用户认证后的信息(可能会包含一个UserDetails的实例). 通常不会被扩展, 除非是为了支持某种特定类型的认证.

UserDetails: 为了存储一个安全实体的概况信息, 包含名字, e-mail, 电话号码等. 通常会被扩展以支持业务需求.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值