一、项目背景
公司需要搭建单点登录服务, 使所有系统共用一套登录逻辑.
对比了多个方案之后, 选用了CAS. 因此首先要搭建CAS服务, 因为涉及到JDK、gradle等组件的版本,最终选择了6.3版本。
后端:SpringBoot 2.3.12(已集成SpringSecurity,并接入SpringCloud Alibaba微服务)
前端:vue2
二、CAS简介
CAS是一个单点登录的开源框架,遵循apache2.0协议,代码托管在github上。
单点登录使用户仅需一次登录便可操作所有系统(系统可以是不同源,即不同的域名、IP及端口)。
CAS登录在前后端不分离的情况下,官方已经给了源码示例
但是在前后端分离的情况下,改动的代码会多一些。
之前翻过很多"大佬"的帖子,多少都有坑,有些人甚至直接给出了CAS不适合前后端分离的结论?!简直震惊四座。
三、实现思路
首先是Spring Security的登录流程:
- 用户在浏览器发起请求web系统私有资源 /private;
- SecurityFilterChain过滤器链路到达FilterSecurityInterceptor,
- 并抛出访问被拒绝的异常AccessDeniedException,
- ExceptionTranslationFilter捕获该异常并通过sendStartAuthentication方法进入CasAuthenticationEntryPoint(AuthenticationEntryPoint的实现类)
- CasAuthenticationEntryPoint设置重定向到CAS Server地址https://cas.poop.com/cas/login?service=https%3A%2F%2Fportal.popo.com%2Fwebapp%2Flogin/cas。
Spring Security登录认证流程.jpg
然后是CAS认证的流程:
-
用户在CAS Server登录验证完后,携带ST跳转到客户端https://cas.poop.com/cas/login?service=https%3A%2F%2Fportal.popo.com%2Fwebapp%2Flogin/cas%3Fticket%3DST-0-ER94xMJmn6pha35CQRoZ) ,进入CasAuthenticationFilter;
-
CasAuthenticationFilter将ST包装成UsernamePasswordAuthenticationToken请求AuthenticationManager进行认证处理;
-
AuthenticationManager将认证委托给CasAuthenticationProvider;
-
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,成功后获取用户登录信息。
-
最后,CasAuthenticationProvider使用AuthenticationUserDetailsService进行后置处理,一般获取更加详细的用户信息,例如权限等。
最后是CAS单点登录的总体流程, 流程并不复杂,引用大佬的一张图:
![](https://img.haomeiwen.com/i25646623/f02b6c6fe7137c99.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;
}
}
五、前端代码
前端部分需要处理的的逻辑大概为:
-
添加
/login/cas
页面, 当进入此页面时(CAS重定向回来), 获取地址中ticket
参数, 注意要进行解码; 如果页面是路由的方式(例如/#/login/cas
), 则url会是localhost:9527/myView?ticket=***/#/login/cas
的格式, 需要将/#/login/cas
去掉。 -
携带
ticket
参数访问后端的/login/cas
, (后端Security注意放行该请求路径)验证ticket。 -
验证成功后会自动返回set-cookie的头,里边包含了jsessionId,后续正常访问接口时则会判断为已登录。
-
登录成功后跳转至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 || '/' })
网友评论