#SpringBoot #Vue #CAS #单点登录 #前后端分离
# 前言
因为组里来了新的前端,所以新开的项目就都准备用前后端分离的方式来做,原本还挺爽的,只用写自己接口就完事了,但是到了对接甲方 CAS 的时候就出了问题。
# CAS 认证流程
官网给出的认证流程如下,
TGC,TGT,ST 之类的概念就不说了,网上一搜一大堆,简单来说就是访问网站后没登陆就跳到 cas 认证中心,登录完会在浏览器存一个 cookie 叫 TGC(用来标识你登陆过了,以及后续获取 ticket),跳转到你的服务上,并且 url 后面会携带一个 ticket,也就是 ST,client 接收到这次请求后(一般是通过 filter 拦截地址,而地址默认是 /cas/login,当然也可以自己设置),会把 ticket 发送给 cas server 去验证,验证通过后返回 xml 结构的用户信息。
# 后端流程
前后端分离对接 CAS 的流程一般是前端负责跳转登录页面,并且接收登录之后返回的 ticket,将其传给后端,后端在接收到 ticket 之后进行验证,验证成功后返回给前端一个标识,用来判断接下来的请求是否已经认证。
后端认证的话有两种方式,一种是通过 cas-client-core 或者 Spring Security 进行配置,配置完之后只需要给前端 /login 接口和 /logout 接口即可,当然这个地址也可以用过 setFilterProcessesUrl 来自定义。
另一种方式自己写一个接口,直接请求 CAS Server 的验证票据接口,认证成功后返回给前端一个 token,证明已经认证成功。
# 一、Spring Security 代码实现
# 1. Spring Security 配置
@Configuration | |
@EnableWebSecurity | |
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) | |
public class CasSecurityConfig extends WebSecurityConfigurerAdapter { | |
@Value("${cas.server-host-url}") | |
private String serverHostUrl; | |
@Value("${cas.server-host-login-url}") | |
private String serverHostLoginUrl; | |
@Value("${cas.client-host-url}") | |
private String clientHostUrl; | |
@Autowired | |
private CustomUserDetailsService customUserDetailsService; | |
@Autowired | |
private CustomAuthenticationEntryPoint unAuthenticationEntrypoint; | |
@Autowired | |
private LogoutSuccessHandlerImpl logoutSuccessHandler; | |
@Autowired | |
private CasAuthenticationSuccessHandler casAuthenticationSuccessHandler; | |
@Autowired | |
private JwtAuthenticationTokenFilter authenticationTokenFilter; | |
@Override | |
protected void configure(HttpSecurity httpSecurity) throws Exception { | |
httpSecurity | |
.cors().disable() | |
.csrf().disable() | |
// 过滤请求 | |
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() | |
.authorizeRequests() | |
.antMatchers( | |
HttpMethod.GET, | |
"/*.html", | |
"/**/*.js", | |
"/profile/**", | |
"/error", | |
"/api/**", | |
"/**/*.css", | |
"/**/*.png", | |
"/**/*.jpg", | |
"/**/*.jpeg", | |
"/**/*.gif", | |
"/**/*.ico", | |
"/font/**" | |
).permitAll() | |
.antMatchers("/swagger-ui.html").anonymous() | |
.antMatchers("/swagger-resources/**").anonymous() | |
.antMatchers("/*/api-docs").anonymous() | |
.antMatchers("/druid/**").anonymous() | |
// 除上面外的所有请求全部需要鉴权认证 | |
.anyRequest().authenticated() | |
.and() | |
.headers().frameOptions().disable(); | |
httpSecurity.exceptionHandling() | |
.authenticationEntryPoint(unAuthenticationEntrypoint).and() | |
.addFilterAt(casAuthenticationFilter(), CasAuthenticationFilter.class) | |
.addFilterBefore(authenticationTokenFilter, CasAuthenticationFilter.class) | |
.addFilterBefore(logoutFilter(), LogoutFilter.class) | |
.addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class); | |
} | |
@Override | |
protected void configure(AuthenticationManagerBuilder auth) throws Exception { | |
super.configure(auth); | |
auth.authenticationProvider(casAuthenticationProvider()); | |
} | |
@Bean | |
public CasAuthenticationEntryPoint authenticationEntryPoint() { | |
CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint(); | |
casAuthenticationEntryPoint.setLoginUrl(serverHostLoginUrl); | |
casAuthenticationEntryPoint.setServiceProperties(serviceProperties()); | |
return casAuthenticationEntryPoint; | |
} | |
@Bean | |
@Override public AuthenticationManager authenticationManagerBean() throws Exception { | |
return super.authenticationManagerBean(); | |
} | |
@Bean | |
public ServiceProperties serviceProperties() { | |
ServiceProperties serviceProperties = new ServiceProperties(); | |
serviceProperties.setService(clientHostUrl + "/login"); | |
serviceProperties.setAuthenticateAllArtifacts(true); | |
return serviceProperties; | |
} | |
@Bean | |
public TicketValidator ticketValidator() { | |
return new Cas20ProxyTicketValidator(serverHostUrl); | |
} | |
@Bean | |
public CasAuthenticationProvider casAuthenticationProvider() { | |
CasAuthenticationProvider provider = new CasAuthenticationProvider(); | |
provider.setServiceProperties(this.serviceProperties()); | |
provider.setTicketValidator(this.ticketValidator()); | |
provider.setAuthenticationUserDetailsService(customUserDetailsService); | |
provider.setKey("CAS_PROVIDER_KEY"); | |
return provider; | |
} | |
@Bean | |
public CasAuthenticationFilter casAuthenticationFilter() throws Exception { | |
CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter(); | |
casAuthenticationFilter.setAuthenticationManager(authenticationManager()); | |
casAuthenticationFilter.setFilterProcessesUrl("/login"); | |
casAuthenticationFilter.setAuthenticationSuccessHandler(casAuthenticationSuccessHandler); | |
return casAuthenticationFilter; | |
} | |
@Bean | |
public SecurityContextLogoutHandler securityContextLogoutHandler() { | |
return new SecurityContextLogoutHandler(); | |
} | |
@Bean | |
public LogoutFilter logoutFilter() { | |
LogoutFilter logoutFilter = new LogoutFilter(logoutSuccessHandler, | |
securityContextLogoutHandler()); | |
logoutFilter.setFilterProcessesUrl("/logout"); | |
return logoutFilter; | |
} | |
@Bean | |
public SingleSignOutFilter singleSignOutFilter() { | |
SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter(); | |
singleSignOutFilter.setIgnoreInitConfiguration(true); | |
return singleSignOutFilter; | |
} | |
} |
# 2. 修改登录跳转
因为项目是前后端分离的方式,肯定是不会出现用户自行访问后端接口地址,然后跳转登录页面的情况,所以就需要修改 authenticationEntryPoint,使其返回一个登录地址,让前端去跳转。
@Component | |
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { | |
// 配置文件中的 CAS 服务器地址 | |
@Value("${cas.server-host-login-url}") | |
private String serverHostLoginUrl; | |
// 配置文件中的本应用前端地址 | |
@Value("${cas.client-host-url}") | |
private String clientHostUrl; | |
/** | |
* 处理未登录或登录超时的逻辑,因为项目前后端分离,此处直接返回一串地址,跳转至 CAS 端进行登录, | |
*/ | |
@Override | |
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { | |
// 构造未登录情况需要跳转的 login 页面 url | |
// 登录地址(指定的一个后台 controller 接口) | |
String encodeUrl = URLEncoder.encode(clientHostUrl + "/login", "utf-8"); | |
// CAS 认证中心页面地址,参数 service 带上登录地址,登录成功后会带上 ticket 跳转回 service 指定的地址 | |
String redirectUrl = serverHostLoginUrl + "?service=" + encodeUrl; | |
response.setStatus(HttpServletResponse.SC_OK); | |
response.setHeader("content-type", "application/json;charset=UTF-8"); | |
response.setCharacterEncoding("UTF-8"); | |
PrintWriter out = response.getWriter(); | |
// 返回与前端约定的格式,前端能获取到 redirectUrl 跳转即可 | |
Result<String> unauthorized = Result.unauthorized(redirectUrl); | |
out.write(JSON.toJSONString(unauthorized)); | |
} | |
} |
前端设置了全局拦截器后,这样不管请求什么接口,一旦状态码为 401,就会自动跳转到登录地址。
# 3. 实现基于 Jwt 认证
@Component | |
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { | |
@Autowired | |
private TokenService tokenService; | |
@Override | |
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) | |
throws ServletException, IOException { | |
CasUserInfo casUserInfo = tokenService.getCasUser(request); | |
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); | |
if (ObjectUtils.isNotEmpty(casUserInfo) && ObjectUtils.isEmpty(authentication)) { | |
tokenService.verifyToken(casUserInfo); | |
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(casUserInfo, null, casUserInfo.getAuthorities()); | |
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); | |
SecurityContextHolder.getContext().setAuthentication(authenticationToken); | |
} | |
chain.doFilter(request, response); | |
} | |
} |
# 3. 登录成功处理
这一步是为了修改原本的成功跳转,改为生成 JWT token,并存储在 cookie 中。
@Service | |
public class CasAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { | |
private RequestCache requestCache = new HttpSessionRequestCache(); | |
@Autowired | |
private TokenService tokenService; | |
@Value("${cas.web-url}") | |
private String webUrl; | |
/** | |
* 令牌有效期(默认 30 分钟) | |
*/ | |
@Value("${token.expireTime}") | |
private int expireTime; | |
@Override | |
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, | |
Authentication authentication) throws ServletException, IOException { | |
String targetUrlParameter = getTargetUrlParameter(); | |
if (isAlwaysUseDefaultTargetUrl() | |
|| (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) { | |
requestCache.removeRequest(request, response); | |
super.onAuthenticationSuccess(request, response, authentication); | |
return; } | |
clearAuthenticationAttributes(request); | |
CasUserInfo userDetails = (CasUserInfo) authentication.getPrincipal(); | |
String token = tokenService.createToken(userDetails); | |
// 往 Cookie 中设置 token | |
Cookie casCookie = new Cookie(Constants.TOKEN, token); | |
casCookie.setMaxAge(expireTime * 60); | |
casCookie.setPath("/"); | |
response.addCookie(casCookie); | |
response.setContentType("application/json;charset=utf-8"); | |
PrintWriter writer = response.getWriter(); | |
writer.write(JSONObject.toJSONString(Result.success("登录成功",null))); | |
// 登录成功后跳转到前端登录页面 | |
// getRedirectStrategy().sendRedirect(request, response, webUrl); | |
} | |
} |
# 4. 单点登出
刚开始对接的时候,不知道是前端使用了异步请求导致后端无法正常重定向,还是本身就不好实现,登出跳转总是出问题,所以干脆改成返回 json,再由前端跳转,和登录的步骤基本一致
@Configuration | |
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler { | |
@Autowired | |
private TokenService tokenService; | |
@Value("${cas.server-host-url}") | |
private String casServerHostUrl; | |
@Value("${cas.client-host-url}") | |
private String casClientHostUrl; | |
/** | |
* 退出处理 | |
* | |
* @return | |
*/ | |
@Override | |
@SneakyThrows | |
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { | |
CasUserInfo casUserInfo=tokenService.getCasUser(request); | |
if (ObjectUtils.isNotEmpty(casUserInfo)) { | |
// 删除用户缓存记录 | |
tokenService.delLoginUser(casUserInfo.getToken()); | |
} | |
String url = casServerHostUrl + "/logout?service=" + casClientHostUrl; | |
JSONObject jsonObject = new JSONObject(); | |
jsonObject.put("logoutUrl", url); | |
ServletUtils.renderString(response, JSON.toJSONString(Result.success("退出成功",jsonObject))); | |
// response.sendRedirect(this.casServerHostUrl + "/logout?service=" + this.casClientHostUrl); | |
} | |
} |
# 5. 前端工作
前端需要做的是,根据后端返回的 json 跳转到登录页面,还有将登录成功后得到的 ticket 传给后端,以及从 cookie 里拿 token 并放在 header 里面。
# 二、serviceValidate 配置
这种方式就是直接去请求 CAS Server 提供的一个票据验证的接口,后端这边没啥要做的,就是请求接口,解析返回的 xml 数据,并且给前端一个 token 即可。这边直接拿 JeecgBoot 里面的代码看一下。
@Slf4j | |
@RestController | |
@RequestMapping("/sys/cas/client") | |
public class CasClientController { | |
@Autowired | |
private ISysUserService sysUserService; | |
@Autowired | |
private ISysDepartService sysDepartService; | |
@Autowired | |
private RedisUtil redisUtil; | |
@Value("${cas.prefixUrl}") | |
private String prefixUrl; | |
@GetMapping("/validateLogin") | |
public Object validateLogin(@RequestParam(name="ticket") String ticket, | |
@RequestParam(name="service") String service, | |
HttpServletRequest request, | |
HttpServletResponse response) throws Exception { | |
Result<JSONObject> result = new Result<>(); | |
log.info("Rest api login."); | |
try { | |
String validateUrl = prefixUrl+"/p3/serviceValidate"; | |
String res = CasServiceUtil.getStValidate(validateUrl, ticket, service); | |
log.info("res."+res); | |
final String error = XmlUtils.getTextForElement(res, "authenticationFailure"); | |
if(StringUtils.isNotEmpty(error)) { | |
throw new Exception(error); | |
} | |
final String principal = XmlUtils.getTextForElement(res, "user"); | |
if (StringUtils.isEmpty(principal)) { | |
throw new Exception("No principal was found in the response from the CAS server."); | |
} | |
log.info("-------token----username---"+principal); | |
//1. 校验用户是否有效 | |
SysUser sysUser = sysUserService.getUserByName(principal); | |
result = sysUserService.checkUserIsEffective(sysUser); | |
if(!result.isSuccess()) { | |
return result; | |
} | |
String token = JwtUtil.sign(sysUser.getUsername(), sysUser.getPassword()); | |
// 设置超时时间 | |
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, token); | |
redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME*2 / 1000); | |
// 获取用户部门信息 | |
JSONObject obj = new JSONObject(); | |
List<SysDepart> departs = sysDepartService.queryUserDeparts(sysUser.getId()); | |
obj.put("departs", departs); | |
if (departs == null || departs.size() == 0) { | |
obj.put("multi_depart", 0); | |
} else if (departs.size() == 1) { | |
sysUserService.updateUserDepart(principal, departs.get(0).getOrgCode()); | |
obj.put("multi_depart", 1); | |
} else { | |
obj.put("multi_depart", 2); | |
} | |
obj.put("token", token); | |
obj.put("userInfo", sysUser); | |
result.setResult(obj); | |
result.success("登录成功"); | |
} catch (Exception e) { | |
//e.printStackTrace(); | |
result.error500(e.getMessage()); | |
} | |
return new HttpEntity<>(result); | |
} | |
} |
需要注意的一点是,不同协议的票据验证地址是不一样的,比如 CAS3.0 的是 /p3/serviceValiate,CAS2.0 的则没有前面的 p3,而 1.0 的地址则是 /validate,并且接收的参数和返回的结构也有一些差别。