基于 spring-security-cas 的 Cas 协议接入(仅支持Spring Boot)

spring-security-cas 是 Spring Security 对 cas-client-core 客户端的整合,适用于使用 Spring Security 作为权限拦截的系统,基于它可快速的实现统一身份认证平台的集成!

第1步:引入依赖

在项目依赖管理中引入 spring-security-cas 依赖 :

Maven 依赖
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <version>2.7.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-cas -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-cas</artifactId>
    <version>5.7.2</version>
</dependency>
Gradle 依赖
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '2.7.2'
// https://mvnrepository.com/artifact/org.springframework.security/spring-security-cas
implementation group: 'org.springframework.security', name: 'spring-security-cas', version: '5.7.2'
第2步:项目配置

首先新建一个YmlProperties类来读取 application.yml 的相关配置

@Component
@Data
public class YmlProperties {

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

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

  @Value("${app.logout.url}")
  private String appLogoutUrl;

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

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

  @Value("${cas.server.logout_url}")
  private String casServerLogoutUrl;
}

新建UserDetail类封装我们需要的用户信息

注意如果没有相关需求,此类可以不建,直接使用spring security提供的org.springframework.security.core.userdetails.User类。

public class UserDetail extends User implements UserDetails, CredentialsContainer {

  private Long userId;

  public UserDetail(String username,String password, Collection<? extends GrantedAuthority> authorities){
    super(username,password,true,true,true,true,authorities);
  }

  public Long getUserId() {
    return userId;
  }

  public void setUserId(Long userId) {
    this.userId = userId;
  }
}

新建 MyUserDetailsService 类实现 AuthenticationUserDetailsService 接口

此处的实现主要是为了给Cas客户端提供用户信息,以及相关的用户权限信息

@Service
public class MyUserDetailsService implements AuthenticationUserDetailsService<CasAssertionAuthenticationToken>{

  @Autowired
  private UserService userService;

  @Autowired
  private PermissionMapper permissionMapper;

  @Override
  public UserDetails loadUserDetails(CasAssertionAuthenticationToken token)
      throws UsernameNotFoundException {
    User user = userService.findByName(token.getName());
    if (Objects.isNull(user)) {
      throw new RuntimeException("用户不存在");
    }
List<String> roleCodes = permissionMapper.getPermissionByUser(user.getId());
    if (CollectionUtils.isEmpty(roleCodes)) {
      throw new RuntimeException("账户[" + user.getUsername() + "]未绑定角色");
    }
    Set<GrantedAuthority> authorities = new HashSet<>();
    for (String roleCode : roleCodes) {
      authorities.add(new SimpleGrantedAuthority(roleCode));
    }

    UserDetail userDetail=new UserDetail(user.getUsername(),user.getPassword(),authorities);
    userDetail.setUserId(user.getId());
    return userDetail;
  }

}

新建 SecurityCasConfiguration 类实现 WebSecurityConfigurerAdapter接口

@Slf4j
@EnableWebSecurity
//开启@Secured和@PreAuthorize
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@Configuration
public class SecurityCasConfiguration extends WebSecurityConfigurerAdapter {

  @Autowired
  private YmlProperties ymlProperties;

  @Autowired
  private MyUserDetailsService myUserDetailsService;

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests() //配置安全策略
        .antMatchers("/login").permitAll()
        .anyRequest().authenticated()
        .and()
        .csrf().disable()
        .logout().permitAll() //logout不需要验证
        .and()
        .headers().frameOptions().disable()
        .and()
        .cors()
        .and().formLogin(); //使用form表单登录

