net.shibboleth.idp.authn.spnego.impl.SPNEGOAuthnController.java Source code

Java tutorial

Introduction

Here is the source code for net.shibboleth.idp.authn.spnego.impl.SPNEGOAuthnController.java

Source

/*
 * Licensed to the University Corporation for Advanced Internet Development, 
 * Inc. (UCAID) under one or more contributor license agreements.  See the 
 * NOTICE file distributed with this work for additional information regarding
 * copyright ownership. The UCAID licenses this file to You under the Apache 
 * License, Version 2.0 (the "License"); you may not use this file except in 
 * compliance with the License.  You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package net.shibboleth.idp.authn.spnego.impl;

import java.io.IOException;
import java.util.Arrays;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.security.auth.Subject;
import javax.security.auth.kerberos.KerberosPrincipal;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import net.shibboleth.idp.authn.AuthnEventIds;
import net.shibboleth.idp.authn.ExternalAuthentication;
import net.shibboleth.idp.authn.ExternalAuthenticationException;
import net.shibboleth.idp.authn.context.AuthenticationContext;
import net.shibboleth.idp.authn.principal.UsernamePrincipal;
import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
import net.shibboleth.utilities.java.support.codec.HTMLEncoder;

import org.apache.commons.codec.binary.Base64;
import org.ietf.jgss.GSSException;
import org.ietf.jgss.GSSName;
import org.opensaml.profile.context.ProfileRequestContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;

/**
 * MVC controller for managing the SPNEGO exchanges implemented as an {@link ExternalAuthentication} mechanism.
 * 
 * The handler methods either return contents back to the browser by returning an appropriate ResponseEntity<String>
 * object, or they return back to the flow by calling
 * {@link ExternalAuthentication#finishExternalAuthentication(String, HttpServletRequest, HttpServletResponse)} and
 * returning null. On unrecoverable errors, an exception is thrown.
 */
public class SPNEGOAuthnController {

    /** Event ID indicating that SPNEGO is not supported by the client or is not available for other reasons. */
    @Nonnull
    @NotEmpty
    public static final String SPNEGO_NOT_AVAILABLE = "SPNEGONotAvailable";

    /** Event ID indicating that NTLM was attempted by the client. */
    @Nonnull
    @NotEmpty
    public static final String NTLM_UNSUPPORTED = "NTLMUnsupported";

    /** Class logger. */
    @Nonnull
    private final Logger log = LoggerFactory.getLogger(SPNEGOAuthnController.class);

    /**
     * Handle initial request that starts SPNEGO.
     * 
     * @param conversationKey the SWF conversation key
     * @param httpRequest the HTTP request
     * @param httpResponse the HTTPresponse
     * 
     * @return the response view
     * @throws IOException 
     * @throws ExternalAuthenticationException 
     */
    @RequestMapping(value = "/{conversationKey}", method = RequestMethod.GET)
    @Nullable
    public ModelAndView startSPNEGO(@PathVariable @Nonnull @NotEmpty final String conversationKey,
            @Nonnull final HttpServletRequest httpRequest, @Nonnull final HttpServletResponse httpResponse)
            throws ExternalAuthenticationException, IOException {

        final String key = ExternalAuthentication.startExternalAuthentication(httpRequest);
        if (!key.equals(conversationKey)) {
            throw new ExternalAuthenticationException("Conversation key on query string doesn't match URL path");
        }
        final ProfileRequestContext prc = ExternalAuthentication.getProfileRequestContext(key, httpRequest);

        final SPNEGOContext spnegoCtx = getSPNEGOContext(prc);
        if (spnegoCtx == null || spnegoCtx.getKerberosSettings() == null) {
            log.error("Kerberos settings not found in profile request context");
            finishWithError(conversationKey, httpRequest, httpResponse, AuthnEventIds.INVALID_AUTHN_CTX);
            return null;
        }

        // Start the SPNEGO exchange.
        log.trace("SPNEGO negotiation started, answering request with 401 (WWW-Authenticate: Negotiate)");
        return replyUnauthorizedNegotiate(prc, httpRequest, httpResponse);
    }

