/*
* The MIT License
*
* Copyright (c) 2004-2009, Sun Microsystems, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package edu.hawaii.its.hudson.security;
import groovy.lang.GroovyShell;
import groovy.lang.Script;
import hudson.Extension;
import hudson.Util;
import hudson.model.Descriptor;
import hudson.security.ChainedServletFilter;
import hudson.security.SecurityRealm;
import hudson.util.FormValidation;
import org.acegisecurity.Authentication;
import org.acegisecurity.context.SecurityContextHolder;
import org.apache.commons.lang.StringUtils;
import org.codehaus.groovy.control.CompilationFailedException;
import org.jasig.cas.client.authentication.AttributePrincipalImpl;
import org.jasig.cas.client.authentication.AuthenticationFilter;
import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.util.CommonUtils;
import org.jasig.cas.client.validation.*;
import org.kohsuke.stapler.*;
import org.springframework.web.util.UrlPathHelper;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.net.HttpCookie;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
/**
* {@link hudson.security.SecurityRealm} that uses CAS authentication protocol version 1.
* This is the plain text protocol that UH extended with affiliation details.
* This implementation doesn't use Acegi because it doesn't look like Acegi supports version 1;
* Acegi uses only the CAS version 2 client with XML and proxy tickets.
*
* @author jbeutel@hawaii.edu
*/
public class Cas1SecurityRealm extends SecurityRealm {
private static final String AUTH_KEY = "AUTH_KEY";
public final String casServerUrl;
public final String hudsonHostName;
public final Boolean forceRenewal;
public final String rolesValidationScript;
public final String testValidationResponse; // not used, but stored for the convenience of future testing
private transient Script parsedScript = null; // lazy cache, but avoid marshalling
@DataBoundConstructor
public Cas1SecurityRealm(String casServerUrl, String hudsonHostName, Boolean forceRenewal, String rolesValidationScript, String testValidationResponse) {
if (testValidationResponse == null) {
testValidationResponse = "";
}
this.testValidationResponse = testValidationResponse; // no trimming; allow test of spaces
this.casServerUrl = Util.fixEmptyAndTrim(casServerUrl);
this.hudsonHostName = Util.fixEmptyAndTrim(hudsonHostName);
this.rolesValidationScript = normalizeRolesValidationScript(rolesValidationScript);
this.forceRenewal = forceRenewal;
}
// @Override
// public boolean canLogOut() {
// return false; // hides the log out link, because CAS will just log right back in again
// }
// This makes the log out link work, and is handy for testing, but I don't like loosing my single sign-on.
@Override
protected String getPostLogOutUrl(StaplerRequest req, Authentication auth) {
return casServerUrl + "/logout";
}
private static String normalizeRolesValidationScript(String rolesValidationScript) {
rolesValidationScript = Util.fixEmptyAndTrim(rolesValidationScript);
if (rolesValidationScript == null) {
rolesValidationScript = "return []";
}
return rolesValidationScript;
}
private synchronized Script getParsedScript() {
if (parsedScript == null) {
parsedScript = new GroovyShell().parse(rolesValidationScript);
}
return parsedScript;
}
@Override
public Filter createFilter(FilterConfig filterConfig) {
AuthenticationFilter authenticationFilter = new AuthenticationFilter();
authenticationFilter.setIgnoreInitConfiguration(true); // configuring here, not in web.xml
authenticationFilter.setRenew(forceRenewal);
authenticationFilter.setGateway(false);
authenticationFilter.setCasServerLoginUrl(casServerUrl + "/login");
authenticationFilter.setServerName(hudsonHostName);
Cas10TicketValidationFilter validationFilter = new Cas10TicketValidationFilter();
validationFilter.setIgnoreInitConfiguration(true); // configuring here, not in web.xml
validationFilter.setRedirectAfterValidation(true);
validationFilter.setServerName(hudsonHostName);
validationFilter.setTicketValidator(
new AbstractCasProtocolUrlBasedTicketValidator(casServerUrl) {
protected String getUrlSuffix() {
return "validate"; // version 1 protocol
}
protected Assertion parseResponseFromServer(final String response) throws TicketValidationException {
if (!response.startsWith("yes")) {
throw new TicketValidationException("CAS could not validate ticket.");
}
try {
final BufferedReader reader = new BufferedReader(new StringReader(response));
String mustBeYes = reader.readLine();
assert mustBeYes.equals("yes") : mustBeYes;
String username = reader.readLine();
// parse optional extra validation attributes
Collection roles = parseRolesFromValidationResponse(getParsedScript(), response);
Map<String, Object> attributes = new HashMap<String, Object>();
attributes.put(AUTH_KEY, new Cas1Authentication(username, roles)); // Acegi Authentication
// CAS saves this Assertion in the session; we'll use the Authentication it's carrying.
return new AssertionImpl(new AttributePrincipalImpl(username), attributes);
}
catch (final IOException e) {
throw new TicketValidationException("Unable to parse CAS response.", e);
}
}
}
);
Filter casToAcegiContext = new OnlyDoFilter() {
/**
* Gets the authentication out of the session and puts it in Acegi's ThreadLocal on every request.
* If we've made it this far down this FilterChain without a redirect,
* then there must be a session with an authentication in it.
* Using an Acegi filter to do this would require implementing more of the Acegi framework.
*/
public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException {
final HttpServletRequest request = (HttpServletRequest) servletRequest;
final HttpSession session = request.getSession(false);
final Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
try {
Cas1Authentication auth = (Cas1Authentication) assertion.getAttributes().get(AUTH_KEY);
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(servletRequest, servletResponse);
} finally {
SecurityContextHolder.getContext().setAuthentication(null);
}
}
};
Filter jettyJsessionidRedirect = new OnlyDoFilter() {
private final UrlPathHelper URL_PATH_HELPER = new UrlPathHelper();
/**
* Redirects to remove a jsessionid that a servlet container leaves in the URI if it's also in a cookie.
* Jetty's getRequestURI() fails to remove the jsessionid (whether or not it's also in a cookie),
* and this messes up Hudson's Stapler (as of version 1.323, at least). CAS tickles this bug because
* Jetty's encodeRedirectURL() is adding jsessionid on redirect after validation,
* if it wasn't in a cookie on the request. However, apparently Jetty also puts it in a cookie
* on the redirect response, and Firefox accepts it. This is a work-around to redirect that jsessionid
* off the URL, since the cookie is enough, and the whole point of CAS redirect after validation is
* to get a clean URL anyway (for bookmarks or restored browser tabs).
* Other servlet containers and browser combinations may behave differently.
* <p/>
* This work-around does not attempt to make Hudson work in Jetty without cookies.
* A potential approach for that would be for this filter to install an HttpServletRequestWrapper
* that cleans jsessionid out of getRequestURI(). However, Hudson would also need to rewrite
* all its URLs with the jsessionid, and I have no idea whether it does that. That is an issue
* between Hudson and Jetty, and we can just use cookies anyway.
*/
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
if (request instanceof HttpServletRequest) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
if (httpRequest.getRequestURI().contains(";jsessionid=") && httpRequest.isRequestedSessionIdFromCookie()) {
// without (i.e., with relative) protocol, host, and port
String decodedCleanedUrl = URL_PATH_HELPER.getRequestUri(httpRequest);
if (StringUtils.isNotBlank(httpRequest.getQueryString())) {
decodedCleanedUrl += "?"
+ URL_PATH_HELPER.decodeRequestString(httpRequest, httpRequest.getQueryString());
}
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.sendRedirect(httpResponse.encodeRedirectURL(decodedCleanedUrl));
return;
}
}
filterChain.doFilter(request, response);
}
};
// todo: Exclude paths in Hudson#getTarget() from CAS filtering/Authorization?
// todo: Add SecurityFilters.commonProviders?
// todo: Or, is all that just to support on-demand authentication (upgrade)?
return new ChainedServletFilter(authenticationFilter, validationFilter, casToAcegiContext, jettyJsessionidRedirect);
}
private static Collection parseRolesFromValidationResponse(Script script, String response) {
script.getBinding().setVariable("response", response);
return (Collection) script.run();
}
public SecurityComponents createSecurityComponents() {
return new SecurityComponents(); // do nothing, falling back to createFilter()
}
// @Override
// public GroupDetails loadGroupByGroupname(final String groupname) throws UsernameNotFoundException, DataAccessException {
// if(CLibrary.libc.getgrnam(groupname)==null)
// throw new UsernameNotFoundException(groupname);
// return new GroupDetails() {
// @Override
// public String getName() {
// return groupname;
// }
// };
// }
public static final class DescriptorImpl extends Descriptor<SecurityRealm> {
private static final String CONFIRMED = "confirmed";
public String getDisplayName() {
return "CAS protocol version 1";
}
public FormValidation doCheckCasServerUrl(@QueryParameter String value) throws IOException, ServletException {
value = Util.fixEmptyAndTrim(value);
if (value == null) {
return FormValidation.error("required"); // todo: doesn't Hudson have a better way?
}
try {
URL url = new URL(value + "/login");
String response = CommonUtils.getResponseFromServer(url);
if (!response.contains("username")) {
return FormValidation.warning("CAS server response could not be validated.");
}
} catch (MalformedURLException e) {
return FormValidation.error("Malformed CAS server URL: " + e);
} catch (RuntimeException e) {
return FormValidation.error("Problem getting a response from CAS server: "
+ (e.getCause() == null ? e : e.getCause()));
}
return FormValidation.ok();
}
public FormValidation doHudsonConfirmation() { // action method stops Stapler evaluation
return FormValidation.ok(CONFIRMED);
}
// This check is tedious, but it's important because the user can lock himself out.
// This value is redundant with Hudson#getRootUrl(), but that comes from the E-mail Notification
// section which is at the bottom of the global config page while this security is at the top,
// and it would be a bad side-effect to try the wrong URL in the email and find yourself locked out.
public FormValidation doCheckHudsonHostName(StaplerRequest req, StaplerResponse rsp, @QueryParameter String value) throws IOException, ServletException {
value = Util.fixEmptyAndTrim(value);
if (value == null) {
return FormValidation.error("required"); // todo: does Hudson have a better way?
}
String testServiceUrl = CommonUtils.constructServiceUrl(req, rsp, null, value, "ticket", true); // the CAS way
String thisDiscriptorUri = "descriptorByName/" + Cas1SecurityRealm.class.getName();
String testMethodUri = thisDiscriptorUri + "/checkHudsonHostName";
assert testServiceUrl.contains(testMethodUri) : testServiceUrl;
String hudsonConfirmationUrl = testServiceUrl.substring(0, testServiceUrl.indexOf(testMethodUri));
hudsonConfirmationUrl += thisDiscriptorUri + "/hudsonConfirmation";
// alternative: hudsonConfirmationUrl += "securityRealms/Cas1SecurityRealm/hudsonConfirmation";
try {
URL url = new URL(hudsonConfirmationUrl);
HttpSession session = req.getSession(false);
String response;
if (session == null) { // before security has been enabled
response = CommonUtils.getResponseFromServer(url);
} else {
// need session for authorization (if this realm is in effect, at least)
response = getResponseFromServer(url, createSessionCookie(url, session));
}
if (!response.contains(CONFIRMED)) {
return FormValidation.warning("Could not validate Hudson response.");
}
} catch (MalformedURLException e) {
return FormValidation.error("Malformed Hudson server URL: " + e);
} catch (RuntimeException e) {
Throwable specific = e.getCause() == null ? e : e.getCause();
return FormValidation.error("Problem getting a response from Hudson server: " + specific);
}
return FormValidation.ok();
}
private static String getResponseFromServer(final URL constructedUrl, HttpCookie cookie) {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) constructedUrl.openConnection();
// need to use cookie for session because Jetty leaves jsessionid on URI, which messes up Stapler
conn.setRequestProperty("Cookie", cookie.toString());
BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line;
StringBuffer stringBuffer = new StringBuffer();
while ((line = in.readLine()) != null) {
stringBuffer.append(line);
stringBuffer.append("\n");
}
return stringBuffer.toString();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
private static HttpCookie createSessionCookie(URL constructedUrl, HttpSession session) {
HttpCookie cookie = new HttpCookie("JSESSIONID", session.getId());
cookie.setDomain(constructedUrl.getHost());
cookie.setPath(constructedUrl.getPath());
return cookie;
}
public FormValidation doTestScript(
@QueryParameter("rolesValidationScript") final String rolesValidationScript,
@QueryParameter("testValidationResponse") final String testValidationResponse) {
try {
Script script = new GroovyShell().parse(normalizeRolesValidationScript(rolesValidationScript));
Collection roles = parseRolesFromValidationResponse(script, testValidationResponse);
if (roles == null) { // cast to Collection succeeds for null, so check specifically
return FormValidation.error("Roles Validation Script returned null.");
}
return FormValidation.ok("Roles parsed from the test validation response: " + roles);
}
catch (CompilationFailedException e) {
return FormValidation.error("Roles Validation Script failed to compile: " + e);
} catch (ClassCastException e) {
return FormValidation.error("Roles Validation Script did not return a Collection: " + e);
}
}
}
@Extension
public static DescriptorImpl install() {
return new DescriptorImpl();
}
private static abstract class OnlyDoFilter implements Filter {
public void init(FilterConfig filterConfig) throws ServletException {
// do nothing
}
public void destroy() {
// do nothing
}
}
}