    http.exceptionHandling().authenticationEntryPoint(myCasAuthenticationEntryPoint)
        .and()
        .addFilter(casAuthenticationFilter())
        .addFilterBefore(casLogoutFilter(), LogoutFilter.class)
        .addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class);
  }


  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    super.configure(auth);
    auth.authenticationProvider(casAuthenticationProvider());
  }

  /**
   * 主要配置的是ServiceProperties的service属性,它指定的是cas回调的地址
   */
  @Bean
  public ServiceProperties serviceProperties() {
    ServiceProperties serviceProperties = new ServiceProperties();
    serviceProperties.setService(ymlProperties.getAppServerUrl() + ymlProperties.getAppLoginUrl());
    serviceProperties.setSendRenew(false);
    serviceProperties.setAuthenticateAllArtifacts(true);
    return serviceProperties;
  }

  /**
   * 认证的入口,指定cas服务器的登录地址,指定ServiceProperties(主要是获取回调地址)
   */
  @Bean
  public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
    CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint();
    casAuthenticationEntryPoint
        .setLoginUrl(ymlProperties.getCasServerLoginUrl());
    casAuthenticationEntryPoint.setServiceProperties(serviceProperties());
    return casAuthenticationEntryPoint;
  }


  @Bean
  public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
    CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter();
    casAuthenticationFilter.setServiceProperties(serviceProperties());    casAuthenticationFilter.setFilterProcessesUrl(ymlProperties.getAppLoginUrl());
    casAuthenticationFilter.setAuthenticationManager(authenticationManager());

    //指定登录成功后跳转页面,也可以使用SavedRequestAwareAuthenticationSuccessHandler
//    SavedRequestAwareAuthenticationSuccessHandler handler=new SavedRequestAwareAuthenticationSuccessHandler();
//    handler.setRedirectStrategy(new MyRedirectStrategy());
//    casAuthenticationFilter.setAuthenticationSuccessHandler(handler);
    casAuthenticationFilter.setAuthenticationSuccessHandler(
        new SimpleUrlAuthenticationSuccessHandler(
            ymlProperties.getAppServerUrl() + "/hello"));
    casAuthenticationFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy());
    return casAuthenticationFilter;
  }

//  class MyRedirectStrategy extends DefaultRedirectStrategy {
//
//    @Override
//    public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url)
//        throws IOException {
//      String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
//      redirectUrl = response.encodeRedirectURL(redirectUrl);
//      log.info(redirectUrl);
//      if(redirectUrl.startsWith("http://")){
//        if(redirectUrl.contains("/app/login")){
//          redirectUrl="http://localhost:8080/test/index";
//        }
//      }
//      response.sendRedirect(redirectUrl);
//    }
//  }


  @Bean
  public CasAuthenticationProvider casAuthenticationProvider() {
    CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
    casAuthenticationProvider.setServiceProperties(serviceProperties());
    casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());
    casAuthenticationProvider
        .setAuthenticationUserDetailsService(myUserDetailsService);
    casAuthenticationProvider.setKey("casAuthenticationProviderKey");
    return casAuthenticationProvider;
  }


  /**
   * 验证ticker,向cas服务器发送验证请求
   */
  @Bean
  public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
    //转Https请求
//    HttpURLConnectionFactory httpURLConnectionFactory=new HttpURLConnectionFactory() {
//      @Override
//      public HttpURLConnection buildHttpURLConnection(URLConnection urlConnection) {
//        SSLContext sslContext=null;
//        try {
//          sslContext = SSLContext.getInstance("SSL");
//          sslContext.init(new KeyManager[]{},new TrustManager[]{new X509TrustManager() {
//            @Override
//            public void checkClientTrusted(X509Certificate[] x509Certificates, String s)
//                throws CertificateException {
//
//            }
//
//            @Override
//            public void checkServerTrusted(X509Certificate[] x509Certificates, String s)
//                throws CertificateException {
//
//            }
//
//            @Override
//            public X509Certificate[] getAcceptedIssuers() {
//              return new X509Certificate[0];
//            }
//          }},null);
//          SSLSocketFactory socketFactory=sslContext.getSocketFactory();
//
//          if(urlConnection instanceof HttpsURLConnection){
//            HttpsURLConnection httpsURLConnection=(HttpsURLConnection)urlConnection;
//            httpsURLConnection.setSSLSocketFactory(socketFactory);
//            httpsURLConnection.setHostnameVerifier((s,l)->true);
//          }
//
//          return (HttpURLConnection)urlConnection;
//
//        }catch (Exception e){
//          throw new RuntimeException(e);
//        }
//      }
//    };
    Cas20ServiceTicketValidator cas20ServiceTicketValidator = new Cas20ServiceTicketValidator(
        ymlProperties.getCasServerUrl());
    cas20ServiceTicketValidator.setEncoding("UTF-8");
    return cas20ServiceTicketValidator;
  }


  @Bean
  public SessionAuthenticationStrategy sessionAuthenticationStrategy() {
    SessionAuthenticationStrategy sessionAuthenticationStrategy = new SessionFixationProtectionStrategy();
    return sessionAuthenticationStrategy;
  }


  /**
   * 此过滤器向cas发送登出请求
   */
  @Bean
  public SingleSignOutFilter singleSignOutFilter() {
    SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter();
    singleSignOutFilter.setCasServerUrlPrefix(ymlProperties.getCasServerUrl());
    singleSignOutFilter.setIgnoreInitConfiguration(true);
    return singleSignOutFilter;
  }

  /**
   * 此过滤器拦截客户端的logout请求,发现logout请求后向cas服务器发送登出请求
   */
  @Bean
  public LogoutFilter casLogoutFilter() {
    LogoutFilter logoutFilter = new LogoutFilter(ymlProperties.getCasServerLogoutUrl(),
        new SecurityContextLogoutHandler());
    logoutFilter.setFilterProcessesUrl(ymlProperties.getAppLogoutUrl());
    return logoutFilter;
  }

  /**
   * 去除@Secured的前缀 "ROLE_"
   * @return
   */
  @Bean
  public GrantedAuthorityDefaults grantedAuthorityDefaults() {
    return new GrantedAuthorityDefaults("");
  }

}

