美文网首页CAS 单点登录
SprinBoot(SpringSecurity)+前后端分离

SprinBoot(SpringSecurity)+前后端分离

作者: 饱饱想要的灵感 | 来源:发表于2024-09-28 10:01 被阅读0次

一、项目背景

公司需要搭建单点登录服务, 使所有系统共用一套登录逻辑.

对比了多个方案之后, 选用了CAS. 因此首先要搭建CAS服务, 因为涉及到JDK、gradle等组件的版本,最终选择了6.3版本。

后端:SpringBoot 2.3.12(已集成SpringSecurity,并接入SpringCloud Alibaba微服务)
前端:vue2

二、CAS简介

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

单点登录使用户仅需一次登录便可操作所有系统(系统可以是不同源,即不同的域名、IP及端口)。

CAS登录在前后端不分离的情况下,官方已经给了源码示例

但是在前后端分离的情况下,改动的代码会多一些。

之前翻过很多"大佬"的帖子,多少都有坑,有些人甚至直接给出了CAS不适合前后端分离的结论?!简直震惊四座。

三、实现思路

首先是Spring Security的登录流程:

  1. 用户在浏览器发起请求web系统私有资源 /private;
  2. SecurityFilterChain过滤器链路到达FilterSecurityInterceptor,
  3. 并抛出访问被拒绝的异常AccessDeniedException,
  4. ExceptionTranslationFilter捕获该异常并通过sendStartAuthentication方法进入CasAuthenticationEntryPoint(AuthenticationEntryPoint的实现类)
  5. CasAuthenticationEntryPoint设置重定向到CAS Server地址https://cas.poop.com/cas/login?service=https%3A%2F%2Fportal.popo.com%2Fwebapp%2Flogin/cas
    Spring Security登录认证流程.jpg

然后是CAS认证的流程:

  1. 用户在CAS Server登录验证完后,携带ST跳转到客户端https://cas.poop.com/cas/login?service=https%3A%2F%2Fportal.popo.com%2Fwebapp%2Flogin/cas%3Fticket%3DST-0-ER94xMJmn6pha35CQRoZ) ,进入CasAuthenticationFilter;

  2. CasAuthenticationFilter将ST包装成UsernamePasswordAuthenticationToken请求AuthenticationManager进行认证处理;

  3. AuthenticationManager将认证委托给CasAuthenticationProvider;

  4. CasAuthenticationProvider使用TicketValidator向CAS Server发起ST校验请求https://cas.poop.com/cas/login?service=https%3A%2F%2Fportal.popo.com%2Fwebapp%2Flogin/cas&ticket=ST-0-ER94xMJmn6pha35CQRoZ,成功后获取用户登录信息。

  5. 最后,CasAuthenticationProvider使用AuthenticationUserDetailsService进行后置处理,一般获取更加详细的用户信息,例如权限等。

最后是CAS单点登录的总体流程, 流程并不复杂,引用大佬的一张图:

cas登录流程.png

前后端分离的CAS验证流程, 涉及到三个模块,分别为系统前端、系统后端及单点登录CAS服务,流程可以简单概括为三步:

  • 第一步:前端访问后端,后端发现未登陆,重定向至CAS服务进行登录。
  • 第二步:登录成功后,CAS服务携带登录成功的ticket凭证跳转回前端, 写入jsessionid。
  • 第三步:前端拿到ticket访问后端进行验证,若验证成功则为登录成功, 返回token。

四、后端代码实现

1. 引入依赖

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

2. 修改配置文件

application.yml:

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

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

3. 修改SpringSecurity代码

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 static final String[] PERMIT_URL = new String[]{"/login/cas", "/logout/cas", "/loginUser", "/bye", "/v2/**", "/permission"};

    // 自定义的用户信息类(用于ticket验证成功后获取用户信息的逻辑)
    private final CasPersonServiceImpl casPersonService;

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

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

    /**
     * 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\"" + "}");
        });
        casAuthenticationFilter.setAuthenticationFailureHandler((request, response, e) -> {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            PrintWriter out = response.getWriter();
            out.write("{\"code\":401" + ",\"message\":\"Ticket verified failed!\"}");
            logger.error("单点登录验证失败", e);
        });
        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;
    }

}

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;
    }

}

五、前端代码

前端部分需要处理的的逻辑大概为:

  1. 添加/login/cas页面, 当进入此页面时(CAS重定向回来), 获取地址中ticket参数, 注意要进行解码; 如果页面是路由的方式(例如/#/login/cas), 则url会是localhost:9527/myView?ticket=***/#/login/cas的格式, 需要将/#/login/cas去掉。

  2. 携带ticket参数访问后端的/login/cas, (后端Security注意放行该请求路径)验证ticket。

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

  4. 登录成功后跳转至index首页, 如果页面是路由的方式(例如/#/index), 则url会携带ticket参数, 形如localhost:9527/myView?ticket=***/#/index的格式, 需要将ticket去掉, 参考代码:

let newUrl = window.location.origin + '/' + window.location.hashwindow.history.pushState({}, '', newUrl);
this.$router.push({ path: this.redirect || '/' })

相关文章

网友评论

    本文标题:SprinBoot(SpringSecurity)+前后端分离

    本文链接:https://www.haomeiwen.com/subject/mxzhrjtx.html