基础整合记录,方便下次梭哈。
近期公司某项目需要集成某中间平台的单点登录,增强员工跨系统操作的体验。
1.前提
后端:在已有的SpringBoot项目中(已集成SpringSecurity),引入CAS相关的依赖,并进行相应配置。
前端:需要改变登录逻辑。
1 2 3 4
| 本篇文章基于以下SDK、插件及版本。 Java - 1.8.0 Maven - 4.0.0 SpringBoot - 2.6.2
|
2.引入依赖
/pom.xml1 2 3 4 5 6 7 8 9
| <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端携带登录成功的ticket凭证跳转回前端。
第三步:前端拿到ticket访问后端进行验证,若验证成功则为登录成功。
4.代码改动
4.1.后端配置文件
application.yml:
/resources/application.yml1 2 3
| 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的配置
/config/SecurityConfig.java1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
| 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 {
@Value("${cas.server}") private String casServerUrl; @Value("${cas.client}") private String casClientUrl;
private final CustomAuthenticationEntryPoint authenticationEntryPoint; private final CasPersonServiceImpl casPersonService;
public SecurityConfig( CustomAuthenticationEntryPoint authenticationEntryPoint, CasPersonServiceImpl casPersonService) { this.authenticationEntryPoint = authenticationEntryPoint; this.casPersonService = casPersonService; }
@Override protected void configure(HttpSecurity http) throws Exception { http .cors().disable() .csrf().disable() .authorizeRequests() .antMatchers("/login/cas").permitAll() .antMatchers(HttpMethod.OPTIONS).permitAll() .anyRequest().authenticated() .and() .exceptionHandling() .authenticationEntryPoint(authenticationEntryPoint) .and() .addFilter(casAuthenticationFilter()) .addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class) .addFilterBefore(casLogoutFilter(), LogoutFilter.class); }
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { super.configure(auth); auth.authenticationProvider(casAuthenticationProvider()); }
@Bean public CasAuthenticationEntryPoint casAuthenticationEntryPoint() { CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint(); casAuthenticationEntryPoint.setLoginUrl(casServerUrl + "/login"); casAuthenticationEntryPoint.setServiceProperties(serviceProperties()); return casAuthenticationEntryPoint; }
@Bean public ServiceProperties serviceProperties() { ServiceProperties serviceProperties = new ServiceProperties(); serviceProperties.setService(casClientUrl + "/login/cas"); serviceProperties.setAuthenticateAllArtifacts(true); return serviceProperties; }
@Bean public CasAuthenticationFilter casAuthenticationFilter() throws Exception { CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter(); casAuthenticationFilter.setAuthenticationManager(authenticationManager()); casAuthenticationFilter.setFilterProcessesUrl("/login/cas"); casAuthenticationFilter.setServiceProperties(serviceProperties()); casAuthenticationFilter.setAuthenticationSuccessHandler((request, response, authentication) -> { response.setStatus(HttpServletResponse.SC_OK); PrintWriter out = response.getWriter(); out.write("{\"status\":" + "\"200\"" + "}"); }); return casAuthenticationFilter; }
@Bean public CasAuthenticationProvider casAuthenticationProvider() { CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider(); casAuthenticationProvider.setAuthenticationUserDetailsService(customUserDetailsService()); casAuthenticationProvider.setServiceProperties(serviceProperties()); casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator()); casAuthenticationProvider.setKey("EXAMPLE_CAS_PROVIDER"); return casAuthenticationProvider; }
@Bean public AuthenticationUserDetailsService<CasAssertionAuthenticationToken> customUserDetailsService() { return casPersonService; }
@Bean public Cas20ServiceTicketValidator cas20ServiceTicketValidator() { return new Cas20ServiceTicketValidator(casServerUrl); }
@Bean public SingleSignOutFilter singleSignOutFilter() { SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter(); singleSignOutFilter.setIgnoreInitConfiguration(true); return singleSignOutFilter; }
@Bean public LogoutFilter casLogoutFilter() { LogoutFilter logoutFilter = new LogoutFilter(casServerUrl + "/logout?service=" + casClientUrl, new SecurityContextLogoutHandler()); logoutFilter.setFilterProcessesUrl("/logout/cas"); return logoutFilter; }
}
|
CustomAuthenticationEntryPoint.java - 自定义的用户认证入口类(用于处理未登录或登录超时的逻辑)
/config/CustomAuthenticationEntryPoint.java1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| 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 {
@Value("${cas.server}") private String casServerUrl;
@Value("${cas.client}") private String casClientUrl;
@Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { String encodeUrl = URLEncoder.encode(casClientUrl + "/login/cas", "utf-8"); String redirectUrl = casServerUrl + "/login?service=" + encodeUrl; response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); PrintWriter out = response.getWriter(); out.write("{\"url\":" + "\"" + redirectUrl + "\"" + "}"); }
}
|
CasPersonServiceImpl.java - 自定义的用户信息类(用于ticket验证成功后获取用户信息的逻辑)
/service/CasPersonServiceImpl.java1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| 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; }
@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,后续正常访问接口时则会判断为已登录。