我们上网的时候,一定遇到过类似这样的情况,例如使用网易邮箱时进行了登录操作,之后再访问网易的博客系统时,发现自动以之前的ID登录了。这种实现在计算机中称为SSO(Single Sign On),即我们常说的单点登录。这种在关联网站间共享认证信息,避免需要在多个系统中重复输入帐户信息的行为,是SSO要解决的。
对于许多应用,可能会独立部署等情况,所以常会采用cas的形式,来实现SSO。
我们今天要了解的,是作为在同一个Tomcat中部署的应用之间,如何实现SSO,避免重复登录。
首先,有几点预备知识需要先了解一下。
在Tomcat架构设计中,不同的Container中包含了Peipline。各个Pipeline中可以添加多种不同形式的Valve。例如我们之前提到的AccessLogValveTomcat的AccessLogValve介绍
Tomcat中session的实现,最常用的是Cookie Session, 通过将名为JSESSIONID的cookie写回浏览器,实现session。我们在前面的文章里也描述过。深入Tomcat源码分析Session
关于认证的一些内容,可以参考介绍过的Basic认证。你可能不了解的Basic认证
有了这些准备之后,我们开始进行环境的搭建和实验。
以Tomcat自带的几个应用为例,我们启动Tomcat后,访问这两个应用:docs
、examples
我们看到,默认是不需要登录的,都可以直接访问。
此时,在docs应用的web.xml中增加如下配置:
<security-constraint> <display-name>Security Constraint</display-name> <web-resource-collection> <web-resource-name>Protected Area</web-resource-name> <url-pattern>/*</url-pattern> </web-resource-collection> <auth-constraint> <role-name>tomcat</role-name> </auth-constraint> </security-constraint> <login-config> <auth-method>BASIC</auth-method> <realm-name>SSO Test</realm-name> </login-config> <security-role> <role-name>tomcat</role-name> </security-role>
此时重启Tomcat,再次请求docs应用,发现需要验证了。
同样,再修改examples应用的web.xml,限制对于其直接访问,在文件中增加如下内容: <url-pattern>/*</url-pattern>
。只需要增加这个就可以了,下面是修改内容对应的位置参考。
<web-resource-collection> <web-resource-name>Protected Area - Allow methods</web-resource-name> <url-pattern>/jsp/security/protected/*</url-pattern> <url-pattern>/*</url-pattern> <http-method>DELETE</http-method> <http-method>GET</http-method> <http-method>POST</http-method> <http-method>PUT</http-method> </web-resource-collection>
修改之后,examples也需要登录才能访问了。由于同样的认证,我们对两个应用的访问需要重复输入用户名、密码进行认证,此时,SSO的配置就显出了必要性了。
在Tomcat的server.xml中,默认的Host,localhost中,增加以下Valve:
<Valve className="org.apache.catalina.authenticator.SingleSignOn"/>
再次重启Tomcat,这个时候SSO已经生效了,你再重新访问上面两个应用时,只需要对其中一个进行认证即可,是不是很容易?
在前面分析请求流程的几篇文章中,我们介绍过从CoyoteAdapter进行service处理,再到达各个Pipeline、Valve。(Facade模式与请求处理)
而这些Valve中,对于SSO的Valve SingleSignOn
是在认证的ValveAuthenticatorBase
之前执行。
在SingleSignOn中,会先进行userPrincipal的判断,不为空就会直接向后执行,为空时,判断请求中是否包含SSO Cookie。
if (request.getUserPrincipal() != null) { getNext().invoke(request, response); return; } // Check for the single sign on cookie Cookie cookie = null; Cookie cookies[] = request.getCookies(); if (cookies != null) { for (int i = 0; i < cookies.length; i++) { if (Constants.SINGLE_SIGN_ON_COOKIE.equals(cookies[i].getName())) { cookie = cookies[i]; break; } } } if (cookie == null) { getNext().invoke(request, response); return; }
对于第一个就进认证的应用,走的流程基本和配置之前一样,区别就在于SSO配置后,会把认证的信息,添加到Cookie中。并将其存储并和一个ssoId进行关联。
对于docs应用,使用的是Basic认证方式
principal = context.getRealm().authenticate(username, password); if (principal != null) { register(request, response, principal, HttpServletRequest.BASIC_AUTH, username, password); return (true); } }
对于examples应用,使用的是Form的认证方式,如果是Form认证的应用不是第一个请求,则在请求到达时,已经进行过认证,后面的请求会直接获取session并关联到ssoId上。 如果是初次请求即访问Form认证的应用,SsoId还没值,流程基本和Basic一样,不同的是从表单中提取用户名和密码信息,再进行register。
Principal principal = request.getUserPrincipal(); String ssoId = (String) request.getNote(Constants.REQ_SSOID_NOTE); if (principal != null) { // Associate the session with any existing SSO session if (ssoId != null) { associate(ssoId, request.getSessionInternal(true)); // 注意这里,把新获取到的sessionId关联到ssoId中 } return true; }
这里register会把认证的信息添加, 在ssoId为空时,进行Cookie的创建,
String ssoId = (String) request.getNote(Constants.REQ_SSOID_NOTE); if (ssoId == null) { ssoId = sessionIdGenerator.generateSessionId(); Cookie cookie = new Cookie(Constants.SINGLE_SIGN_ON_COOKIE, ssoId); cookie.setMaxAge(-1); cookie.setPath("/"); // Bugzilla 41217 cookie.setSecure(request.isSecure()); // Bugzilla 34724 String ssoDomain = sso.getCookieDomain(); if(ssoDomain != null) { cookie.setDomain(ssoDomain); } // Configure httpOnly on SSO cookie using same rules as session cookies if (request.getServletContext().getSessionCookieConfig().isHttpOnly() || request.getContext().getUseHttpOnly()) { cookie.setHttpOnly(true); } response.addCookie(cookie); // Register this principal with our SSO valve sso.register(ssoId, principal, authType, username, password); request.setNote(Constants.REQ_SSOID_NOTE, ssoId);
Cookie不为空时,进行ssoId和session的关联
protected boolean associate(String ssoId, Session session) { SingleSignOnEntry sso = cache.get(ssoId); if (sso == null) { if (containerLog.isDebugEnabled()) { containerLog.debug(sm.getString("singleSignOn.debug.associateFail", ssoId, session)); } return false; } else { } sso.addSession(this, ssoId, session); return true; } }
我们注意到这行代码sso.addSession(this, ssoId, session)
这里会给session添加一个listener,这个listener会在session过期销毁时,把sso的session也移除掉
在Pipeline中从SingleSignOn这个Valve开始,一直调用到AuthenticatorBase,再到达其实现类. SingleSignOn这个Valve处理请求时,判断entry是否为空,此时由于前面的应用已经存储过该信息,所以这里不为空,就会据此设置request中的authType和principal
SingleSignOnEntry entry = cache.get(cookie.getValue()); if (entry != null) { request.setNote(Constants.REQ_SSOID_NOTE, cookie.getValue()); // Only set security elements if reauthentication is not required if (!getRequireReauthentication()) { request.setAuthType(entry.getAuthType()); request.setUserPrincipal(entry.getPrincipal()); }
而后面的Valve中,认证时首先会判断principal是否为空。由于前置的sso已经把这些信息填充过了,所以这里就会走这样的逻辑:
public void invoke(Request request, Response response) throws IOException, ServletException { // Have we got a cached authenticated Principal to record? if (cache) { Principal principal = request.getUserPrincipal(); // 这里不为空 if (principal == null) { Session session = request.getSessionInternal(false); if (session != null) { principal = session.getPrincipal(); if (principal != null) { request.setAuthType(session.getAuthType()); request.setUserPrincipal(principal); } } } }
单点登录的实现,是在第一次进行认证的时候,将认证信息进行存储。后续相同域的请求到达时,会先判断是否存储了单点登录的认证信息,如果已经存储过,就将其添加到新到达的request中,以此进行后续的认证,从而实现SSO.