2013年1月23日 星期三

Applying Spring Security for SSO of Cross Web Applications

因應雲端世代的來臨,在架構設計上必須讓系統具有分散式佈署的能力,所以將一個大的系統用多個web application來開發是無可避免的方式,除了在module上可以做功能性的分割之外,也可以將不同的web application佈署到不同的application server上,做到server loading分散的目的,只要架設一個Apache HTTP Server做為load balancer,對前端使用者來說就不會感覺到系統後面其實有很多application server在服務。

在這個前提之下,我們會面臨不同web application的session其實是被隔離的問題,也就是說在一個web application登入之後,另外一個web application並不會知道使用者已經登入過,或許會有人用cross servlet context的方式讓不同的web application交換資料,但這牽涉到所使用application server的特性,不見得是好的方法。

我們使用Spring Security的Pre-Authentication機制來處理SSO of Cross Web Applications問題,在某個master web application中實作登入機制,其它的slave web application只需要套用Pre-Authentication機制取得使用者曾經登入的資訊,那麼就不會發生登入不同步的問題了。

以下是我們簡單利用cookie記錄使用者曾經登入過的帳號密碼來做Pre-Authentication,當然帳號、密碼是經過加密的。
Spring XML for Master Web Application
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:security="http://www.springframework.org/schema/security"
    xsi:schemaLocation="http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
        http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <security:http pattern="/login.action" security="none" />
    <security:http pattern="/struts/**" security="none" />
    <security:http pattern="/css/**" security="none" />
    <security:http pattern="/images/**" security="none" />
    <security:http pattern="/js/**" security="none" />
    <security:http pattern="/styles/**" security="none" />
    <security:http pattern="/template/**" security="none" />
    <security:http auto-config="true" authentication-manager-ref="authenticationManager" use-expressions="true">
        <security:form-login authentication-success-handler-ref="cookieTokenAwareAuthenticationSuccessHandler"
            login-page="/login.action" authentication-failure-url="/login.action?login_error=1" />
        <security:logout invalidate-session="true" success-handler-ref="cookieTokenClearingLogoutSuccessHandler"
            logout-url="/spring_security_logout" />
        <security:custom-filter position="PRE_AUTH_FILTER" ref="cookieTokenAuthenticationFilter" />
        <security:custom-filter position="CAS_FILTER" ref="casFilter" />
        <security:intercept-url pattern="/**" access="authenticated" />
    </security:http>

    <security:authentication-manager alias="authenticationManager">
        <security:authentication-provider ref="preauthAuthProvider" />
        <security:authentication-provider ref="casAuthenticationProvider" />
        <security:authentication-provider ref="ldapAuthenticationProvider" />
        <security:authentication-provider user-service-ref="userDetailService">
            <!-- <security:password-encoder ref="passwordEncoder"/> -->
        </security:authentication-provider>
    </security:authentication-manager>

    <!-- <bean id="passwordEncoder" class="org.springframework.security.authentication.encoding.ShaPasswordEncoder"/> -->

    <bean id="cookieTokenAwareAuthenticationSuccessHandler" class="com.gss.gmo.cao.spring.security.web.authentication.CookieTokenAwareAuthenticationSuccessHandler">
        <property name="defaultTargetUrl" value="/init/menu.jsp" />
        <property name="alwaysUseDefaultTargetUrl" value="true" />
    </bean>

    <bean id="cookieTokenClearingLogoutSuccessHandler" class="com.gss.gmo.cao.spring.security.web.authentication.logout.CookieTokenClearingLogoutSuccessHandler">
        <property name="defaultTargetUrl" value="/login.action" />
        <property name="alwaysUseDefaultTargetUrl" value="true" />
    </bean>

    <bean id="cookieTokenAuthenticationFilter" class="com.gss.gmo.cao.spring.security.web.authentication.preauth.CookieTokenAuthenticationFilter">
        <property name="authenticationManager" ref="authenticationManager" />
        <property name="continueFilterChainOnUnsuccessfulAuthentication" value="true" />
        <property name="checkForPrincipalChanges" value="true" />
    </bean>

    <bean id="preauthAuthProvider" class="org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider">
        <property name="preAuthenticatedUserDetailsService" ref="authenticationUserDetailsService" />
    </bean>

    <bean id="authenticationUserDetailsService" class="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
        <property name="userDetailsService" ref="userDetailService" />
    </bean>

    <bean id="serviceProperties" class="org.springframework.security.cas.ServiceProperties">
        <property name="service" value="http://localhost:8080/portal-web/gss/j_spring_cas_security_check" />
    </bean>

    <bean id="casFilter" class="org.springframework.security.cas.web.CasAuthenticationFilter">
        <property name="filterProcessesUrl" value="/gss/j_spring_cas_security_check" />
        <property name="authenticationManager" ref="authenticationManager" />
        <property name="authenticationSuccessHandler" ref="cookieTokenAwareAuthenticationSuccessHandler" />
        <property name="authenticationFailureHandler">
            <bean class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
                <property name="defaultFailureUrl" value="/login.action?login_error=1" />
            </bean>
        </property>
    </bean>

    <bean id="casAuthenticationProvider" class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
        <property name="serviceProperties" ref="serviceProperties" />
        <property name="ticketValidator">
            <bean class="org.jasig.cas.client.validation.Cas20ServiceTicketValidator">
                <constructor-arg value="http://teamkube.gss.com.tw/cas" />
            </bean>
        </property>
        <property name="key" value="teamkube-cas" />
        <property name="authenticationUserDetailsService" ref="authenticationUserDetailsService" />
    </bean>

