搭建基于OAuth2和SSO的开放平台

搭建基于OAuth2和SSO的开放平台

原创文章,转载或摘录请说明文章来源,谢谢!

开放平台介绍

什么是开放平台

开放平台在百科中的定义:
开放平台(Open Platform) 在软件行业和网络中,开放平台是指软件系统通过公开其应用程序编程接口(API)或函数(function)来使外部的程序可以增加该软件系统的功能或使用该软件系统的资源,而不需要更改该软件系统的源代码。

通俗或者说应景点的说法,开放平台,就是互联网企业,将其内部的资源(一般是数据),比如用户数据,平台业务数据,以技术的手段(一般是RESTFul接口API),开放给受控的第三方合作伙伴,活公司内部的其它一些产品,形成一个安全受控的资源暴露平台。

为什么要搭建开放平台

搭建开放平台的意义,一般在于:
1.搭建基于API的生态体系
2.利用开放平台,搭建基于计费的API数据平台
3.为APP端提供统一接口管控平台,类似于网关的概念
4.为第三方合作伙伴的业务对接提供授信可控的技术对接平台

开放平台体系结构图

open

开放平台核心模块

一个典型的开放平台,至少包含以下几个核心模块:
1.平台门户
平台门户负责向第三方展示用于进行业务及技术集成的管理界面,至少包含以下几个功能:
1.服务商入住(第三方合作伙伴入住)
2.应用配置(第三方应用管理)
3.权限申请(一般包括接口权限和字段权限)
4.运维中心(开放平台当前服务器、接口状态,服务商接口告警等)
5.帮助中心(入住流程说明,快速接入说明,API文档等)

2.鉴权服务
鉴权服务负责整个平台的安全性
1.接口调用鉴权(第三方合作伙伴是否有权限调用某接口)
2.用户授权管理(用户对某个第三方应用获取改用户信息的权限管理)
3.用户鉴权(平台用户的鉴权)
4.应用鉴权(第三方合作伙伴的应用是否有权调用该平台)

3.开放接口
开放接口用于将平台数据暴露给合作伙伴
1.平台用户接口(用于获取公司APP生态链中的用户信息)
2.平台数据接口(平台中的一些开放数据)
3.其它业务接口(平台开放的一些业务数据)

4.运营系统
运营系统是整个平台的后台业务管理系统,负责对第三方合作伙伴提出的各种申请进行审核操作,对当前应用的操作进行审计工作,对当前业务健康度进行监控等
1.服务商管理(对第三方合作伙伴的资质进行审核、操作)
2.应用管理(对第三方应用进行审核、上下线管理)
3.权限管理(对合作伙伴申请的资源进行审核、操作)
4.统计分析(监控平台当前运行状态,统计平台业务数据)

OAuth2介绍

什么是OAuth2

百科:OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。与以往的授权方式不同之处是OAUTH的授权不会使第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此OAUTH是安全的。oAuth是Open Authorization的简写。

简单来说:OAuth2协议,定义了一套用户、第三方服务和存储着用户数据的平台之间的交互规则,可以使得用户无需将自己的用户名和密码暴露给第三方,即可使第三方应用获取用户在该平台上的数据,最常见的场景便是现在互联网上的各种使用XXX账号登录。

OAuth2协议中角色介绍

OAuth2协议中,共有四个参与方(角色):
1.resource owner:资源拥有者
即用户
2.resource server:资源服务器
即存储用户数据的服务器,一般对外都以RESTFul API的形式暴露用户数据,client使用access token访问resource server申请被保护起来的用户数据
3.client:客户端
即第三方应用
4.authorization server:授权服务器
用来鉴权第三方应用合法性,并对用户登录、是否授权第三方应用获取数据进行响应,并根据用户操作,想第三应用颁发用户token或者告知授权失败

OAuth2常用协议介绍

OAUTH2标准业务协议,如下图所示
oauth
A.第三方应用向用户请求授权,希望获取用户数据
B.用户同意授权
C.第三方应用拿着用户授权,向平台索要用户access token
D.平台校验第三应用合法性及用户授权真实性后,向平台发放用户access token
E.第三方应用拿着用户access token向平台索要用户数据
F.平台在校验用户access token真实性后,返回用户数据

OAuth2使用场景介绍

