如何在Zuul上实现CAS的单点登录

前言

最近项目跟一个基于CAS的系统对接做单点登录,之前在传统框架做过CAS的对接,本来以为很简单,简单配置一下双方对好账户就完了,但现在系统架构调整为SpringCloud的微服务体系,这让这次对接就不那么简单直接的。本文主要讨论CAS与Zuul上的结合思路。

CAS原理

CAS的基本原理还是挺简单的,如下图所示。
这里写图片描述
主要就是
1.拦截重定向
2.登录
3.验证
4.获取用户信息
看着挺复杂,但其实框架已经把大部分工作都做好了,封装起来了,正常情况下我们只需要配置一下就ok了,但在微服务的环境下还是挺让人迷惑的。
对了我们用的是CAS3.5版本搭建的测试系统。

实现思路

Zuul作为整个系统的网关,有几样工作特别适合:
1.路由,Zuul的天职
2.负载均衡,Zuul 2.0的性能还是可以期待的
3.日志,由于外部的请求都经过Zuul因此它的日志处理是非常重要和必要的
4.鉴权,同样由于外部服务都经过Zuul,鉴权也是非常合适的,因此对于SpringCloud体系来说做CAS的单点登录的集成Zuul是最合适不过的。

我们Zuul也是基于SpringBoot,因此可以使用Spring Security的套路实现CAS的拦截与验证等工作。
简单的总结一下:
1.将CAS集成放到Zuul上
2.使用Spring Security套餐

但同时我们也知道Zuul还要处理日志,因此要将CAS与Zuul本身的职责协调好,同时我们也都知道Zuul核心是ZuulFilter,而SpringSecurity实质上也是一系列的Filter来处理,把这两套Fillter理清楚是搞定这个问题的先决条件。

下面我们就结合具体代码看看怎么解决这个问题。

show me the code。

实例代码

首先是工程目录,maven的惯例
这里写图片描述

ps不要在意那个UserLoginInfoCache.java其实就是一个缓存。

然后是POM.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.zw.se2</groupId>
    <artifactId>demo-zuul</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>hy-zuul</name>
    <description>Demo project for Zuul and CAS</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <spring-cloud.version>Finchley.RC1</spring-cloud.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>net.sf.json-lib</groupId>
            <artifactId>json-lib</artifactId>
            <version>2.4</version>
            <classifier>jdk15</classifier>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session</artifactId>
            <version>1.3.1.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-cas</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-taglibs</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
    <repositories>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>
</project>

接下来是Zuul的配置
application.properties

#默认情况下,敏感的头信息无法经过API网关进行传递,需要开启。解决使用zuul网关后spring session无效问题
zuul.routes.tim-service.sensitiveHeaders="*"
zuul.routes.main-service.path=/main-service/**
zuul.routes.main-service.url=http://localhost:8383 
zuul.routes.main-service.sensitiveHeaders="*"
devMode=false
spring.application.name=demo-zuul-server
#下面那个参数是去掉zuul-prefix参数产生的前缀的,跟path一毛钱关系都没有
zuul.strip-prefix=false
server.port=8085

#将 hystrix 的超时时间禁用掉
hystrix.command.default.execution.timeout.enabled=false
#session存储
spring.session.store-type=none
#日志配置文件路径
logging.config=ext/conf/logback.xml

#CAS服务地址
cas.server.host.url=http://10.0.4.53:8080/cas-server-webapp-3.5.0
#CAS服务登录地址
cas.server.host.login_url=${cas.server.host.url}/login
#应用访问地址
app.server.host.url=http://localhost:8085
#应用登录地址,这个URL不一定非要存在
app.login.url=/cas/login/zuul

接下来是启动配置bootstrap.yaml

eureka:
  client:
    service-url:
        defaultZone: http://localhost:8797/eureka
spring:
  cloud:
    config:
      uri: http://localhost:8888
      profile: dev
      name: hyConfig

接下来就是cas了
CasProperties.java

package com.zw.se2.hy.zuul.cas.config;

import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
 * Created by ZEW on 2018/6/7.
 */