新建 HelloController

@Slf4j
@RestController
public class HelloController {

  @Secured("security_index") 
  //@PreAuthorize("hasAnyAuthority('security_index')")
  @GetMapping("/hello")
  public String hello() {
    return "首页";
  }
}

浏览器访问 http://localhost:8080/test/login 如果未登录会跳转到CAS服务端登录页。

前后端分离项目遇到的问题

对于前后端分离面临的问题是后端不干涉前端页面跳转,在退出登录后,访问前端页面仍可以访问,前端向后端发送请求后端拦截重定向到CAS服务端地址,但是前端页面跳转失败。虽说页面数据是不会加载,但是这不符合我们希望实现的。
目前想到通过实现AuthenticationEntryPoint并重定向到指定接口,返回页面
CAS服务端跳转地址,前端通过拦截器在每个页面访问时都会向后端指定接口发送请求,如果没有登录就会返回CAS服务端地址,登录后直接放行,具体判断我们通过返回状态码来实现。

新建 HelloController

方法中Result类是自定义的统一返回json格式,我会在下面贴出来。

@Slf4j
@RestController
public class HelloController {

  @Autowired
  private YmlProperties ymlProperties;

  /**
   * 适用前后端分离
   * 当未登录时重定向到此请求,返回给前端CAS服务器登录地址,通过前端跳转
   * @return
   */
  @GetMapping("/send")
  public Result send() {
    String url =
        ymlProperties.getCasServerLoginUrl() + "?service=" + ymlProperties.getAppServerUrl()
            + ymlProperties.getAppLoginUrl();
    return Result.failed().setCode(444).setData(url).setMessage("未登录").setSuccess(false);
  }

  /**
   * 适用前后端分离
   * 当登录成功后返回前端数据
   * @return
   */
  @GetMapping("/login")
  public Result login(){
    return Result.success(null,"已登录");
  }
}

Result统一返回json格式

public interface IErrorCode {
    long getCode();
    String getMessage();
}

public enum ResultCode implements IErrorCode {
    FAILED(500,"操作失败"),
    SUCCESS(200,"操作成功");
    private long code;
    private String message;

    private ResultCode(long code,String message){
        this.code=code;
        this.message=message;
    }

    @Override
    public long getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }
}

Result

@Data
@Accessors(chain = true)
public class Result<T> {
    private boolean success;
    private long code;
    private String message;
    private T data;

    private Result() {
    }
    private Result(boolean success,long code,String message,T data){
        this.success=success;
        this.code=code;
        this.message=message;
        this.data=data;
    }

    /**
     * 成功返回结果
     * @param data  获取的数据
     */
    public static <T> Result<T> success(T data){
        return new Result<T>(true,ResultCode.SUCCESS.getCode(),ResultCode.SUCCESS.getMessage(),data);
    }

    /**
     * 成功返回结果
     * @param data  获取的数据
     * @param message   提示信息
     */
    public static <T> Result<T> success(T data,String message){
        return new Result<>(true,ResultCode.SUCCESS.getCode(),message,data);
    }

