edu.hawaii.its.hudson.security.Cas1SecurityRealm.java Source code

Java tutorial

Introduction

Here is the source code for edu.hawaii.its.hudson.security.Cas1SecurityRealm.java

Source

/*
 * 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
        }
    }
}