SpringBoot:前后端分离情况下的单点登录(CAS) - MiZU

基础整合记录,方便下次梭哈。

近期公司某项目需要集成某中间平台的单点登录,增强员工跨系统操作的体验。

1.前提

后端:在已有的SpringBoot项目中(已集成SpringSecurity),引入CAS相关的依赖,并进行相应配置。

前端:需要改变登录逻辑。

本篇文章基于以下SDK、插件及版本。
Java - 1.8.0
Maven - 4.0.0
SpringBoot - 2.6.2

2.引入依赖

<!--    spring security    -->
<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>

3.CAS简介

CAS是一种单点登录开源框架,遵循apache2.0协议,代码托管在github上。

如上文所说,单点登录是方便用户在不同系统之间仅需一次登录便可操作不同的系统(不同源,即不同的IP及端口)的框架。

CAS登录流程在前后端不分离的情况下,按照官方给的源码就可以很方便的集成:

前后端不分离的CAS解决方案: java-cas-client

但是在前后端不分离的情况下集成,需要改动一些代码。
在此之前翻阅过很多大佬的帖子,多多少少有很多坑,有些大佬直接给出了“CAS不适合前后端分离的项目”的结论。

在集成时遇到的坑不少,但最终实现后发现改动的东西其实并不多。

CAS的流程并不复杂,引用大佬的一张图:

前后端分离的CAS验证流程
前后端分离的CAS验证流程

验证流程设计到三个模块,分别为本系统前端、本系统后端及单点登录CAS端,流程可以简单概括为三步。

第一步:前端访问后端,后端告知前端尚未登陆,需跳转至CAS端进行登录。

第二部:登录成功后,CAS端携带登录成功的ticket凭证跳转回前端。

第三步:前端拿到ticket访问后端进行验证,若验证成功则为登录成功。

4.代码改动

4.1.后端配置文件

application.yml:

cas:
  server: http://192.168.0.30:8080/cas
  client: http://localhost:9527

配置文件增加两个URL,其中cas.server为CAS端的调用地址,cas.client为本系统前端的地址。

4.2.后端代码

需要改动的文件除了pom.xml、application.yml外,一共三个:

SecurityConfig.java - 基于CAS调整SpringSecurity的配置

import com.alibaba.fastjson.JSON;
import com.example.service.impl.admin.structure.CasPersonServiceImpl;
import com.example.util.Response;
import org.jasig.cas.client.session.SingleSignOutFilter;
import org.jasig.cas.client.validation.Cas20ServiceTicketValidator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
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.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;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;

import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // 配置文件中的CAS服务器地址
    @Value("${cas.server}")
    private String casServerUrl;
    
    // 配置文件中的本应用前端地址
    @Value("${cas.client}")
    private String casClientUrl;

    // 自定义的用户认证入口类(用于处理未登录或登录超时的逻辑)
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;
    
    // 自定义的用户信息类(用于ticket验证成功后获取用户信息的逻辑)
    private final CasPersonServiceImpl casPersonService;

    /**
     * 构造函数
     */
    public SecurityConfig(
            CustomAuthenticationEntryPoint authenticationEntryPoint,
            CasPersonServiceImpl casPersonService) {
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.casPersonService = casPersonService;
    }

    /**
     * SpringSecurity配置
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 禁用CORS
                .cors().disable()
                // 禁用CSRF
                .csrf().disable()
                // 配置接口过滤网,放行/login/cas用于单点登录的验证
                .authorizeRequests()
                .antMatchers("/login/cas").permitAll()
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                .anyRequest().authenticated()
                .and()
                // 配置自定义的用户认证入口类(用于处理未登录或登录超时的逻辑)
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .and()
                // 配置自定义的CAS用户认证入口类
                .addFilter(casAuthenticationFilter())
                // 配置CAS需要用到的其他类
                .addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class)
                .addFilterBefore(casLogoutFilter(), LogoutFilter.class);
    }

    /**
     * CAS配置(AuthenticationProvider)
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        super.configure(auth);
        auth.authenticationProvider(casAuthenticationProvider());
    }

    /**
     * CAS:认证入口
     */
    @Bean
    public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
        CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint();
        casAuthenticationEntryPoint.setLoginUrl(casServerUrl + "/login");
        casAuthenticationEntryPoint.setServiceProperties(serviceProperties());
        return casAuthenticationEntryPoint;
    }

    /**
     * CAS:服务配置
     */
    @Bean
    public ServiceProperties serviceProperties() {
        ServiceProperties serviceProperties = new ServiceProperties();
        serviceProperties.setService(casClientUrl + "/login/cas");
        serviceProperties.setAuthenticateAllArtifacts(true);
        return serviceProperties;
    }

    /**
     * CAS:配置自定义的CAS用户认证入口类
     */
    @Bean
    public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
        CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter();
        casAuthenticationFilter.setAuthenticationManager(authenticationManager());
        casAuthenticationFilter.setFilterProcessesUrl("/login/cas");
        casAuthenticationFilter.setServiceProperties(serviceProperties());
        // 重要:此处为配置ticket验证成功后的逻辑,默认为重定向到首页,因前后端分离,仅需要返回成功即可。
        casAuthenticationFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            response.setStatus(HttpServletResponse.SC_OK);
            PrintWriter out = response.getWriter();
            out.write("{\"status\":" + "\"200\"" + "}");
        });
        return casAuthenticationFilter;
    }

    /**
     * CAS:CAS的核心,CasAuthenticationProvider
     */
    @Bean
    public CasAuthenticationProvider casAuthenticationProvider() {
        CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
        casAuthenticationProvider.setAuthenticationUserDetailsService(customUserDetailsService());
        casAuthenticationProvider.setServiceProperties(serviceProperties());
        casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());
        casAuthenticationProvider.setKey("EXAMPLE_CAS_PROVIDER");
        return casAuthenticationProvider;
    }

    /**
     * CAS:自定义的用户认证入口类(用于处理未登录或登录超时的逻辑)
     */
    @Bean
    public AuthenticationUserDetailsService<CasAssertionAuthenticationToken> customUserDetailsService() {
        return casPersonService;
    }

    /**
     * CAS:ticket验证类
     */
    @Bean
    public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
        return new Cas20ServiceTicketValidator(casServerUrl);
    }

    /**
     * CAS:SingleSignOutFilter
     */
    @Bean
    public SingleSignOutFilter singleSignOutFilter() {
        SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter();
        singleSignOutFilter.setIgnoreInitConfiguration(true);
        return singleSignOutFilter;
    }

    /**
     * CAS:LogoutFilter
     */
    @Bean
    public LogoutFilter casLogoutFilter() {
        LogoutFilter logoutFilter = new LogoutFilter(casServerUrl + "/logout?service=" + casClientUrl,
                new SecurityContextLogoutHandler());
        logoutFilter.setFilterProcessesUrl("/logout/cas");
        return logoutFilter;
    }

}