目前,OAuth2协议使用最多的场景还是用以给第三方应用获取用户信息,业务流程如下图所示
case
1.在浏览器中,用户点击第三方应用按钮,由第三方应用发起请求,向平台发起授权请求。
2.平台在接收到第三方应用请求后,浏览器跳转用户登录界面,请求用户进行登录。
3.用户在平台登录界面输入用户名、密码进行登录
4.平台判断用户合法性,校验失败,在浏览器中提示错误原因
5.平台判断用户是否需要对该第三方应用进行授权。(不需要授权的情况有两种:a.平台信任该第三方应用,如公司内部应用,无需用户进行授权,默认给予用户数据。b.该用户之前已经给该应用授予过权限,并且仍在有效期内)
6.如需授权,平台跳转浏览器界面至授权界面,告知用户将授予哪个第三方哪些数据权限
7.用户授权后,将用户授权码回调给第三方url
8.第三方在获取用户授权码后,带着用户授权码访问平台鉴权接口,请求用户token
9.平台在收到第三方请求后,校验授权码真实性,并返回用户token
10.第三方使用用户token向平台请求用户接口
11.平台接口判断用户token真实性,并向第三方返回用户数据

OAuth2核心功能说明

1.应用注册
应用注册后,OAuth2会下发应用app_id和app_secret,用以标记该应用的唯一性,并且这两个参数将贯穿整个OAuth协议,用以对应用合法性进行校验。同时,应用需要提供redirect_uri,用以和平台进行异步交互,获取用户令牌及错误信息。
2.授权/鉴权中心
a.对用户的应户名、密码进行鉴权
b.对第三方应用的app_id,app_secret进行鉴权
c.展示授权界面,并对用户对第三方应用的授权操作进行响应
d.对用户授权码及用户token的真实性进行鉴权
3.token管理
a.创建token、刷新token
b.查询token详细数据
c.校验token时效性

OAuth2体系结构

case

开放平台集成OAuth2体系

1.平台门户:
门户应用入住界面,需要集成OAuth2应用创建接口,录入第三方回调地址,并回显app_id和app_secret参数
2.鉴权服务:
鉴权服务需集成OAuth2的authorize及token接口,用以提供用户授权及code/token鉴权功能
3.开放接口:
开放接口需集成OAuth2的resource server角色,对用户数据进行安全管理,对第三方应用发起的请求做出响应,并对token进行真实性校验
4.运营系统:
运营系统需提供对当前OAuth2应用的管理功能,用户授权列表管理,用户token管理等OAuth2协议相关管理功能。

SSO介绍

什么是SSO

百科:SSO英文全称Single Sign On,单点登录。SSO是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。它包括可以将这次主要的登录映射到其他应用中用于同一个用户的登录的机制。它是目前比较流行的企业业务整合的解决方案之一。

简单来说,SSO出现的目的在于解决同一产品体系中,多应用共享用户session的需求。SSO通过将用户登录信息映射到浏览器cookie中,解决其它应用免登获取用户session的问题。

为什么需要SSO

开放平台业务本身不需要SSO,但是如果平台的普通用户也可以在申请后成为一个应用开发者,那么就需要将平台加入到公司的整体账号体系中去,另外,对于企业级场景来说,一般都会有SSO系统,充当统一的账号校验入口。

CAS协议中概念介绍

SSO单点登录只是一个方案,而目前市面上最流行的单端登录系统是由耶鲁大学开发的CAS系统,而由其实现的CAS协议,也成为目前SSO协议中的既定协议,下文中的单点登录协议及结构,均为CAS中的体现结构
CAS协议中有以下几个概念:
1.CAS Client:需要集成单点登录的应用,称为单点登录客户端
2.CAS Server:单点登录服务器,用户登录鉴权、凭证下发及校验等操作
3.TGT:ticker granting ticket,用户凭证票据,用以标记用户凭证,用户在单点登录系统中登录一次后,再其有效期内,TGT即代表用户凭证,用户在其它client中无需再进行二次登录操作,即可共享单点登录系统中的已登录用户信息
4.ST:service ticket,服务票据,服务可以理解为客户端应用的一个业务模块,体现为客户端回调url,CAS用以进行服务权限校验,即CAS可以对接入的客户端进行管控
5.TGC:ticket granting cookie,存储用户票据的cookie,即用户登录凭证最终映射的cookies

CAS核心协议介绍

