一、整合目的
账务核心系统迁移到小花环境,需要对接小花现有的统一权限系统做权限控制和单点登录。目前账务系统所使用的是Spring Boot+React的前后端分离项目,所以本篇文章的主要目的是整理在前后端分离的项目中怎样去整合cas,并对整合过程中遇到的问题做重点说明。
二、整合步骤
添加引用
这里省略了其他的引用,只列出了Spring-Security以及Spring-Security-CAS的引用。
org.springframework.bootgroupId> spring-boot-starter-securityartifactId>dependency> org.springframework.securitygroupId> spring-security-casartifactId>dependency>
添加配置
配置CAS和应用相关地址信息。
#CAS服务地址server.host.url= http://www.mycompany.com/cas#CAS服务登录地址server.host.login_url=${server.host.url}/login#CAS服务登出地址server.host.logout_url=${server.host.url}/logout#应用访问地址app.server.host.url=http://localhost:8083/account-view#应用登录地址app.login.url=/login/cas#应用登出地址app.logout.url=/logout#前端应用地址app.web.url=http://localhost:9000
新建⼀个对应的CasProperties类。
@Data@Componentpublic class CasProperties { @Value("${server.host.url}") private String casServerUrl; @Value("${server.host.login_url}") private String casServerLoginUrl; @Value("${server.host.logout_url}") private String casServerLogoutUrl; @Value("${app.server.host.url}") private String appServerUrl; @Value("${app.login.url}") private String appLoginUrl; @Value("${app.logout.url}") private String appLogoutUrl; @Value("${app.web.url}") private String appWebUrl;}
配置Spring Security及CAS
注意:在引入Spring Security时请在启动类上先禁用原有的KaelManagementWebSecurityAutoConfiguration。
@SpringBootApplication(exclude = KaelManagementWebSecurityAutoConfiguration.class)
WebSecurityConfig
@EnableWebSecurity@EnableGlobalMethodSecurity(prePostEnabled = true)public class WebSecurityConfig extends WebSecurityConfigurerAdapter { private final CasProperties casProperties; public WebSecurityConfig(CasProperties casProperties) { this.casProperties = casProperties; } @Override protected void configure(AuthenticationManagerBuilder auth) { auth.authenticationProvider(casAuthenticationProvider()); } @Override protected void configure(HttpSecurity http) throws Exception { http.cors().and() // 允许跨域 .csrf().disable() //关闭 CSRF 跨站请求防护 .authorizeRequests() //配置安全策略 // 放行接口 .antMatchers("/api/**", "/admin/health/**").permitAll() .anyRequest().authenticated() //其余的所有请求都需要验证 .and().logout().permitAll() //定义logout不需要验证 .and().headers().frameOptions().disable() // 防止iframe 造成跨域 .and().formLogin();//使用form表单登录 http.exceptionHandling().authenticationEntryPoint(casAuthenticationEntryPoint()) .accessDeniedHandler(accessDeniedHandler()) .and() .addFilter(casAuthenticationFilter()) .addFilterBefore(casLogoutFilter(), LogoutFilter.class) .addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class); } /** * 认证的入口,即跳转至服务端的cas地址 * Note:浏览器访问不可直接填客户端的login请求,若如此则会返回Error页面,无法被此入口拦截 */ @Bean public AuthenticationEntryPoint casAuthenticationEntryPoint() { MyAuthenticationEntryPoint authenticationEntryPoint = new MyAuthenticationEntryPoint(); //Cas Server的登录地址 authenticationEntryPoint.setLoginUrl(casProperties.getCasServerLoginUrl()); //service相关的属性 authenticationEntryPoint.setServiceProperties(serviceProperties()); return authenticationEntryPoint; } /** * 指定service相关信息 * 设置客户端service的属性 * 主要设置请求cas服务端后的回调路径,一般为主页地址,不可为登录地址 * * @return */ @Bean public ServiceProperties serviceProperties() { ServiceProperties serviceProperties = new ServiceProperties(); // 设置回调的service路径,此为主页路径 //Cas Server认证成功后的跳转地址,这里要跳转到我们的Spring Security应用, //之后会由CasAuthenticationFilter处理,默认处理地址为/j_spring_cas_security_check serviceProperties.setService(casProperties.getAppServerUrl() + casProperties.getAppLoginUrl()); // 对所有的未拥有ticket的访问均需要验证 serviceProperties.setAuthenticateAllArtifacts(true); return serviceProperties; } /** * CAS认证过滤器 */ @Bean public CasAuthenticationFilter casAuthenticationFilter() throws Exception { CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter(); casAuthenticationFilter.setAuthenticationManager(authenticationManager()); //指定处理地址,不指定时默认将会是“/j_spring_cas_security_check” casAuthenticationFilter.setFilterProcessesUrl(casProperties.getAppLoginUrl()); casAuthenticationFilter.setAuthenticationSuccessHandler(forwardAuthenticationSuccessHandler()); return casAuthenticationFilter; } /** * 关键点!!!前后端分离,认证成功之后重定向到前端地址,也可以跳转到一个后端接口完成相关初始化的操作,然后在接口里重定向到前端页面 * * @return * @see #simpleUrlAuthenticationSuccessHandler() */ @Bean public ForwardAuthenticationSuccessHandler forwardAuthenticationSuccessHandler() { return new ForwardAuthenticationSuccessHandler("/index"); } @Bean public SimpleUrlAuthenticationSuccessHandler simpleUrlAuthenticationSuccessHandler() { SimpleUrlAuthenticationSuccessHandler simpleUrlAuthenticationSuccessHandler = new SimpleUrlAuthenticationSuccessHandler(); simpleUrlAuthenticationSuccessHandler.setAlwaysUseDefaultTargetUrl(true); simpleUrlAuthenticationSuccessHandler.setDefaultTargetUrl(casProperties.getAppWebUrl()); return simpleUrlAuthenticationSuccessHandler; } /** * 创建CAS校验类 * Notes:TicketValidator、AuthenticationUserDetailService属性必须设置; * serviceProperties属性主要应用于ticketValidator用于去cas服务端检验ticket * * @return */ @Bean public CasAuthenticationProvider casAuthenticationProvider() { CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider(); casAuthenticationProvider.setAuthenticationUserDetailsService(customUserDetailsService()); casAuthenticationProvider.setServiceProperties(serviceProperties()); casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator()); casAuthenticationProvider.setKey("casAuthenticationProviderKey"); return casAuthenticationProvider; } /** * 用户自定义的AuthenticationUserDetailsService */ @Bean public CustomUserDetailsService customUserDetailsService() { return new CustomUserDetailsService(); } /** * 配置Ticket校验器 * * @return */ @Bean public Cas20ServiceTicketValidator cas20ServiceTicketValidator() { // 配置上服务端的校验ticket地址 return new Cas20ServiceTicketValidator(casProperties.getCasServerUrl()); } /** * 单点注销,接受cas服务端发出的注销session请求 * * @return */ @Bean public SingleSignOutFilter singleSignOutFilter() { SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter(); singleSignOutFilter.setCasServerUrlPrefix(casProperties.getCasServerUrl()); singleSignOutFilter.setIgnoreInitConfiguration(true); return singleSignOutFilter; } /** * 单点请求CAS客户端退出Filter类 * 请求/logout,转发至CAS服务端进行注销 */ @Bean public LogoutFilter casLogoutFilter() { // 设置回调地址,以免注销后页面不再跳转 // 前后端分离 LogoutFilter logoutFilter = new LogoutFilter(myUrlLogoutSuccessHandler(), new SecurityContextLogoutHandler()); logoutFilter.setFilterProcessesUrl(casProperties.getAppLogoutUrl()); return logoutFilter; } @Bean public LogoutSuccessHandler myUrlLogoutSuccessHandler() { final MyUrlLogoutSuccessHandler logoutSuccessHandler = new MyUrlLogoutSuccessHandler(); // 关键点!!!注意调整地址,这里配置的是跳转到前端地址 logoutSuccessHandler.setAlwaysUseDefaultTargetUrl(true); logoutSuccessHandler.setDefaultTargetUrl(casProperties.getCasServerLogoutUrl() + "?service=" + casProperties.getAppWebUrl()); return logoutSuccessHandler; } /** * 权限拒绝处理:用户访问没有权限资源的处理。 * * @return */ @Bean public AccessDeniedHandler accessDeniedHandler() { return new MyAccessDeniedHandler(); }}
MyAuthenticationEntryPoint
@Slf4jpublic class MyAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean { // ~ Instance fields // ================================================================================================ private ServiceProperties serviceProperties; private String loginUrl; /** * Determines whether the Service URL should include the session id for the specific * user. As of CAS 3.0.5, the session id will automatically be stripped. However, * older versions of CAS (i.e. CAS 2), do not automatically strip the session * identifier (this is a bug on the part of the older server implementations), so an * option to disable the session encoding is provided for backwards compatibility. * * By default, encoding is enabled. */ private boolean encodeServiceUrlWithSessionId = true; // ~ Methods // ======================================================================================================== @Override public void afterPropertiesSet() throws Exception { Assert.hasLength(this.loginUrl, "loginUrl must be specified"); Assert.notNull(this.serviceProperties, "serviceProperties must be specified"); Assert.notNull(this.serviceProperties.getService(), "serviceProperties.getService() cannot be null."); } @Override public final void commence(final HttpServletRequest request, final HttpServletResponse response, final AuthenticationException authenticationException) throws IOException { final String urlEncodedService = createServiceUrl(request, response); final String redirectUrl = createRedirectUrl(urlEncodedService); preCommence(request, response); Map data = Maps.newHashMap(); data.put("path", redirectUrl); ResponseUtil.renderJson(response, HttpServletResponse.SC_UNAUTHORIZED, data); } /** * Constructs a new Service Url. The default implementation relies on the CAS client * to do the bulk of the work. * * @param request the HttpServletRequest * @param response the HttpServlet Response * @return the constructed service url. CANNOT be NULL. */ protected String createServiceUrl(final HttpServletRequest request, final HttpServletResponse response) { return CommonUtils.constructServiceUrl(null, response, this.serviceProperties.getService(), null, this.serviceProperties.getArtifactParameter(), this.encodeServiceUrlWithSessionId); } /** * Constructs the Url for Redirection to the CAS server. Default implementation relies * on the CAS client to do the bulk of the work. * * @param serviceUrl the service url that should be included. * @return the redirect url. CANNOT be NULL. */ protected String createRedirectUrl(final String serviceUrl) { return CommonUtils.constructRedirectUrl(this.loginUrl, this.serviceProperties.getServiceParameter(), serviceUrl, this.serviceProperties.isSendRenew(), false); } /** * Template method for you to do your own pre-processing before the redirect occurs. * * @param request the HttpServletRequest * @param response the HttpServletResponse */ protected void preCommence(final HttpServletRequest request, final HttpServletResponse response) { } /** * The enterprise-wide CAS login URL. Usually something like * https://www.mycompany.com/cas/login
. * * @return the enterprise-wide CAS login URL */ public final String getLoginUrl() { return this.loginUrl; } public final ServiceProperties getServiceProperties() { return this.serviceProperties; } public final void setLoginUrl(final String loginUrl) { this.loginUrl = loginUrl; } public final void setServiceProperties(final ServiceProperties serviceProperties) { this.serviceProperties = serviceProperties; } /** * Sets whether to encode the service url with the session id or not. * * @param encodeServiceUrlWithSessionId whether to encode the service url with the * session id or not. */ public final void setEncodeServiceUrlWithSessionId( final boolean encodeServiceUrlWithSessionId) { this.encodeServiceUrlWithSessionId = encodeServiceUrlWithSessionId; } /** * Sets whether to encode the service url with the session id or not. * * @return whether to encode the service url with the session id or not. */ protected boolean getEncodeServiceUrlWithSessionId() { return this.encodeServiceUrlWithSessionId; }}
这个类的作用是用来解决匿名用户访问无权限资源时的异常,也就是说当用户还未登录时,访问未授权的资源的拒绝策略,后端返回401的状态码给到前端,并将跳转地址一并返回给前端,具体的使用请看后面前端代码部分。
MyUrlLogoutSuccessHandler
public class MyUrlLogoutSuccessHandler extends AbstractAuthenticationTargetUrlRequestHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { String targetUrl = determineTargetUrl(request, response); String redirectUrl = response.encodeRedirectURL(targetUrl); Map data = Maps.newHashMap(); data.put("path", redirectUrl); ResponseUtil.renderJson(response, HttpServletResponse.SC_OK, data); } @Override public void setDefaultTargetUrl(String defaultTargetUrl) { super.setDefaultTargetUrl(defaultTargetUrl); }}
该类的作用时用户的登出策略,需要配合前端一起使用,具体的使用请看后面前端代码部分。
CustomUserDetailsService
@Slf4jpublic class CustomUserDetailsService implements AuthenticationUserDetailsService { @Autowired private IPrivilegeBaseApiService privilegeBaseApiService; @Autowired private IPrivilegeService privilegeService; @Autowired private IPrivilegeServiceClassify privilegeServiceClassify; @Autowired private CasProperties casProperties; @Override public UserDetails loadUserDetails(CasAssertionAuthenticationToken token) throws UsernameNotFoundException { final String userName = token.getName(); User user = privilegeBaseApiService.getUserByUsername(userName); if (user == null) { throw new UsernameNotFoundException(String.format("当前用户: [%s]不存在!", userName)); } if (user.getIsLock()) { throw new LockedException(String.format("当前用户: [%s]已被锁定,请联系管理员!", user.getUsername())); } UserClassify userClassify = new UserClassify(); BeanUtils.copyProperties(user, userClassify); final List roles = privilegeService.getRoleByUserId(user.getId()); final List modules = privilegeService.getModuleByUserId(user.getId()); if (CollectionUtils.isEmpty(roles) || CollectionUtils.isEmpty(modules)) { log.warn("当前用户: {} 暂未配置任何角色或权限信息!", user.getUsername()); return UserPrincipal.create(userClassify); } final boolean hasModule = modules.stream().map(Module::getFlag).collect(Collectors.toList()).contains(casProperties.getModuleFlag()); if (hasModule) { final List resources = privilegeService.getResourceListByModuleFlag(casProperties.getModuleFlag(), user.getId()); final List resourceClassifies = privilegeServiceClassify.getResourceClassifyListByModuleFlag(casProperties.getModuleFlag(), user.getId()); userClassify.setResourceList(resources); userClassify.setResourceClassifyList(resourceClassifies); } userClassify.setRoleList(roles); userClassify.setRoleIds(roles.stream().map(p -> p.getId().toString()).collect(Collectors.joining(","))); userClassify.setRoleNames(roles.stream().map(Role::getName).collect(Collectors.joining(","))); userClassify.setModuleList(modules); userClassify.setHasModule(hasModule); return UserPrincipal.create(userClassify); }}
该类的作用是通过Ticket验证后来获取用户信息。这里需要实现UserDetailsService接口,或实现AuthenticationUserDetailsService接口。
UserPrincipal
@Data@Accessors(chain = true)public class UserPrincipal implements UserDetails, Serializable { private static final long serialVersionUID = -7413698186699669097L; /** * 主键 */ private Long id; /** * 用户名 */ private String username; /** * 全名 */ private String fullName; /** * 密码 */ @JsonIgnore private String password; /** * 昵称 */ private String nickname; /** * 手机 */ private String phone; /** * 邮箱 */ private String email; /** * 生日 */ private Long birthday; /** * 性别,男-1,女-2 */ private Integer sex; /** * 部门ID */ private Integer departmentId; /** * 部门名称 */ private String departmentName; /** * 状态,启用-1,禁用-0 */ private Integer status; /** * 创建时间 */ private Long createTime; /** * 更新时间 */ private Long updateTime; /** * 用户角色列表 */ private List roles; /** * 用户权限列表 */ private Collection extends GrantedAuthority> authorities; public static UserPrincipal create(UserClassify user) { // 角色名称列表 List roleNames = Lists.newArrayList(); if (CollectionUtils.isNotEmpty(user.getRoleList())) { roleNames = user.getRoleList().stream().map(Role::getName).collect(Collectors.toList()); } // 权限列表 List authorities = Lists.newArrayList(); if (CollectionUtils.isNotEmpty(user.getResourceList())) { authorities = user.getResourceList().stream() .filter(permission -> StringUtils.isNotBlank(permission.getName())) .map(permission -> new SimpleGrantedAuthority(permission.getName())) .collect(Collectors.toList()); } return new UserPrincipal().setId(Long.valueOf(user.getId())) .setUsername(user.getUsername()) .setFullName(user.getFullname()) .setPassword(user.getPassword()) .setDepartmentId(user.getDepartmentId()) .setDepartmentName(user.getDepartmentName()) .setSex(user.getGender() ? 1 : 2) .setStatus(user.getIsDelete() ? 0 : 1) .setRoles(roleNames) .setAuthorities(authorities); } @Override public Collection extends GrantedAuthority> getAuthorities() { return this.authorities; } // 省略其他Override方法 ...}
自定义实体类,包含了我们的用户信息,以及权限信息等,需要实现继承UserDetails。
IndexController
@RestController@Slf4jpublic class IndexController { private final CasProperties casProperties; public IndexController(CasProperties casProperties) { this.casProperties = casProperties; } @GetMapping("/index") public void hello(HttpServletRequest request, HttpServletResponse response) throws IOException { final UserPrincipal currentUser = SecurityUtil.getCurrentUser(); if (currentUser != null) { final HttpSession session = request.getSession(false); final String sessionId = session.getId(); log.info("JSESSIONID:{}", sessionId); log.info("登录成功:{}", currentUser); response.sendRedirect(casProperties.getAppWebUrl()); } else { throw new AccessDeniedException("未登录,请先完成登录操作!"); } }}
该类的作用时登录成功后跳转到该接口做一些初始化的动作后再重定向到前端页面。这个与使用SimpleUrlAuthenticationSuccessHandler直接重定向到前端页面的区别是可以做一些扩展功能,比如设置Session或者Cookie等操作。
ResponseUtil
@Data@Slf4jpublic class ResponseUtil { private ResponseUtil() { } /** * 往 response 写出 json * * @param response 响应 * @param code 状态 * @param data 返回数据 */ public static void renderJson(HttpServletResponse response, Integer code, Object data) { try { response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Access-Control-Allow-Methods", "*"); response.setContentType("application/json;charset=UTF-8"); response.setStatus(code); response.getWriter().write(JSON.toJSONString(data)); } catch (IOException e) { log.error("Response写出JSON异常,", e); } }}
前端代码
前端代码主要是两个地方,一个是响应用户未授权时的处理,另一个时用户登出时的处理。
export const axios = axiosCreater({ baseURL: '/', validateStatus: function (status) { return status >= 200 && status < 300 }, failMiddleware: (error) => { // 处理后端响应的401状态码 if (!error.response || (error.response && error.response.status === 401)) { // console.log(error.response.data.path) const href = window.location.href location.href = error.response.data.path throw new Error('没有登录') } else { throw error } }, headers: { // 'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/json' }})
当前端捕捉到后端接口返回401时,我们进行CAS跳转登录。配合MyAuthenticationEntryPoint使用。
logout = () => { if (this.props.needCustomize) { this.props.customLogout() } else { this.props.logout().then(res => { message.success('登出成功') location.href = res.data.path }) } }
用户登出后重定向到CAS登出地址,配合MyUrlLogoutSuccessHandler使用。
以上代码基本上就是整合过程中涉及到的所有关键代码了。接下来我们看下整合后的结果是怎样的。
三、整合结果
四、总结
本文只是简单的演示怎样在前后端项目中整合Spring Boot、Spring Security和Cas,没有涉及到比较深的原理和源码级别的讲解,当然这一部分网上也有很多比较好的资源,大家有空可以自己在网上看看。后续我们再补充怎样使用Spring Security在权限控制部分,包括菜单权限和按钮权限的控制,大家敬请期待~