    /**
     * 失败返回结果
     * @param <T>
     * @return
     */
    public static <T> Result<T> failed(){
        return new Result<>(false,ResultCode.FAILED.getCode(),ResultCode.FAILED.getMessage(),null);
    }

    /**
     * 失败返回结果
     * @param message 提示信息
     */
    public static <T> Result<T> failed(String message){
        return new Result<>(false,ResultCode.FAILED.getCode(),message,null);
    }
}

新建MyCasAuthenticationEntryPoint类实现AuthenticationEntryPoint

@Component
public class MyCasAuthenticationEntryPoint implements AuthenticationEntryPoint {

  @Autowired
  private YmlProperties ymlProperties;

  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException authException) throws IOException, ServletException {
    response.sendRedirect(ymlProperties.getAppServerUrl()+"/send");
  }
}

重写 SecurityCasConfiguration 配置类

@Slf4j
@EnableWebSecurity
//开启@Secured和@PreAuthorize
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@Configuration
public class SecurityCasConfiguration extends WebSecurityConfigurerAdapter {

  @Autowired
  private YmlProperties ymlProperties;

  @Autowired
  private MyCasAuthenticationEntryPoint myCasAuthenticationEntryPoint;

  @Autowired
  private MyUserDetailsService myUserDetailsService;

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests() //配置安全策略
        .antMatchers("/login","/send").permitAll() 
        .anyRequest().authenticated() //所有请求都要验证
        .and()
        .csrf().disable()
        .logout().permitAll() //logout不需要验证
        .and()
        .headers().frameOptions().disable()
        .and()
        .cors()
        .and().formLogin(); //使用form表单登录

    http.exceptionHandling().authenticationEntryPoint(myCasAuthenticationEntryPoint)
        .and()
        .addFilter(casAuthenticationFilter())
        .addFilterBefore(casLogoutFilter(), LogoutFilter.class)
        .addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class);
  }


  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    super.configure(auth);
    auth.authenticationProvider(casAuthenticationProvider());
  }

  /**
   * 主要配置的是ServiceProperties的service属性,它指定的是cas回调的地址
   */
  @Bean
  public ServiceProperties serviceProperties() {
    ServiceProperties serviceProperties = new ServiceProperties();
    serviceProperties.setService(ymlProperties.getAppServerUrl() + ymlProperties.getAppLoginUrl());
    serviceProperties.setSendRenew(false);
    serviceProperties.setAuthenticateAllArtifacts(true);
    return serviceProperties;
  }

  /**
   * 认证的入口,指定cas服务器的登录地址,指定ServiceProperties(主要是获取回调地址)
   */
//  @Bean
//  public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
//    CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint();
//    casAuthenticationEntryPoint
//        .setLoginUrl(casProperties.getCasServerLoginUrl());
//    casAuthenticationEntryPoint.setServiceProperties(serviceProperties());
//    return casAuthenticationEntryPoint;
//  }


  @Bean
  public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
    CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter();
    casAuthenticationFilter.setServiceProperties(serviceProperties());
    casAuthenticationFilter.setFilterProcessesUrl(ymlProperties.getAppLoginUrl());
    casAuthenticationFilter.setAuthenticationManager(authenticationManager());

    //指定登录成功后跳转页面,也可以使用SavedRequestAwareAuthenticationSuccessHandler
//    SavedRequestAwareAuthenticationSuccessHandler handler=new SavedRequestAwareAuthenticationSuccessHandler();
//    handler.setRedirectStrategy(new MyRedirectStrategy());
//    casAuthenticationFilter.setAuthenticationSuccessHandler(handler);
    casAuthenticationFilter.setAuthenticationSuccessHandler(
        new SimpleUrlAuthenticationSuccessHandler(
            ymlProperties.getAppServerUrl() + "/hello"));
    casAuthenticationFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy());
    return casAuthenticationFilter;
  }