case
1.用户在浏览器中访问应用
2.应用发现需要索要用户信息,跳转至SSO服务器
3.SSO服务器向用户展示登录界面,用户进行登录操作,SSO服务器进行用户校验后,映射出TGC
4.SSO服务器向回调应用服务url,返回ST
5.应用去SSO服务器校验ST权限及合法性
6.SSO服务器校验成功后,返回用户信息

CAS基本流程介绍

以下为基本的CAS协议流程,图一为初次登录时的流程,图二为已进行过一次登录后的流程
case
case

代码及示例

spring提供了整套的开源包,用以搭建OAUTH2+SSO的体系:
1.spring-oauth2:用以实现OAuth2协议,提供了上述所有四个角色提供的功能
2.spring-cas:用以实现和cas的集成,将OAuth2的登录、登出功能委托给CAS处理,并提供了统一的回调机制及凭证校验机制
3.CAS,耶鲁大学官方提供的SSO开源实现,本文的单点登录协议即按照CAS进行的说明

本文还提供了基于GO语言实现的简单OAuth2+SSO功能,详见github:
https://github.com/janwenjohn/go-oauth2-sso

cas3.5.2集群化部署及定制开发

集群化方案:

1.tomcat集群共享session
2.持久化票根st及tgt
3.持久化service
4.修改ServiceManager,从内存共享改为redis共享

tomcat集群共享session

之所以要共享session,是因为cas使用了spring-webflow,而webflow使用session存储中间变量,如果不共享session,会直接导致登录流程因为缺少中间变量而失败,具体表现为输入正确用户名密码后,界面刷新重新进入登录界面。

session共享在tomcat中有三种方案可供选择,分别是:1.tomcat原始集群共享。2.redis session持久化共享。3.memcache session持久化共享。

这里我选用了tomcat原始的集群共享,原因是redis session持久化实验失败,session是成功持久化到redis中了,但是登录流程还是失败,memcache由于没有环境,没有试验。

配制tomcat集群

1.在server.xml的Engine元素下加入以下配制

    <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">
      <Manager className="org.apache.catalina.ha.session.DeltaManager"
             expireSessionsOnShutdown="false"
             notifyListenersOnReplication="true"/>
      <Channel className="org.apache.catalina.tribes.group.GroupChannel">
        <Membership
            className="org.apache.catalina.tribes.membership.McastService"
            address="228.0.0.4"
            port="45564"
            frequency="500"
            dropTime="3000"
            mcastTTL="1"/>
        <Receiver
            className="org.apache.catalina.tribes.transport.nio.NioReceiver"
            address="xxx"
            port="4001"
            autoBind="0"
            selectorTimeout="100"
            maxThreads="6"/>
        <Sender className="org.apache.catalina.tribes.transport.ReplicationTransmitter">
          <Transport className="org.apache.catalina.tribes.transport.nio.PooledParallelSender"/>
        </Sender>
        <Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>
        <Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatch15Interceptor"/>
      </Channel>
      <Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
           filter=".*\.gif;.*\.js;.*\.jpg;.*\.htm;.*\.html;.*\.txt;"/>
      <ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener"/>
    </Cluster>

上面的address=”xxx”,替换成你自己的服务器ip地址

2.在工程web.xml的开头加入配置项

<distributable />

持久化票根

票根的持久化,cas默认就提供了支持,我们所要做的就是把相应的持久化类使用起来,在配置文件中替换掉原来的内存存储类。
1.pom中增加以下依赖:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.18</version>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.6.2</version>
    <type>jar</type>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>1.5.0.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>4.1.0.Final</version>
</dependency>
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-entitymanager</artifactId>
    <version>4.1.0.Final</version>
</dependency>
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <scope>runtime</scope>
    <version>4.2.0.Final</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.0.1</version>
</dependency>

以上依赖包括mysql驱动,hibernate相关包,druid数据连接池及redis驱动(redis用于后面service持久化)

2.applicationContext.xml文件修改
增加以下配置项:

<tx:annotation-driven transaction-manager="transactionManager" />