@Data
@Component
public class CasProperties {
    @Value("${cas.server.host.url}")
    private String casServerUrl;

    @Value("${cas.server.host.login_url}")
    private String casServerLoginUrl;

    @Value("${app.server.host.url}")
    private String appServerUrl;

    @Value("${app.login.url}")
    private String appLoginUrl;

}

SecurityConfig.java,这个是配置的核心,其中最核心的就是配置拦截策略和处理过滤器。

package com.zw.se2.hy.zuul.cas.config;

import com.zw.se2.hy.zuul.cas.custom.CustomUserDetailsService;
import org.jasig.cas.client.session.SingleSignOutFilter;
import org.jasig.cas.client.validation.Cas20ServiceTicketValidator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.cas.ServiceProperties;
import org.springframework.security.cas.authentication.CasAssertionAuthenticationToken;
import org.springframework.security.cas.authentication.CasAuthenticationProvider;
import org.springframework.security.cas.web.CasAuthenticationEntryPoint;
import org.springframework.security.cas.web.CasAuthenticationFilter;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.AuthenticationUserDetailsService;

/**
 * Created by ZEW on 2018/6/7.
 */
@Configuration
@EnableWebSecurity //启用web权限
@EnableGlobalMethodSecurity(prePostEnabled = true) //启用方法验证
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private CasProperties casProperties;

    /**定义认证用户信息获取来源,密码校验规则等*/
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        super.configure(auth);
        auth.authenticationProvider(casAuthenticationProvider());
    }

    /**定义安全策略*/
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()//配置安全策略
                .antMatchers("/**/api/**").permitAll()
                .antMatchers("/**/**.html").permitAll()
                .anyRequest().authenticated();//定义/请求不需要验证

        http.exceptionHandling().authenticationEntryPoint(casAuthenticationEntryPoint())
                .and()
                .addFilter(casAuthenticationFilter())
                .addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class);
//这个地方需要注意,这里禁用是为了照顾需要使用post请求且这个请求是外部系统提供的,没有办法,
//为了安全还是推荐不禁用转而使用csrf-token的模式
        http.csrf().disable(); 
    }

    /**认证的入口*/
    @Bean
    public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
        CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint();
        casAuthenticationEntryPoint.setLoginUrl(casProperties.getCasServerLoginUrl());
        casAuthenticationEntryPoint.setServiceProperties(serviceProperties());
        return casAuthenticationEntryPoint;
    }

    /**指定service相关信息*/
    @Bean
    public ServiceProperties serviceProperties() {
        ServiceProperties serviceProperties = new ServiceProperties();
        serviceProperties.setService(casProperties.getAppServerUrl() + casProperties.getAppLoginUrl());
        serviceProperties.setAuthenticateAllArtifacts(true);
        return serviceProperties;
    }

    /**CAS认证过滤器*/
    @Bean
    public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
        CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter();
        casAuthenticationFilter.setAuthenticationManager(authenticationManager());
        casAuthenticationFilter.setFilterProcessesUrl(casProperties.getAppLoginUrl());
        return casAuthenticationFilter;
    }

    /**cas 认证 Provider*/
    @Bean
    public CasAuthenticationProvider casAuthenticationProvider() {
        CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
       //这里实现了一个自定义的认证服务,其实没有特殊需求可以采用默认的服务 casAuthenticationProvider.setAuthenticationUserDetailsService(customUserDetailsService());

        casAuthenticationProvider.setServiceProperties(serviceProperties());
        casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());
        casAuthenticationProvider.setKey("an_id_for_this_auth_provider_only");
        return casAuthenticationProvider;
    }

    /*@Bean
    public UserDetailsService customUserDetailsService(){
        return new CustomUserDetailsService();
    }*/

    /**用户自定义的AuthenticationUserDetailsService*/
    @Bean
    public AuthenticationUserDetailsService<CasAssertionAuthenticationToken> customUserDetailsService(){
        return new CustomUserDetailsService();
    }

    @Bean
    public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
        return new Cas20ServiceTicketValidator(casProperties.getCasServerUrl());
    }



}