//  class MyRedirectStrategy extends DefaultRedirectStrategy {
//
//    @Override
//    public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url)
//        throws IOException {
//      String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
//      redirectUrl = response.encodeRedirectURL(redirectUrl);
//      log.info(redirectUrl);
//      if(redirectUrl.startsWith("http://")){
//        if(redirectUrl.contains("/app/login")){
//          redirectUrl="http://10.11.36.21:8080/test/hello";
//        }
//      }
//      response.sendRedirect(redirectUrl);
//    }
//  }


  @Bean
  public CasAuthenticationProvider casAuthenticationProvider() {
    CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
    casAuthenticationProvider.setServiceProperties(serviceProperties());
    casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());
    casAuthenticationProvider
        .setAuthenticationUserDetailsService(myUserDetailsService);
    casAuthenticationProvider.setKey("casAuthenticationProviderKey");
    return casAuthenticationProvider;
  }


  /**
   * 验证ticker,向cas服务器发送验证请求
   */
  @Bean
  public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
    //转Https请求
//    HttpURLConnectionFactory httpURLConnectionFactory=new HttpURLConnectionFactory() {
//      @Override
//      public HttpURLConnection buildHttpURLConnection(URLConnection urlConnection) {
//        SSLContext sslContext=null;
//        try {
//          sslContext = SSLContext.getInstance("SSL");
//          sslContext.init(new KeyManager[]{},new TrustManager[]{new X509TrustManager() {
//            @Override
//            public void checkClientTrusted(X509Certificate[] x509Certificates, String s)
//                throws CertificateException {
//
//            }
//
//            @Override
//            public void checkServerTrusted(X509Certificate[] x509Certificates, String s)
//                throws CertificateException {
//
//            }
//
//            @Override
//            public X509Certificate[] getAcceptedIssuers() {
//              return new X509Certificate[0];
//            }
//          }},null);
//          SSLSocketFactory socketFactory=sslContext.getSocketFactory();
//
//          if(urlConnection instanceof HttpsURLConnection){
//            HttpsURLConnection httpsURLConnection=(HttpsURLConnection)urlConnection;
//            httpsURLConnection.setSSLSocketFactory(socketFactory);
//            httpsURLConnection.setHostnameVerifier((s,l)->true);
//          }
//
//          return (HttpURLConnection)urlConnection;
//
//        }catch (Exception e){
//          throw new RuntimeException(e);
//        }
//      }
//    };
    Cas20ServiceTicketValidator cas20ServiceTicketValidator = new Cas20ServiceTicketValidator(
        ymlProperties.getCasServerUrl());
    cas20ServiceTicketValidator.setEncoding("UTF-8");
    return cas20ServiceTicketValidator;
  }


  @Bean
  public SessionAuthenticationStrategy sessionAuthenticationStrategy() {
    SessionAuthenticationStrategy sessionAuthenticationStrategy = new SessionFixationProtectionStrategy();
    return sessionAuthenticationStrategy;
  }


  /**
   * 此过滤器向cas发送登出请求
   */
  @Bean
  public SingleSignOutFilter singleSignOutFilter() {
    SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter();
    singleSignOutFilter.setCasServerUrlPrefix(ymlProperties.getCasServerUrl());
    singleSignOutFilter.setIgnoreInitConfiguration(true);
    return singleSignOutFilter;
  }

  /**
   * 此过滤器拦截客户端的logout请求,发现logout请求后向cas服务器发送登出请求
   */
  @Bean
  public LogoutFilter casLogoutFilter() {
    LogoutFilter logoutFilter = new LogoutFilter(ymlProperties.getCasServerLogoutUrl(),
        new SecurityContextLogoutHandler());
    logoutFilter.setFilterProcessesUrl(ymlProperties.getAppLogoutUrl());
    return logoutFilter;
  }

  /**
   * 取出@Secured的前缀 "ROLE_"
   * @return
   */
  @Bean
  public GrantedAuthorityDefaults grantedAuthorityDefaults() {
    return new GrantedAuthorityDefaults("");
  }

}

前端只需要访问/login 请求,登录就会返回200状态码和“已登录”提示信息,未登录后端就会重定向/send请求,并返回444状态码和CAS服务端地址,前端根据444状态码拿到CAS服务端地址并跳转页面。

注意: 未登录时后端会重定向一次,可自己通过修改重定向地址,携带数据给前端。

参考:https://blog.csdn.net/new_ord/article/details/109709181

作者:Jeebiz  创建时间:2022-07-23 18:43
最后编辑:Jeebiz  更新时间:2024-05-07 20:29