    // Checkstyle: CyclomaticComplexity|MethodLength OFF
    /**
     * Process an input GSS token from the client and attempt to complete the context establishment process.
     * 
     * @param conversationKey the conversation key
     * @param authorizationHeader the token from the client
     * @param httpRequest the HTTP request
     * @param httpResponse the HTTP response
     * 
     * @return the response view
     * @throws ExternalAuthenticationException 
     * @throws IOException 
     */
    @RequestMapping(value = "/{conversationKey}", method = RequestMethod.GET, headers = "Authorization")
    @Nullable
    public ModelAndView continueSPNEGO(@PathVariable @Nonnull @NotEmpty final String conversationKey,
            @RequestHeader(HttpHeaders.AUTHORIZATION) @Nonnull @NotEmpty final String authorizationHeader,
            @Nonnull final HttpServletRequest httpRequest, @Nonnull final HttpServletResponse httpResponse)
            throws ExternalAuthenticationException, IOException {

        final ProfileRequestContext prc = ExternalAuthentication.getProfileRequestContext(conversationKey,
                httpRequest);

        if (!authorizationHeader.startsWith("Negotiate ")) {
            return replyUnauthorizedNegotiate(prc, httpRequest, httpResponse);
        }

        final SPNEGOContext spnegoCtx = getSPNEGOContext(prc);
        if (spnegoCtx == null || spnegoCtx.getKerberosSettings() == null) {
            log.error("Kerberos settings not found in profile request context");
            finishWithError(conversationKey, httpRequest, httpResponse, AuthnEventIds.INVALID_AUTHN_CTX);
            return null;
        }

        GSSContextAcceptor acceptor = spnegoCtx.getContextAcceptor();
        if (acceptor == null) {
            try {
                acceptor = createGSSContextAcceptor(spnegoCtx);
                spnegoCtx.setContextAcceptor(acceptor);
            } catch (final GSSException e) {
                log.error("Unable to create GSSContextAcceptor", e);
                finishWithException(conversationKey, httpRequest, httpResponse,
                        new ExternalAuthenticationException(SPNEGO_NOT_AVAILABLE, e));
                return null;
            }
        }

        final byte[] gssapiData = Base64.decodeBase64(authorizationHeader.substring(10).getBytes());
        log.trace("SPNEGO negotiation, Authorization header received, gssapi-data: {}", gssapiData);

        // NTLM Authentication is not supported.
        if (isNTLMMechanism(gssapiData)) {
            log.warn("NTLM is unsupported, failing context negotiation");
            acceptor.logout();
            finishWithError(conversationKey, httpRequest, httpResponse, NTLM_UNSUPPORTED);
            return null;
        }

        byte[] tokenBytes;
        try {
            tokenBytes = acceptor.acceptSecContext(gssapiData, 0, gssapiData.length);
            log.trace("GSS token accepted");
        } catch (final Exception e) {
            log.debug("Exception processing GSS token", e);
            acceptor.logout();
            finishWithException(conversationKey, httpRequest, httpResponse,
                    new ExternalAuthenticationException(SPNEGO_NOT_AVAILABLE, e));
            return null;
        }

        // If the context is established, we can attempt to retrieve the name of the "context initiator."
        // In the case of the Kerberos mechanism, the context initiator is the Kerberos principal of the client.
        if (acceptor.getContext() != null && acceptor.getContext().isEstablished()) {
            log.debug("GSS security context is complete");
            try {
                final GSSName clientGSSName = acceptor.getContext().getSrcName();
                if (clientGSSName == null) {
                    // This case should never happen, but we observed it. Handle it as authentication failure.
                    log.error("Error extracting principal name from security context");
                    acceptor.logout();
                    finishWithException(conversationKey, httpRequest, httpResponse,
                            new ExternalAuthenticationException(SPNEGO_NOT_AVAILABLE));
                    return null;
                }
                final KerberosPrincipal kerberosPrincipal = new KerberosPrincipal(clientGSSName.toString());

                log.info("SPNEGO/Kerberos authentication succeeded for principal: {}", clientGSSName.toString());

                acceptor.logout();
                finishWithSuccess(conversationKey, httpRequest, httpResponse, kerberosPrincipal);
            } catch (final GSSException e) {
                log.error("Error extracting principal name from security context", e);
                acceptor.logout();
                finishWithException(conversationKey, httpRequest, httpResponse,
                        new ExternalAuthenticationException(SPNEGO_NOT_AVAILABLE, e));
            }
        } else {
            // The context is not complete yet.
            // return "WWW-Authenticate: Negotiate <data>" to the browser
            log.trace("SPNEGO negotiation in process, output token: {}", tokenBytes);
            return replyUnauthorizedNegotiate(prc, httpRequest, httpResponse,
                    Base64.encodeBase64String(tokenBytes));
        }

        return null;
    }
    // Checkstyle: CyclomaticComplexity|MethodLength ON

