办个人网站租空间wordpress邮件发送不出去
一、项目背景
公司需要搭建单点登录服务, 使所有系统共用一套登录逻辑.
对比了多个方案之后, 选用了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。
然后是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单点登录的总体流程, 流程并不复杂,引用大佬的一张图:
前后端分离的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/casclient: 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配置*/@Overrideprotected 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)*/@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {super.configure(auth);auth.authenticationProvider(casAuthenticationProvider());}/*** CAS:认证入口*/@Beanpublic CasAuthenticationEntryPoint casAuthenticationEntryPoint() {CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint();casAuthenticationEntryPoint.setLoginUrl(casServerUrl + "/login");casAuthenticationEntryPoint.setServiceProperties(serviceProperties());return casAuthenticationEntryPoint;}/*** CAS:服务配置*/@Beanpublic ServiceProperties serviceProperties() {ServiceProperties serviceProperties = new ServiceProperties();// 此处填入前端登录页面的地址serviceProperties.setService(casClientUrl + "/#/login/cas");serviceProperties.setAuthenticateAllArtifacts(true);return serviceProperties;}/*** CAS:配置自定义的CAS用户认证入口类*/@Beanpublic 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*/@Beanpublic CasAuthenticationProvider casAuthenticationProvider() {CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();casAuthenticationProvider.setAuthenticationUserDetailsService(customUserDetailsService());casAuthenticationProvider.setServiceProperties(serviceProperties());casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());casAuthenticationProvider.setKey("EXAMPLE_CAS_PROVIDER");return casAuthenticationProvider;}/*** CAS:自定义的用户认证入口类(用于处理未登录或登录超时的逻辑)*/@Beanpublic AuthenticationUserDetailsService<CasAssertionAuthenticationToken> customUserDetailsService() {return casPersonService;}/*** CAS:ticket验证类*/@Beanpublic Cas20ServiceTicketValidator cas20ServiceTicketValidator() {return new Cas20ServiceTicketValidator(casServerUrl);}/*** CAS:SingleSignOutFilter*/@Beanpublic SingleSignOutFilter singleSignOutFilter() {SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter();singleSignOutFilter.setIgnoreInitConfiguration(true);return singleSignOutFilter;}/*** CAS:LogoutFilter*/@Beanpublic 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即可。*/@Overrideprotected 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 || '/' })