<context:component-scan base-package="com" />

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close">
    <!-- 数据源驱动类可不写,Druid默认会自动根据URL识别DriverClass -->
    <property name="driverClassName" value="${jdbc.driver}" />
    <!-- 基本属性 url、user、password -->
    <property name="url" value="${jdbc.url}" />
    <property name="username" value="${jdbc.username}" />
    <property name="password" value="${jdbc.password}" />
    <!-- 配置初始化大小、最小、最大 -->
    <property name="initialSize" value="${jdbc.pool.minIdle}" />
    <property name="minIdle" value="${jdbc.pool.minIdle}" />
    <property name="maxActive" value="${jdbc.pool.maxActive}" />
    <!-- 配置获取连接等待超时的时间 -->
    <property name="maxWait" value="30000" />
    <!-- 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 -->
    <property name="timeBetweenEvictionRunsMillis" value="30000" />
    <!-- 配置一个连接在池中最小生存的时间,单位是毫秒 -->
    <property name="minEvictableIdleTimeMillis" value="90000" />
    <property name="validationQuery" value="SELECT 'x'" />
    <property name="testWhileIdle" value="true" />
    <property name="testOnBorrow" value="false" />
    <property name="testOnReturn" value="false" />
</bean>

<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean"    p:dataSource-ref="dataSource" p:jpaVendorAdapter-ref="jpaVendorAdapter">
    <property name="jpaProperties">
        <props>
        <prop key="hibernate.dialect">${database.dialect}</prop>
        <prop key="hibernate.hbm2ddl.auto">update</prop>
        <prop key="hibernate.jdbc.batch_size">${database.batchSize}</prop>
    </props>
    </property>
</bean>

<bean id="jpaVendorAdapter" class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter" p:generateDdl="true" p:showSql="true" />

<bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager" p:entityManagerFactory-ref="entityManagerFactory" />

<bean class="org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor" />

2.ticketRegistry.xml文件修改
查找ticketRegistry,修改原bean声明如下:

<bean id="ticketRegistry" class="org.jasig.cas.ticket.registry.JpaTicketRegistry" />

持久化service

1.deployerConfigContext.xml文件修改
查找serviceRegistryDao,修改bean声明

<bean id="serviceRegistryDao" class="org.jasig.cas.services.JpaServiceRegistryDaoImpl"      p:entityManagerFactory-ref="entityManagerFactory" />

修改ServiceManger

cas自带的DefaultServicesManagerImpl的集群模式,是通过直接将所有的service存在内存的一个set中,然后通过quartz,每两分钟reload一把全量service,这种2分钟同步一次service的集群模式,显然不能正式上线使用,这里我们通过自己实现ServiceManager,采用redis,对所有service进行统一管理。

1.增加RedisServicesManagerImpl类:

public class RedisServicesManagerImpl implements ServicesManager {

    private final Logger log = LoggerFactory.getLogger(getClass());

    private final static String SPLIT = ",";

    private final static String REDIS_KEY = "registedService";

    @NotNull
    private ServiceRegistryDao serviceRegistryDao;

    private RegisteredService disabledRegisteredService;

    private RedisTemplate<String, RegisteredService> redisTemplate;

    private RegexRegisteredService defaultService = new RegexRegisteredService();

    public RedisServicesManagerImpl(final ServiceRegistryDao serviceRegistryDao,            final RedisTemplate<String, RegisteredService> redisTemplate) {
        this.serviceRegistryDao = serviceRegistryDao;
        this.disabledRegisteredService = constructDefaultRegisteredService(new ArrayList<String>());
        this.redisTemplate = redisTemplate;

        constructDefaultService();
    }

    @Transactional(readOnly = false)
    @Audit(action = "DELETE_SERVICE", actionResolverName = "DELETE_SERVICE_ACTION_RESOLVER", resourceResolverName = "DELETE_SERVICE_RESOURCE_RESOLVER")
    public synchronized RegisteredService delete(final long id) {
        final RegisteredService r = findServiceBy(id);
        if (r == null) {
            return null;
        }
        log.info("delete service by id..." + r.getServiceId() + "..."
                + r.getId());
        this.serviceRegistryDao.delete(r);
        String key = r.getId() + SPLIT + r.getServiceId();
        // redisTemplate.opsForValue().getOperations().delete(key);
        redisTemplate.opsForHash().delete(REDIS_KEY, key);
        return r;
    }

    public RegisteredService findServiceBy(final Service service) {
        if (service != null) {
            log.info("find service by service..." + service.getId() + "..."
                    + service.getClass());
        }
        Collection<RegisteredService> c = getAllServices();
        if (c.isEmpty()) {
            log.info("find service by service...service is blank");
            return this.disabledRegisteredService;
        }

        for (final RegisteredService r : c) {
            if (r.matches(service)) {
                log.info("find service by service...service is a match...in service..."
                        + service.getId()
                        + "...with redis..."
                        + r.getServiceId());
                return r;
            }
        }
        log.info("find service by service...service not match");
        return null;
    }