    /**
     * Respond to a user signaling that an error occurred.
     * 
     * @param conversationKey the conversation key
     * @param httpRequest the HTTP request
     * @param httpResponse the HTTP response
     * 
     * @throws IOException 
     * @throws ExternalAuthenticationException 
     */
    @RequestMapping(value = "/{conversationKey}/error", method = RequestMethod.GET)
    public void handleError(@PathVariable final String conversationKey,
            @Nonnull final HttpServletRequest httpRequest, @Nonnull final HttpServletResponse httpResponse)
            throws ExternalAuthenticationException, IOException {

        log.warn("SPNEGO authentication problem signaled by client");
        finishWithError(conversationKey, httpRequest, httpResponse, SPNEGO_NOT_AVAILABLE);
    }

    /**
     * Finish the authentication process successfully.
     * 
     * <p>Sets the attribute {@link ExternalAuthentication#SUBJECT_KEY}.</p>
     * 
     * @param key the conversation key
     * @param httpRequest the HTTP request
     * @param httpResponse the HTTP response
     * @param kerberosPrincipal the Kerberos principal to return
     * 
     * @throws IOException 
     * @throws ExternalAuthenticationException 
     */
    private void finishWithSuccess(@Nonnull @NotEmpty final String key,
            @Nonnull final HttpServletRequest httpRequest, @Nonnull final HttpServletResponse httpResponse,
            @Nonnull final KerberosPrincipal kerberosPrincipal)
            throws ExternalAuthenticationException, IOException {

        // Store the user as a username and as a real KerberosPrincipal object.
        final Subject subject = new Subject();
        subject.getPrincipals().add(new UsernamePrincipal(kerberosPrincipal.getName()));
        subject.getPrincipals().add(kerberosPrincipal);

        // Finish the external authentication task and return to the flow.
        httpRequest.setAttribute(ExternalAuthentication.SUBJECT_KEY, subject);
        ExternalAuthentication.finishExternalAuthentication(key, httpRequest, httpResponse);
    }

    /**
     * Finish the authentication process with an error.
     * 
     * <p>Sets the attribute {@link ExternalAuthentication#AUTHENTICATION_ERROR_KEY}.</p>
     * 
     * @param key the conversation key
     * @param httpRequest the HTTP request
     * @param httpResponse the HTTP response
     * @param error the error string/event to return
     * 
     * @throws IOException 
     * @throws ExternalAuthenticationException 
     */
    private void finishWithError(@Nonnull @NotEmpty final String key, @Nonnull final HttpServletRequest httpRequest,
            @Nonnull final HttpServletResponse httpResponse, @Nonnull @NotEmpty final String error)
            throws ExternalAuthenticationException, IOException {

        // Finish the external authentication task and return to the flow.
        httpRequest.setAttribute(ExternalAuthentication.AUTHENTICATION_ERROR_KEY, error);
        ExternalAuthentication.finishExternalAuthentication(key, httpRequest, httpResponse);
    }

    /**
     * Finish the authentication process with an exception.
     * 
     * <p>Sets the attribute {@link ExternalAuthentication#AUTHENTICATION_EXCEPTION_KEY}.</p>
     * 
     * @param key the conversation key
     * @param httpRequest the HTTP request
     * @param httpResponse the HTTP response
     * @param ex the exception that has been thrown
     * 
     * @throws IOException 
     * @throws ExternalAuthenticationException 
     */
    private void finishWithException(@Nonnull @NotEmpty final String key,
            @Nonnull final HttpServletRequest httpRequest, @Nonnull final HttpServletResponse httpResponse,
            @Nonnull final Exception ex) throws ExternalAuthenticationException, IOException {

        // Finish the external authentication task and return to the flow.
        httpRequest.setAttribute(ExternalAuthentication.AUTHENTICATION_EXCEPTION_KEY, ex);
        ExternalAuthentication.finishExternalAuthentication(key, httpRequest, httpResponse);
    }

