| // Copyright 2013 Google Inc. All Rights Reserved. |
| // |
| // Licensed 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 com.google.enterprise.adaptor; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.enterprise.secmgr.authncontroller.ExportedState; |
| import com.google.enterprise.secmgr.http.HttpClientInterface; |
| import com.google.enterprise.secmgr.modules.SamlClient; |
| import com.google.enterprise.secmgr.servlets.ResponseParser; |
| |
| import com.sun.net.httpserver.HttpExchange; |
| import com.sun.net.httpserver.HttpHandler; |
| |
| import org.joda.time.DateTime; |
| import org.opensaml.saml2.core.Response; |
| import org.opensaml.saml2.core.StatusCode; |
| import org.opensaml.xml.security.SecurityHelper; |
| import org.opensaml.xml.security.credential.Credential; |
| |
| import java.io.IOException; |
| import java.net.HttpURLConnection; |
| import java.net.URI; |
| import java.security.KeyPair; |
| import java.util.Date; |
| import java.util.Set; |
| import java.util.logging.Level; |
| import java.util.logging.Logger; |
| |
| /** |
| * Provides the ability to send SAML authn requests and receive the responses. |
| * |
| * <p>This functions in the Service Provider (SP) role in SAML. A SP is simply |
| * the site that the user was trying to use (it provides a service to the user). |
| * The SP sends the user to an Identity Provider (IdP) to be authenticated. |
| */ |
| class SamlServiceProvider { |
| @VisibleForTesting |
| static final String SESSION_STATE_ATTR_NAME = "authnState"; |
| |
| private static final Logger log = Logger.getLogger( |
| SamlServiceProvider.class.getName()); |
| |
| /** |
| * Manager that handles keeping track of users attempting to authenticate. |
| */ |
| private final SessionManager<HttpExchange> sessionManager; |
| /** SAML configuration of endpoints. */ |
| private final SamlMetadata metadata; |
| /** Credentials to use to sign messages. */ |
| private final Credential cred; |
| /** |
| * Http client implementation that {@code SamlClient} will use to send |
| * requests directly to the GSA, for resolving SAML artifacts. |
| */ |
| private final HttpClientInterface httpClient; |
| private final AssertionConsumerHandler assertionConsumer |
| = new AssertionConsumerHandler(); |
| |
| /** |
| * @param sessionManager manager for storing session state, like authn |
| * results |
| * @param metadata SAML configuration of endpoints |
| * @param keyAlias alias in keystore that contains the key for signing |
| * messages |
| */ |
| public SamlServiceProvider(SessionManager<HttpExchange> sessionManager, |
| SamlMetadata metadata, KeyPair key) { |
| this(sessionManager, metadata, key, new HttpClientAdapter()); |
| } |
| |
| @VisibleForTesting |
| SamlServiceProvider(SessionManager<HttpExchange> sessionManager, |
| SamlMetadata metadata, KeyPair key, HttpClientInterface httpClient) { |
| if (metadata == null || sessionManager == null || httpClient == null) { |
| throw new NullPointerException(); |
| } |
| this.sessionManager = sessionManager; |
| this.metadata = metadata; |
| this.cred = (key == null) ? null |
| : SecurityHelper.getSimpleCredential(key.getPublic(), |
| key.getPrivate()); |
| this.httpClient = httpClient; |
| } |
| |
| /** |
| * Consumer of authentication responses. It must be registered as a handler |
| * with the HTTP server with the same path as defined in the SamlMetadata. |
| */ |
| public HttpHandler getAssertionConsumer() { |
| return assertionConsumer; |
| } |
| |
| /** |
| * Get the identity of the current user. This uses {@code ex} to identify the |
| * user, but does not do any modification of the exchange. |
| * |
| * @return the identity of the user, or {@code null} if the user is not |
| * currently authenticated |
| */ |
| public AuthnIdentity getUserIdentity(HttpExchange ex) { |
| Session session = sessionManager.getSession(ex, false); |
| if (session == null) { |
| return null; |
| } |
| AuthnState authnState |
| = (AuthnState) session.getAttribute(SESSION_STATE_ATTR_NAME); |
| if (authnState != null && authnState.isAuthenticated()) { |
| return authnState.getIdentity(); |
| } |
| return null; |
| } |
| |
| /** |
| * Authenticate the user, consuming {@code ex} in the process. This creates a |
| * SAML Authn Request to the IdP and redirects the user to begin the |
| * authentication process. At the end of the process, the user will be |
| * redirected back to the current URL. |
| * |
| * <p>{@link #getAssertionConsumer} must have been properly registered with |
| * the HTTP server for the operation to complete successfully. |
| */ |
| public void handleAuthentication(HttpExchange ex) throws IOException { |
| String requestMethod = ex.getRequestMethod(); |
| if (!"GET".equals(requestMethod) && !"HEAD".equals(requestMethod)) { |
| HttpExchanges.cannedRespond(ex, HttpURLConnection.HTTP_BAD_METHOD, |
| Translation.HTTP_BAD_METHOD); |
| return; |
| } |
| |
| Session session = sessionManager.getSession(ex); |
| AuthnState authnState = (AuthnState) session.getAttribute( |
| SESSION_STATE_ATTR_NAME); |
| if (authnState == null) { |
| authnState = new AuthnState(); |
| session.setAttribute(SESSION_STATE_ATTR_NAME, authnState); |
| } |
| SamlClient client = |
| new SamlClient( |
| metadata.getLocalEntity(), |
| metadata.getPeerEntity(), |
| "GSA Adaptor", |
| cred, |
| httpClient); |
| authnState.startAttempt(client, HttpExchanges.getRequestUri(ex)); |
| client.sendAuthnRequest(new HttpExchangeOutTransportAdapter(ex, true)); |
| } |
| |
| /** |
| * A servlet that implements the SAML "assertion consumer" role for SAML |
| * credentials gathering. Once the client has been authenticated with the GSA, |
| * the SSO service will redirect the client to us and include a SAML response. |
| * This {@code Handler} interprets the response and updates our knowledge |
| * about the client, followed by redirecting the client back to the original |
| * page they tried to visit. |
| */ |
| private class AssertionConsumerHandler implements HttpHandler { |
| public AssertionConsumerHandler() { |
| SecurityManagerConfig.load(); |
| } |
| |
| @Override |
| public void handle(HttpExchange ex) throws IOException { |
| String requestMethod = ex.getRequestMethod(); |
| if (!"GET".equals(requestMethod)) { |
| HttpExchanges.cannedRespond(ex, HttpURLConnection.HTTP_BAD_METHOD, |
| Translation.HTTP_BAD_METHOD); |
| return; |
| } |
| Session session = sessionManager.getSession(ex); |
| AuthnState authnState = (AuthnState) session.getAttribute( |
| SESSION_STATE_ATTR_NAME); |
| if (authnState == null) { |
| HttpExchanges.cannedRespond(ex, HttpURLConnection.HTTP_CONFLICT, |
| Translation.AUTHN_UNKNOWN_SESSION); |
| return; |
| } |
| if (authnState.isAuthenticated()) { |
| // TODO(ejona): keep track of each request, so that we can redirect here |
| HttpExchanges.cannedRespond(ex, HttpURLConnection.HTTP_CONFLICT, |
| Translation.AUTHN_RETRY); |
| return; |
| } |
| SamlClient client = authnState.getSamlClient(); |
| URI origUri = authnState.getOriginalUri(); |
| if (client == null || origUri == null) { |
| HttpExchanges.cannedRespond(ex, HttpURLConnection.HTTP_INTERNAL_ERROR, |
| Translation.AUTHN_NOT_STARTED); |
| return; |
| } |
| boolean authnSuccess; |
| // GET implies the assertion is being sent with the artifact binding. |
| log.info("Received assertion via artifact binding"); |
| Response samlResponse = client.decodeArtifactResponse( |
| HttpExchanges.getRequestUri(ex), |
| new HttpExchangeInTransportAdapter(ex)); |
| authnSuccess = consumeAssertion(client, samlResponse, |
| client.getArtifactAssertionConsumerService().getLocation(), |
| authnState); |
| |
| if (authnSuccess) { |
| HttpExchanges.sendRedirect(ex, origUri); |
| } else { |
| HttpExchanges.cannedRespond(ex, HttpURLConnection.HTTP_FORBIDDEN, |
| Translation.HTTP_FORBIDDEN_AUTHN_FAILURE); |
| } |
| } |
| |
| private boolean consumeAssertion(SamlClient client, Response samlResponse, |
| String recipient, AuthnState authnState) { |
| if (samlResponse == null) { |
| log.warning("SAML response is missing"); |
| authnState.failAttempt(); |
| return false; |
| } |
| |
| // "some session id" is trash that is used for logging in the |
| // ResponseParser. We have no need for it, but we need to provide a value. |
| ResponseParser parser = ResponseParser.make( |
| client, recipient, samlResponse, "some session id"); |
| if (!parser.isResponseValid()) { |
| log.warning("SAML response is invalid"); |
| authnState.failAttempt(); |
| return false; |
| } |
| |
| String code = parser.getResponseStatus(); |
| log.log(Level.INFO, "status code = {0}", new Object[] {code}); |
| if (!code.equals(StatusCode.SUCCESS_URI)) { |
| log.log(Level.WARNING, "SAML IdP failed to resolve: {0}", |
| new Object[] {code}); |
| authnState.failAttempt(); |
| return false; |
| } |
| |
| if (!parser.areAssertionsValid()) { |
| log.warning("One or more SAML assertions are invalid"); |
| authnState.failAttempt(); |
| return false; |
| } |
| String subjectName = parser.getSubject(); |
| Set<String> groups = null; |
| String password = null; |
| |
| ExportedState state = parser.getExportedState(); |
| if (state != null) { |
| // Groups is also available via parser.getGroups, but this handles the |
| // state == null case more appropriately for our usage. |
| groups = state.getPviCredentials().getGroups(); |
| password = state.getPviCredentials().getPassword(); |
| } |
| DateTime expirationDateTime = parser.getExpirationTime(); |
| long expirationTime = (expirationDateTime == null) |
| ? Long.MAX_VALUE : expirationDateTime.getMillis(); |
| log.log(Level.INFO, "SAML subject {0} verified {1}", |
| new Object[] {subjectName, new Date(expirationTime)}); |
| AuthnIdentity identity = new AuthnIdentityImpl |
| .Builder(new UserPrincipal(subjectName)) |
| .setGroups(GroupPrincipal.makeSet(groups)) |
| .setPassword(password).build(); |
| authnState.authenticated(identity, expirationTime); |
| return true; |
| } |
| } |
| |
| /** |
| * Current state of an authentication attempt. It can represent a pending |
| * authentication attempt or a completed authentication. |
| */ |
| @VisibleForTesting |
| static class AuthnState { |
| /** Client used for pending authn attempt. */ |
| private SamlClient client; |
| /** Original URL that was accessed that caused this authn attempt. */ |
| private URI originalUri; |
| /** Successfully authned identity for the user. */ |
| private AuthnIdentity identity; |
| /** Time in milliseconds that authentication information expires */ |
| private long expirationTimeMillis; |
| |
| public void startAttempt(SamlClient client, URI originalUri) { |
| expirationTimeMillis = 0; |
| this.client = client; |
| this.originalUri = originalUri; |
| } |
| |
| public void failAttempt() { |
| clearAttempt(); |
| } |
| |
| private void clearAttempt() { |
| client = null; |
| originalUri = null; |
| } |
| |
| public void authenticated(AuthnIdentity identity, |
| long expirationTimeMillis) { |
| clearAttempt(); |
| this.identity = identity; |
| this.expirationTimeMillis = expirationTimeMillis; |
| } |
| |
| public boolean isAuthenticated() { |
| if (expirationTimeMillis == 0) { |
| return false; |
| } |
| |
| if (System.currentTimeMillis() > expirationTimeMillis) { |
| expirationTimeMillis = 0; |
| identity = null; |
| return false; |
| } |
| |
| return true; |
| } |
| |
| public SamlClient getSamlClient() { |
| return client; |
| } |
| |
| public URI getOriginalUri() { |
| return originalUri; |
| } |
| |
| public AuthnIdentity getIdentity() { |
| return identity; |
| } |
| } |
| } |