    public RegisteredService findServiceBy(final long id) {
        log.info("find service by id..." + id);
        Map<Object, Object> map = redisTemplate.opsForHash().entries(REDIS_KEY);
        Set<Entry<Object, Object>> set = map.entrySet();
        Iterator<Entry<Object, Object>> it = set.iterator();
        while (it.hasNext()) {
            Entry<Object, Object> entry = it.next();
            String key = entry.getKey().toString();
            RegisteredService value = (RegisteredService) entry.getKey();
            log.info("find service by id...service in redis..." + key);
            if (String.valueOf(id).equals(key.split(SPLIT)[0])) {
                log.info("find service by id...match..." + key);
                try {
                    return (RegisteredService) value.clone();
                } catch (final CloneNotSupportedException e) {
                    return value;
                }
            }
        }
        return null;
    }

    public Collection<RegisteredService> getAllServices() {
        log.info("get all services...");
        Set<RegisteredService> services = new TreeSet<RegisteredService>();
        Map<Object, Object> map = redisTemplate.opsForHash().entries(REDIS_KEY);
        Set<Entry<Object, Object>> set = map.entrySet();
        Iterator<Entry<Object, Object>> it = set.iterator();
        while (it.hasNext()) {
            Entry<Object, Object> entry = it.next();

            log.info("get all services...service in redis..." + entry.getKey()
                    + "..." + entry.getValue().getClass());

            String key = entry.getKey().toString();
            if (entry.getValue() instanceof RegisteredService) {
                RegisteredService value = (RegisteredService) entry.getValue();
                log.info("get all services...service in redis..." + key);
                services.add(value);
            }
        }
        if (!services.contains(defaultService)) {
            services.add(defaultService);
        }
        return services;
    }

    public boolean matchesExistingService(final Service service) {
        return findServiceBy(service) != null;
    }

    @Transactional(readOnly = false)
    @Audit(action = "SAVE_SERVICE", actionResolverName = "SAVE_SERVICE_ACTION_RESOLVER", resourceResolverName = "SAVE_SERVICE_RESOURCE_RESOLVER")
    public synchronized RegisteredService save(
            final RegisteredService registeredService) {
        log.info("save service..." + registeredService.getServiceId());
        final RegisteredService r = this.serviceRegistryDao
                .save(registeredService);

        String key = registeredService.getId() + SPLIT
                + registeredService.getServiceId();
        log.info("save service in redis..." + key);
        // redisTemplate.opsForValue().set(key, registeredService);
        redisTemplate.opsForHash().put(REDIS_KEY, key, registeredService);
        return r;
    }

    private RegisteredService constructDefaultRegisteredService(
            final List<String> attributes) {
        final RegisteredServiceImpl r = new RegisteredServiceImpl();
        r.setAllowedToProxy(true);
        r.setAnonymousAccess(false);
        r.setEnabled(true);
        r.setSsoEnabled(true);
        r.setAllowedAttributes(attributes);

        if (attributes == null || attributes.isEmpty()) {
            r.setIgnoreAttributes(true);
        }

        return r;
    }

    private void constructDefaultService() {
        defaultService.setId(0);
        defaultService.setName("HTTP and IMAP");
        defaultService.setDescription("Allows HTTP(S) and IMAP(S) protocols");
        defaultService.setServiceId("^(https?|imaps?)://.*");
        defaultService.setEvaluationOrder(10000001);
    }

}

2.applicationContext.xml文件修改
增加以下配置项:

<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
    <property name="maxTotal" value="${redis.pool.maxActive}" />
    <property name="maxIdle" value="${redis.pool.maxIdle}" />
    <property name="maxWaitMillis" value="${redis.pool.maxWait}" />
    <property name="testOnBorrow" value="${redis.pool.testOnBorrow}" />
</bean>

<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
    <property name="hostName" value="${redis.hostname}" />
    <property name="port" value="${redis.port}" />
    <property name="password" value="${redis.password}" />
    <property name="poolConfig" ref="jedisPoolConfig" />
</bean>

<bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate">
    <property name="connectionFactory" ref="jedisConnectionFactory" />
</bean>

修改以下配置项:

