When working with Spring Security + CAS I keep hitting a small road block with the callback URL that is sent to CAS, ie the service property. I've looked at a bunch of examples such as this and this but they all use hard coded URLs (even Spring's CAS docs). A typical snip looks something like this...
<bean id="serviceProperties" class="org.springframework.security.ui.cas.ServiceProperties">
<property name="service" value="http://localhost:8080/click/j_spring_cas_security_check" />
</bean>
First, I don't want to hard code the server name or the port since I want this WAR to be deployable anywhere and I don't want my application tied to a particular DNS entry at compile time. Second, I don't understand why Spring can't auto detect my application's context and the request's URL to automagically build the URL. The first part of that statement still stand but As Raghuram pointed out below with this link, we can't trust the HTTP Host Header from the client for security reasons.
Ideally I would like service URL to be exactly what the user requested (as long as the request is valid such as a sub domain of mycompany.com) so it is seamless or at the very least I would like to only specify some path relative my applications context root and have Spring determine the service URL on the fly. Something like the following...
<bean id="serviceProperties" class="org.springframework.security.ui.cas.ServiceProperties">
<property name="service" value="/my_cas_callback" />
</bean>
OR...
<bean id="serviceProperties" class="org.springframework.security.ui.cas.ServiceProperties">
<property name="service" value="${container.and.app.derived.value.here}" />
</bean>
Is any of this possible or easy or have I missed the obvious?
I know this is a bit old but I just had to solve this very problem and couldn't really find anything in the newer stacks.
We have multiple environments sharing the same CAS service (think dev, qa, uat and local development environments); we have the ability to hit each environment from more than one url (via the client side web server over a reverse proxy and directly to the back-end server itself). This means that specifying a single url is difficult at best. Maybe there's a way to do this but being able to use a dynamic ServiceProperties.getService()
. I'll probably add some kind of server suffix check to ensure that the url isn't hijacked at some point.
Here's what I did to get the basic CAS flow working regardless of the URL used to access the secured resource...
CasAuthenticationFilter
.CasAuthenticationProvider
.setAuthenticateAllArtifacts(true)
on the ServiceProperties
.Here's the long form of my spring configuration bean:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, jsr250Enabled = true)
public class CasSecurityConfiguration extends WebSecurityConfigurerAdapter {
Just the usual spring configuration bean.
@Value("${cas.server.url:https://localhost:9443/cas}")
private String casServerUrl;
@Value("${cas.service.validation.uri:/webapi/j_spring_cas_security_check}")
private String casValidationUri;
@Value("${cas.provider.key:whatever_your_key}")
private String casProviderKey;
Some externalized configuration parameters.
@Bean
public ServiceProperties serviceProperties() {
ServiceProperties serviceProperties = new ServiceProperties();
serviceProperties.setService(casValidationUri);
serviceProperties.setSendRenew(false);
serviceProperties.setAuthenticateAllArtifacts(true);
return serviceProperties;
}
The key thing above is the setAuthenticateAllArtifacts(true)
call. This will make the service ticket validator use the AuthenticationDetailsSource
implementation rather than a hard-coded ServiceProperties.getService()
call
@Bean
public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
return new Cas20ServiceTicketValidator(casServerUrl);
}
Standard ticket validator..
@Resource
private UserDetailsService userDetailsService;
@Bean
public AuthenticationUserDetailsService authenticationUserDetailsService() {
return new AuthenticationUserDetailsService() {
@Override
public UserDetails loadUserDetails(Authentication token) throws UsernameNotFoundException {
String username = (token.getPrincipal() == null) ? "NONE_PROVIDED" : token.getName();
return userDetailsService.loadUserByUsername(username);
}
};
}
Standard hook to an existing UserDetailsService
@Bean
public CasAuthenticationProvider casAuthenticationProvider() {
CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
casAuthenticationProvider.setAuthenticationUserDetailsService(authenticationUserDetailsService());
casAuthenticationProvider.setServiceProperties(serviceProperties());
casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());
casAuthenticationProvider.setKey(casProviderKey);
return casAuthenticationProvider;
}
Standard authentication provider
@Bean
public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter();
casAuthenticationFilter.setAuthenticationManager(authenticationManager());
casAuthenticationFilter.setServiceProperties(serviceProperties());
casAuthenticationFilter.setAuthenticationDetailsSource(dynamicServiceResolver());
return casAuthenticationFilter;
}
Key here is the dynamicServiceResolver()
setting..
@Bean
AuthenticationDetailsSource<HttpServletRequest,
ServiceAuthenticationDetails> dynamicServiceResolver() {
return new AuthenticationDetailsSource<HttpServletRequest, ServiceAuthenticationDetails>() {
@Override
public ServiceAuthenticationDetails buildDetails(HttpServletRequest context) {
final String url = makeDynamicUrlFromRequest(serviceProperties());
return new ServiceAuthenticationDetails() {
@Override
public String getServiceUrl() {
return url;
}
};
}
};
}
Dynamically creates the service url from the makeDynamicUrlFromRequest()
method. This bit is used upon ticket validation.
@Bean
public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint() {
@Override
protected String createServiceUrl(final HttpServletRequest request, final HttpServletResponse response) {
return CommonUtils.constructServiceUrl(null, response, makeDynamicUrlFromRequest(serviceProperties())
, null, serviceProperties().getArtifactParameter(), false);
}
};
casAuthenticationEntryPoint.setLoginUrl(casServerUrl + "/login");
casAuthenticationEntryPoint.setServiceProperties(serviceProperties());
return casAuthenticationEntryPoint;
}
This part uses the same dynamic url creator when CAS wants to redirect to the login screen.
private String makeDynamicUrlFromRequest(ServiceProperties serviceProperties){
return "https://howeverYouBuildYourOwnDynamicUrl.com";
}
This is whatever you make of it. I only passed in the ServiceProperties to hold the URI of the service that we're configured for. We use HATEAOS on the back-side and have an implementation like:
return UriComponentsBuilder.fromHttpUrl(
linkTo(methodOn(ExposedRestResource.class)
.aMethodOnThatResource(null)).withSelfRel().getHref())
.replacePath(serviceProperties.getService())
.build(false)
.toUriString();
Edit: here's what I did for the list of valid server suffixes..
private List<String> validCasServerHostEndings;
@Value("${cas.valid.server.suffixes:company.com,localhost}")
private void setValidCasServerHostEndings(String endings){
validCasServerHostEndings = new ArrayList<>();
for (String ending : StringUtils.split(endings, ",")) {
if (StringUtils.isNotBlank(ending)){
validCasServerHostEndings.add(StringUtils.trim(ending));
}
}
}
private String makeDynamicUrlFromRequest(ServiceProperties serviceProperties){
UriComponents url = UriComponentsBuilder.fromHttpUrl(
linkTo(methodOn(ExposedRestResource.class)
.aMethodOnThatResource(null)).withSelfRel().getHref())
.replacePath(serviceProperties.getService())
.build(false);
boolean valid = false;
for (String validCasServerHostEnding : validCasServerHostEndings) {
if (url.getHost().endsWith(validCasServerHostEnding)){
valid = true;
break;
}
}
if (!valid){
throw new AccessDeniedException("The server is unable to authenticate the requested url.");
}
return url.toString();
}