CustomAuthenticationEntryPoint.java - 自定义的用户认证入口类(用于处理未登录或登录超时的逻辑)

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.URLEncoder;

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    // 配置文件中的CAS服务器地址
    @Value("${cas.server}")
    private String casServerUrl;

    // 配置文件中的本应用前端地址
    @Value("${cas.client}")
    private String casClientUrl;

    /**
     * 处理未登录或登录超时的逻辑,因为项目前后端分离,此处直接返回一串地址,跳转至CAS端进行登录,
     * 同时携带参数service指定前端地址,方便登录成功后重定向回来。
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // 构造未登录情况需要跳转的login页面url
        // 登录地址(指定的一个后台controller接口)
        String encodeUrl = URLEncoder.encode(casClientUrl + "/login/cas", "utf-8");
        // CAS认证中心页面地址,参数service带上登录地址,登录成功后会带上ticket跳转回service指定的地址
        String redirectUrl = casServerUrl + "/login?service=" + encodeUrl;
        // 返回401
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        PrintWriter out = response.getWriter();
        // 返回与前端约定的格式,前端能获取到redirectUrl跳转即可
        out.write("{\"url\":" + "\"" + redirectUrl + "\"" + "}");
    }

}

CasPersonServiceImpl.java - 自定义的用户信息类(用于ticket验证成功后获取用户信息的逻辑)

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.config.SecurityToken;
import com.example.entity.admin.structure.Person;
import com.example.entity.admin.structure.PersonView;
import com.example.service.admin.structure.PersonService;
import com.example.service.admin.structure.PersonViewService;
import com.example.util.Strings;
import org.jasig.cas.client.validation.Assertion;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.cas.userdetails.AbstractCasAssertionUserDetailsService;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class CasPersonServiceImpl extends AbstractCasAssertionUserDetailsService {

    private final PersonService personService;

    public CasPersonServiceImpl(PersonService personService) {
        this.personService = personService;
    }

    /**
     * 此处为ticket验证成功后,使用CAS返回的用户名在本地获取用户数据的逻辑,可自定义。
     * 需要返回一个UserDetails,此处自定义了token类SecurityToken,
     * 继承自org.springframework.security.core.userdetails.User即可。
     */
    @Override
    protected UserDetails loadUserDetails(Assertion assertion) {
        // 查找用户
        String username = assertion.getPrincipal().getName();
        Person person = personService.getOne(new QueryWrapper<Person>().lambda().eq(Person::getUsername, username));
        if (person == null) throw new UsernameNotFoundException("用户不存在");
        if (person.getIsLocked() == 1) throw new LockedException("账户已锁定");
        if (!"ACTIVE".equals(person.getStatus())) throw new AccountExpiredException("账户已失效");
        // 查询角色
        List<GrantedAuthority> authorities = new ArrayList<>();
        if (person.getIsAdmin() == 1) authorities.add(new SimpleGrantedAuthority(Strings.ROLE_ADMIN));
        // 用户信息
        SecurityToken token = new SecurityToken(person.getUsername(), person.getPassword(), authorities);
        token.setInfo(person);
        return token;
    }

}

4.3.前端代码

集成时我作为后端coding,前端代码不是我写,此处则不贴出。

总体前端部分需要处理的的逻辑为:

1.访问后端时若发现未登录则跳转至CAS。

2.CAS重定向回来时,需要处理URL中的ticket,调用后端的/login/cas(后端Security、CAS配置放行的链接)验证ticket。

3.验证成功后会自动返回set-cookie的头,里边包含了sessionId,后续正常访问接口时则会判断为已登录。

评论