由于涉及业务,自定义的认证服务这里就不贴了,有兴趣的可以参考
参考链接
但是请注意这篇文档在loadUserDetails函数中要对userInfo的权限相关信息赋值,否则就不能通过验证。
代码如下:

 UserInfo userInfo = new UserInfo();
        userInfo.setUsername(token.getName());
        userInfo.setName(token.getName());
        Set<AuthorityInfo> authorities = new HashSet<>();
        AuthorityInfo authorityInfo = new AuthorityInfo("CAS");
        authorities.add(authorityInfo);
        userInfo.setAuthorities(authorities);
        userInfo.setAccountNonLocked(true);
        userInfo.setAccountNonExpired(true);
        userInfo.setCredentialsNonExpired(true);

集成完CAS,该处理Zuul本身的过滤器
经过试验CAS的过滤器优先级要高于Zuul的Pre过滤器,这样倒也方便了,只需要处理登录等日志即可。
也就是增加一个post过滤器。
如下所示 LoginResponseFilter.java.

package com.zw.se2.hy.zuul.filter.post;

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.zw.se2.hy.zuul.UserLoginInfoCache;
import com.zw.se2.hy.zuul.filter.ConstantPath;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestTemplate;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;

import static org.springframework.util.ReflectionUtils.rethrowRuntimeException;

@Component
public class LoginResponseFilter extends ZuulFilter {
      private static Logger log = LoggerFactory.getLogger("monitor");


    @Override
    public boolean shouldFilter() {
        RequestContext context = RequestContext.getCurrentContext();
        String url = context.getRequest().getRequestURL().toString();
        //只处理登录请求
        return StringUtils.endsWith(url, ConstantPath.LOGIN_PATH);

    }

    @Override
    public Object run() {
        try {
            RequestContext context = RequestContext.getCurrentContext();
            InputStream stream = context.getResponseDataStream();
            String body = StreamUtils.copyToString(stream, Charset.forName("UTF-8"));
            String url = context.getRequest().getRequestURL().toString();

                if (StringUtils.isNotBlank(body)) {
                    //验证响应结果是否为登录成功
                    JSONObject bodyJson = JSONObject.fromObject(body);
                    if (bodyJson.has(ConstantPath.LOGIN_RESPONSE_STATUS)) {
                        String status = bodyJson.getString(ConstantPath.LOGIN_RESPONSE_STATUS);
                        if (StringUtils.equals(status, "200")) {
                            if (bodyJson.has(ConstantPath.LOGIN_RESPONSE_RESULT)) {
                                JSONArray resultArray = bodyJson.getJSONArray(ConstantPath.LOGIN_RESPONSE_RESULT);
                                if (resultArray != null && resultArray.size() > 0) {
                                    JSONObject userObject = resultArray.getJSONObject(0);
                                    processLogin(context, userObject);
                                }
                            }
                        }


                }
            }

            context.setResponseBody(body);
        } catch (IOException e) {
            rethrowRuntimeException(e);
        }
        return null;
    } 

    private void processLogin(RequestContext context, JSONObject userObject) {
        if (userObject.has(ConstantPath.LOGIN_USERNAME)) {
            String userName = userObject.getString(ConstantPath.LOGIN_USERNAME);
            //将用户名存储在session中,判断用户是否登录
            HttpServletRequest request = context.getRequest();
            HttpSession session = request.getSession();
            session.setAttribute("userName", userName);
            session.setMaxInactiveInterval(1800);           
            log.info(">>>用户>>>" + userName + "执行了>>>登录>>>操作);
        }
    }

    @Override
    public String filterType() {
        return "post";
    }

    @Override
    public int filterOrder() {
        return 1;
    }

}

总结

可以看到只要理清思路Zuul和Cas的结合还是挺简单的,关键就是理清思路。

猜你喜欢

转载自blog.csdn.net/zhaoenweiex/article/details/80658913
今日推荐