前后端是基于若依的前后端分离框架(RuoYi-Vue)进行搭建的,现在需要加入cas单点认证,并且支持配置文件配置的方式,动态的切换认证的方式。
cas服务器的搭建,直接在网上下载即可,本文主要是对cas客户端系统前后端项目的改造,有不当之处,希望大家指正。
特别注意,前端和后端的 casEnable配置需要一致
>
>org.springframework.security >
>spring-security-cas >
>5.2.2.RELEASE >
>
由于CAS认证需要authorities属性,此属性不能为空,此处为了方便直接new HashSet():
@Override
public Collection<? extends GrantedAuthority> getAuthorities()
{
return new HashSet();
}
@Component
public class CasProperties {
@Value("${app.server.host.url}")
private String appServerUrl;
@Value("${app.server.home.url}")
private String appServerHomeUrl;
@Value("${app.login.url}")
private String appLoginUrl;
@Value("${app.logout.url}")
private String appLogoutUrl;
@Value("${app.key}")
private String appKey;
@Value("${app.casEnable}")
private boolean casEnable;
@Value("${cas.server.host}")
private String casServerUrl;
@Value("${cas.server.login_url}")
private String casServerLoginUrl;
@Value("${cas.server.logout_url}")
private String casServerLogoutUrl;
public CasProperties() {
}
public String getAppKey() {
return appKey;
}
public void setAppKey(String appKey) {
this.appKey = appKey;
}
public String getAppServerHomeUrl() {
return appServerHomeUrl;
}
public void setAppServerHomeUrl(String appServerHomeUrl) {
this.appServerHomeUrl = appServerHomeUrl;
}
public String getAppServerUrl() {
return appServerUrl;
}
public void setAppServerUrl(String appServerUrl) {
this.appServerUrl = appServerUrl;
}
public String getAppLoginUrl() {
return appLoginUrl;
}
public void setAppLoginUrl(String appLoginUrl) {
this.appLoginUrl = appLoginUrl;
}
public String getAppLogoutUrl() {
return appLogoutUrl;
}
public void setAppLogoutUrl(String appLogoutUrl) {
this.appLogoutUrl = appLogoutUrl;
}
public String getCasServerUrl() {
return casServerUrl;
}
public boolean isCasEnable() {
return casEnable;
}
public void setCasEnable(boolean casEnable) {
this.casEnable = casEnable;
}
public void setCasServerUrl(String casServerUrl) {
this.casServerUrl = casServerUrl;
}
public String getCasServerLoginUrl() {
return casServerLoginUrl;
}
public void setCasServerLoginUrl(String casServerLoginUrl) {
this.casServerLoginUrl = casServerLoginUrl;
}
public String getCasServerLogoutUrl() {
return casServerLogoutUrl;
}
public void setCasServerLogoutUrl(String casServerLogoutUrl) {
this.casServerLogoutUrl = casServerLogoutUrl;
}
}
@RestController
@RequestMapping("/cas")
public class SysCASController extends BaseController {
@Autowired
private CasProperties casProperties;
/**
* 适用前后端分离
* 当未登录时重定向到此请求,返回给前端CAS服务器登录地址,通过前端跳转
*
* @return
*/
@GetMapping("/send")
public AjaxResult send() {
String url = casProperties.getCasServerLoginUrl() + "?service=" + casProperties.getAppServerUrl() + casProperties.getAppLoginUrl() + "&key=" + casProperties.getAppKey();
return AjaxResult.error(600, url);
}
/**
* 适用前后端分离
* 当登录成功后返回前端数据
*
* @return
*/
@GetMapping("/login")
public AjaxResult login(HttpServletResponse response) throws IOException {
response.sendRedirect(casProperties.getAppServerHomeUrl());
return AjaxResult.success("成功");
}
}
这里是直接重定向到controller的send接口回到首页,可根据业务自定义实现认证失败的逻辑
@Component
public class CasAuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Autowired
private CasProperties casProperties;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.sendRedirect(casProperties.getAppServerUrl() + "/send");
}
}
根据自己系统内部的认证方式去自行修改,楼主这里是需要取封装全局的LoginUser对象,大家根据自己的系统去实现即可,楼楼的如下:
@Service
public class CasUserDetailsService implements AuthenticationUserDetailsService<CasAssertionAuthenticationToken> {
private static final Logger log = LoggerFactory.getLogger(CasUserDetailsService.class);
@Autowired
private ISysUserService userService;
@Autowired
private SysPermissionService permissionService;
@Override
public UserDetails loadUserDetails(CasAssertionAuthenticationToken token) throws UsernameNotFoundException {
String username = token.getName();
SysUser user = userService.selectUserByUserName(username);
if (StringUtils.isNull(user)) {
log.info("登录用户:{} 不存在.", username);
throw new ServiceException("登录用户:" + username + " 不存在");
} else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
log.info("登录用户:{} 已被删除.", username);
throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
} else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", username);
throw new ServiceException("对不起,您的账号:" + username + " 已停用");
}
return createLoginUser(user);
}
public UserDetails createLoginUser(SysUser user) {
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}
通过casEnable确认启用的认证方式
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CasProperties casProperties;
@Autowired
private CasAuthenticationEntryPointImpl casAuthenticationEntryPoint;
@Autowired
private CasUserDetailsService casUserDetailsService;
/**
* 自定义用户认证逻辑
*/
@Autowired
private UserDetailsService userDetailsService;
/**
* 认证失败处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;
/**
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* anyRequest | 匹配所有请求路径
* access | SpringEl表达式结果为true时可以访问
* anonymous | 匿名可以访问
* denyAll | 用户不能访问
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
* permitAll | 用户可以任意访问
* rememberMe | 允许通过remember-me登录的用户访问
* authenticated | 用户登录后可访问
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
if (!casProperties.isCasEnable()) {
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage", "/refToken").anonymous()
.antMatchers(
HttpMethod.GET,
"/",
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js"
).permitAll()
.antMatchers("/tool/manual**").authenticated()
.antMatchers("/doc.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous()
.antMatchers("/webjars/**").anonymous()
.antMatchers("/*/api-docs").anonymous()
.antMatchers("/druid/**").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.cors().and()
.headers().frameOptions().disable();
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// // 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
} else {
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 过滤请求
.authorizeRequests()
//
.antMatchers("/refToken", "/doc.html").anonymous()
.antMatchers(
HttpMethod.GET,
"/",
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js"
).permitAll()
.antMatchers("/tool/manual**").authenticated()
.antMatchers("/cas/**").permitAll()
.antMatchers("/swagger-resources/**").anonymous()
.antMatchers("/webjars/**").anonymous()
.antMatchers("/*/api-docs").anonymous()
.antMatchers("/druid/**").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.cors().and()
.logout().permitAll().and()//logout不需要验证
.cors().and()
.headers().frameOptions().disable();
httpSecurity.exceptionHandling()
.authenticationEntryPoint(casAuthenticationEntryPoint) //认证失败
.and().addFilter(casAuthenticationFilter())
.addFilterBefore(authenticationTokenFilter, CasAuthenticationFilter.class)
.addFilterBefore(casLogoutFilter(), LogoutFilter.class)
.addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class);
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
httpSecurity.headers().cacheControl();
}
}
/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
if (!casProperties.isCasEnable()) {
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
} else {
auth.authenticationProvider(casAuthenticationProvider());
}
}
/**
* 主要配置的是ServiceProperties的service属性,它指定的是cas回调的地址
*/
@ConditionalOnExpression("${app.casEnable}")
@Bean
public ServiceProperties serviceProperties() {
ServiceProperties serviceProperties = new ServiceProperties();
serviceProperties.setService(casProperties.getAppServerUrl() + casProperties.getAppLoginUrl());
serviceProperties.setSendRenew(false);
serviceProperties.setAuthenticateAllArtifacts(true);
return serviceProperties;
}
@ConditionalOnExpression("${app.casEnable}")
@Bean
public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter();
casAuthenticationFilter.setServiceProperties(serviceProperties());
casAuthenticationFilter.setFilterProcessesUrl(casProperties.getAppLoginUrl());
casAuthenticationFilter.setAuthenticationManager(authenticationManager());
casAuthenticationFilter.setAuthenticationSuccessHandler(
new SimpleUrlAuthenticationSuccessHandler(
casProperties.getAppServerUrl() + "/hello"));
casAuthenticationFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy());
return casAuthenticationFilter;
}
@ConditionalOnExpression("${app.casEnable}")
@Bean
public CasAuthenticationProvider casAuthenticationProvider() {
CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
casAuthenticationProvider.setServiceProperties(serviceProperties());
casAuthenticationProvider.setTicketValidator(cas30ServiceTicketValidator());
casAuthenticationProvider
.setAuthenticationUserDetailsService(casUserDetailsService);
casAuthenticationProvider.setKey("casAuthenticationProviderKey");
return casAuthenticationProvider;
}
/**
* 验证ticker,向cas服务器发送验证请求
*/
@ConditionalOnExpression("${app.casEnable}")
@Bean
public Cas30ProxyTicketValidator cas30ServiceTicketValidator() {
Cas30ProxyTicketValidator cas30ServiceTicketValidator = new Cas30ProxyTicketValidator(
casProperties.getCasServerUrl());
cas30ServiceTicketValidator.setEncoding("UTF-8");
return cas30ServiceTicketValidator;
}
@ConditionalOnExpression("${app.casEnable}")
@Bean
public SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new SessionFixationProtectionStrategy();
}
/**
* 此过滤器向cas发送登出请求
*/
@ConditionalOnExpression("${app.casEnable}")
@Bean
public SingleSignOutFilter singleSignOutFilter() {
SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter();
singleSignOutFilter.setCasServerUrlPrefix(casProperties.getCasServerUrl());
singleSignOutFilter.setIgnoreInitConfiguration(true);
return singleSignOutFilter;
}
/**
* 此过滤器拦截客户端的logout请求,发现logout请求后向cas服务器发送登出请求
*/
@ConditionalOnExpression("${app.casEnable}")
@Bean
public LogoutFilter casLogoutFilter() {
LogoutFilter logoutFilter = new LogoutFilter(casProperties.getCasServerLogoutUrl(),
new SecurityContextLogoutHandler());
logoutFilter.setFilterProcessesUrl(casProperties.getAppLogoutUrl());
return logoutFilter;
}
/**
* 取出@Secured的前缀 "ROLE_"
*
* @return
*/
@ConditionalOnExpression("${app.casEnable}")
@Bean
public GrantedAuthorityDefaults grantedAuthorityDefaults() {
return new GrantedAuthorityDefaults("");
}
从上面的配置可以看出,退出处理和token的认证类沿用之前的认证方式即可
特别注意(楼楼在这里栽了大跟头,手动狗头):
在不同模式下通过@ConditionalOnExpression注解,动态的注入bean防止bean的冲突
前端不咋会,望大家指正,相互学习