</beans>
Spring XML for Slave Web Application
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context" xmlns:security="http://www.springframework.org/schema/security"
    xsi:schemaLocation="http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
        http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">

    <import resource="classpath:spring-acm-core.xml" />
    <import resource="classpath:gmo-cao-struts2-spring-context.xml" />

    <context:annotation-config />
    <context:component-scan base-package="com.gss.acm.web" />

    <security:http pattern="/struts/**" security="none" />
    <security:http pattern="/css/**" security="none" />
    <security:http pattern="/images/**" security="none" />
    <security:http pattern="/js/**" security="none" />
    <security:http pattern="/styles/**" security="none" />
    <security:http pattern="/template/**" security="none" />
    <security:http auto-config="true" use-expressions="true">
        <!-- Additional http configuration omitted -->
        <security:form-login login-page="/../portal-web/login.action" />
        <security:logout invalidate-session="true" logout-success-url="/../portal-web/spring_security_logout"
            logout-url="/spring_security_logout" />
        <security:custom-filter position="PRE_AUTH_FILTER" ref="cookieTokenAuthenticationFilter" />
        <security:intercept-url pattern="/**" access="authenticated" />
    </security:http>

    <security:authentication-manager alias="authenticationManager">
        <security:authentication-provider ref="preauthAuthProvider" />
    </security:authentication-manager>

</beans>
CookieTokenAuthenticationFilter
package com.gss.gmo.cao.spring.security.web.authentication.preauth;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;

import org.jasypt.encryption.StringEncryptor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
import org.springframework.web.util.WebUtils;

import com.gss.gmo.cao.spring.security.util.StringEncryptorFactory;

/**
 * Get username and password from cookie for SSO.
 *
 * @author linus_chien
 *
 */
public class CookieTokenAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter {

    /**
     * Encryptor.
     */
    private StringEncryptor encryptor = StringEncryptorFactory.createInstance();

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (getPreAuthenticatedPrincipal((HttpServletRequest) request) == null) {
            SecurityContextHolder.clearContext();
        }
        super.doFilter(request, response, chain);
    }

    @Override
    protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
        Cookie passwordCookie = WebUtils.getCookie(request, UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_PASSWORD_KEY);
        if (passwordCookie != null) {
            return encryptor.decrypt(passwordCookie.getValue());
        }
        return null;
    }

    @Override
    protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
        Cookie usernameCookie = WebUtils.getCookie(request, UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY);
        if (usernameCookie != null) {
            return encryptor.decrypt(usernameCookie.getValue());
        }
        return null;
    }

}
CookieTokenAwareAuthenticationSuccessHandler
package com.gss.gmo.cao.spring.security.web.authentication;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.jasypt.encryption.StringEncryptor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.gss.gmo.cao.spring.security.util.StringEncryptorFactory;

/**
 * Set username/password to cookie for cross web context SSO.
 *
 * @author linus_chien
 *
 */
public class CookieTokenAwareAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    /**
     * Encryptor.
     */
    private StringEncryptor encryptor = StringEncryptorFactory.createInstance();
 
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException,
            IOException {
     
        UserDetails user = (UserDetails) authentication.getPrincipal();
        Cookie username = new Cookie(UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY, encryptor.encrypt(user.getUsername()));
        username.setPath("/");
        Cookie password = new Cookie(UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_PASSWORD_KEY, encryptor.encrypt(user.getPassword()));
        password.setPath("/");

        response.addCookie(username);
        response.addCookie(password);

        super.onAuthenticationSuccess(request, response, authentication);
    }

}
CookieTokenClearingLogoutSuccessHandler
package com.gss.gmo.cao.spring.security.web.authentication.logout;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;

/**
 * Clear username/password from cookie for cross web context SSO.
 *
 * @author linus_chien
 *
 */
public class CookieTokenClearingLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        Cookie username = new Cookie(UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY, null);
        username.setPath("/");
        username.setMaxAge(0);
        Cookie password = new Cookie(UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_PASSWORD_KEY, null);
        password.setPath("/");
        password.setMaxAge(0);

        response.addCookie(username);
        response.addCookie(password);

        super.onLogoutSuccess(request, response, authentication);
    }

}
最後需要強調的是AbstractPreAuthenticatedProcessingFilter和AbstractAuthenticationProcessingFilter是不相關的filter類別,如果是內部系統彼此間的SSO可以使用AbstractPreAuthenticatedProcessingFilter,但是外部系統的SSO最好還是使用AbstractAuthenticationProcessingFilter的子類別來處理,但無論使用哪種方式,我們都會reuse UserDetailsService instance。