    /**
     * Navigate to the {@link SPNEGOContext} in the context tree.
     * 
     * @param prc profile request context
     * 
     * @return the child context, or null
     */
    @Nullable
    private SPNEGOContext getSPNEGOContext(@Nonnull final ProfileRequestContext prc) {
        final AuthenticationContext authnContext = prc.getSubcontext(AuthenticationContext.class);
        return authnContext != null ? authnContext.getSubcontext(SPNEGOContext.class) : null;
    }

    /**
     * Create a new {@link GSSContextAcceptor} object.
     * (Created in a separate method to support unit testing.)
     * 
     * @param spnegoCtx the {@link SPNEGOContext} conteining the {@link KerberosSettings}
     * @return a new {@link GSSContextAcceptor}
     * @throws GSSException if an error occurs while creating the {@link GSSContextAcceptor}.
     */
    @Nonnull
    protected GSSContextAcceptor createGSSContextAcceptor(@Nonnull final SPNEGOContext spnegoCtx)
            throws GSSException {
        return new GSSContextAcceptor(spnegoCtx.getKerberosSettings());
    }

    /**
     * Send back an empty Negotiate challenge.
     * 
     * @param profileRequestContext profile request context
     * @param httpRequest servlet request
     * @param httpResponse servlet response
     * 
     * @return a {@link ModelAndView} wrapping the response
     */
    @Nonnull
    private ModelAndView replyUnauthorizedNegotiate(@Nonnull final ProfileRequestContext profileRequestContext,
            @Nonnull final HttpServletRequest httpRequest, @Nonnull final HttpServletResponse httpResponse) {
        return replyUnauthorizedNegotiate(profileRequestContext, httpRequest, httpResponse, "");
    }

    /**
     * Send back a Negotiate challenge token.
     * 
     * @param profileRequestContext profile request context
     * @param httpRequest servlet request
     * @param httpResponse servlet response
     * @param base64Token challenge token to send back
     * 
     * @return a {@link ModelAndView} wrapping the response
     */
    @Nonnull
    private ModelAndView replyUnauthorizedNegotiate(@Nonnull final ProfileRequestContext profileRequestContext,
            @Nonnull final HttpServletRequest httpRequest, @Nonnull final HttpServletResponse httpResponse,
            @Nonnull final String base64Token) {

        final StringBuilder authenticateHeader = new StringBuilder("Negotiate");
        if (!base64Token.isEmpty()) {
            authenticateHeader.append(" " + base64Token);
        }
        httpResponse.addHeader(HttpHeaders.WWW_AUTHENTICATE, authenticateHeader.toString());
        httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        return createModelAndView(profileRequestContext, httpRequest, httpResponse);
    }

    /**
     * Create a {@link ModelAndView} object to return.
     * 
     * @param profileRequestContext profile request context
     * @param httpRequest the HTTP request
     * @param httpResponse the HTTP response
     * 
     * @return the ModelAndView object
     */
    @Nonnull
    private ModelAndView createModelAndView(@Nonnull final ProfileRequestContext profileRequestContext,
            @Nonnull final HttpServletRequest httpRequest, @Nonnull final HttpServletResponse httpResponse) {
        final ModelAndView modelAndView = new ModelAndView("spnego-unavailable");
        modelAndView.addObject("profileRequestContext", profileRequestContext);
        modelAndView.addObject("request", httpRequest);
        modelAndView.addObject("response", httpResponse);
        modelAndView.addObject("encoder", HTMLEncoder.class);
        final StringBuffer errorUrl = httpRequest.getRequestURL();
        errorUrl.append("/error");
        final String queryString = httpRequest.getQueryString();
        if (queryString != null) {
            errorUrl.append("?").append(queryString);
        }
        modelAndView.addObject("errorUrl", errorUrl.toString());
        return modelAndView;
    }

    /**
     * Check if the GSS-API data represents an NTLM mechanism request.
     * 
     * @param token token retrieved from the Authorization header.
     * 
     * @return true iff it represents a NTLM mechanism
     */
    private boolean isNTLMMechanism(@Nonnull final byte[] token) {
        byte[] headerNTLM = { (byte) 0x4E, (byte) 0x54, (byte) 0x4C, (byte) 0x4D, (byte) 0x53, (byte) 0x53,
                (byte) 0x50 };
        return Arrays.equals(headerNTLM, Arrays.copyOfRange(token, 0, 7));
    }

}