<bean id="servicesManager" class="com.wondersgroup.cas.services.RedisServicesManagerImpl">
    <constructor-arg index="0" ref="serviceRegistryDao" />
    <constructor-arg index="1" ref="redisTemplate" />
</bean>

spring-oauth集成cas单点登录,登陆完成进入授权页面后,按回退按钮进入404页面的问题

背景:

1.项目中使用耶鲁的cas做单点登录。
2.使用spring-oauth包实现oauth2服务
3.使用spring-cas做spring-security及cas的集成

现象:

开发报了个bug,大致流程就是
系统调用/oauth/authorize接口,被spring-security拦截进入cas登录界面后,用户输入用户名密码做登录,登录成功后,浏览器跳转到授权界面,这时候用户点了回退按钮(这个用户好无聊),进入了404的错误界面

原因:

撸了一把spring的源码后发现,spring-security的默认拦截器堆栈中,有个拦截器叫RequestCacheAwareFilter
官方文档解释说这个拦截器是用来还原被登录操作打断的用户界面跳转用的,什么意思呢,就是说:
1.你的系统用spring-security提供的登录方法
2.用户点了你系统的某个链接xxx,这个xxx在spring-security中是个需要被安全控制的连接
3.spring-security拦截这个xxx链接,并且跳到了登录界面
4.用户登录完了后,RequestCacheAwareFilter这个拦截器,再把xxx返回给浏览器

但是,我们这里是集成了cas做登录的,那么,悲剧发生了。

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest wrappedSavedRequest =
                requestCache.getMatchingRequest((HttpServletRequest) request, (HttpServletResponse) response);
        chain.doFilter(wrappedSavedRequest == null ? request : wrappedSavedRequest, response);
    }

这个filter会去调用requestCache的一个方法,requestCache是spring中用来缓存浏览器request请求的缓存存储。这个方法如下所示:

public HttpServletRequest getMatchingRequest(HttpServletRequest request,
            HttpServletResponse response) {
        DefaultSavedRequest saved = (DefaultSavedRequest) getRequest(request,
                response);
        if (saved == null) {
            return null;
        }
        if (!saved.doesRequestMatch(request, portResolver)) {
            logger.debug("saved request doesn't match");
            return null;
        }
        removeRequest(request, response);
        return new SavedRequestAwareWrapper(saved, request);
    }

有一行叫remveRequest,这行代码会把存在requestCache中的请求路径给清除。而这个请求路径就是用户在按回退按钮时,浏览器要跳转的路径。具体看以下逻辑:

当用户按回退按钮时,其实是回到了spring-cas提供的j_spring_cas_security_check这个url里,最终会到

SavedRequestAwareAuthenticationSuccessHandler

中判断该跳转到哪个页面:

SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest == null) {
            super.onAuthenticationSuccess(request, response, authentication);
            return;
        }
        String targetUrlParameter = getTargetUrlParameter();
        if (isAlwaysUseDefaultTargetUrl() || (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
            requestCache.removeRequest(request, response);
            super.onAuthenticationSuccess(request, response, authentication);
            return;
        }
        clearAuthenticationAttributes(request);
        // Use the DefaultSavedRequest URL
        String targetUrl = savedRequest.getRedirectUrl();
        logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);

可以看到,如果requestCache的请求路径是空,那么就会跳转到默认路径,这个路径如果你没有在spring中配置的话,默认就是你应用访问上下文的跟路径,如果跟路径下没有首页的话,那就自然报404错误了。

解决:

没什么好的解决方式,目前我们只是粗暴的将RequestCache的实现类HttpSessionRequestCache重写了一把,加入一段判断逻辑:

public void removeRequest(HttpServletRequest currentRequest,
            HttpServletResponse response) {
        HttpSession session = currentRequest.getSession(false);
        if (session != null) {
            logger.debug("Removing DefaultSavedRequest from session if present");
            Object obj = session.getAttribute(SAVED_REQUEST);
            if (obj instanceof DefaultSavedRequest) {
                DefaultSavedRequest savedRequest = (DefaultSavedRequest) obj;
                String requestURI = savedRequest.getRequestURI();
                System.out.println("requestCache....requestURI...."
                        + savedRequest.getRequestURI());
                if (requestURI.contains("/api/oauth/")) {
                    return;
                }
            }
            session.removeAttribute(SAVED_REQUEST);
        }
    }