001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions Copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2015 ForgeRock AS.
015 */
016
017package org.forgerock.openig.openam;
018
019import static org.forgerock.http.protocol.Response.newResponsePromise;
020import static org.forgerock.http.protocol.Status.FORBIDDEN;
021import static org.forgerock.json.JsonValue.json;
022import static org.forgerock.json.JsonValue.object;
023import static org.forgerock.openig.el.Bindings.bindings;
024import static org.forgerock.openig.http.Responses.newInternalServerError;
025import static org.forgerock.util.Reject.checkNotNull;
026import static org.forgerock.util.promise.Promises.newResultPromise;
027
028import java.io.IOException;
029import java.net.URI;
030import java.util.Map;
031
032import org.forgerock.http.Filter;
033import org.forgerock.http.Handler;
034import org.forgerock.http.protocol.Request;
035import org.forgerock.http.protocol.Response;
036import org.forgerock.http.session.SessionContext;
037import org.forgerock.openig.el.Bindings;
038import org.forgerock.openig.el.Expression;
039import org.forgerock.openig.heap.GenericHeapObject;
040import org.forgerock.services.context.Context;
041import org.forgerock.util.AsyncFunction;
042import org.forgerock.util.Function;
043import org.forgerock.util.annotations.VisibleForTesting;
044import org.forgerock.util.promise.NeverThrowsException;
045import org.forgerock.util.promise.Promise;
046
047/**
048 * Provides an OpenAM SSO Token in the given header name for downstream components.
049 *
050 * <p>The SSO Token is stored in the session to avoid DOS on OpenAM endpoints.
051 *
052 * <p>If the request failed, a unique attempt to refresh the SSO token is tried.
053 */
054public class SsoTokenFilter extends GenericHeapObject implements Filter {
055
056    static final String SSO_TOKEN_KEY = "SSOToken";
057    static final String BASE_ENDPOINT = "json";
058    static final String AUTHENTICATION_ENDPOINT = "/authenticate";
059    static final String DEFAULT_HEADER_NAME = "iPlanetDirectoryPro";
060
061    private final Handler ssoClientHandler;
062    private final URI openamUrl;
063    private final String realm;
064    private final String headerName;
065    private final Expression<String> username;
066    private final Expression<String> password;
067
068    SsoTokenFilter(final Handler ssoClientHandler,
069                   final URI openamUrl,
070                   final String realm,
071                   final String headerName,
072                   final Expression<String> username,
073                   final Expression<String> password) {
074        this.ssoClientHandler = checkNotNull(ssoClientHandler);
075        this.openamUrl = checkNotNull(openamUrl);
076        this.realm = startsWithSlash(realm);
077        this.headerName = headerName != null ? headerName : DEFAULT_HEADER_NAME;
078        this.username = username;
079        this.password = password;
080    }
081
082    private static String startsWithSlash(final String realm) {
083        String nonNullRealm = realm != null ? realm : "/";
084        return nonNullRealm.startsWith("/") ? nonNullRealm : "/" + nonNullRealm;
085    }
086
087    @Override
088    public Promise<Response, NeverThrowsException> filter(final Context context,
089                                                          final Request request,
090                                                          final Handler next) {
091
092        final AsyncFunction<String, Response, NeverThrowsException> executeRequestWithToken =
093                new AsyncFunction<String, Response, NeverThrowsException>() {
094
095                    @Override
096                    public Promise<Response, NeverThrowsException> apply(String token) {
097                        if (token != null) {
098                            request.getHeaders().put(headerName, token);
099                            return next.handle(context, request);
100                        } else {
101                            return newResponsePromise(newInternalServerError("Unable to retrieve SSO Token"));
102                        }
103                    }
104                };
105
106        final AsyncFunction<Response, Response, NeverThrowsException> checkResponse =
107                new AsyncFunction<Response, Response, NeverThrowsException>() {
108
109                    @Override
110                    public Promise<Response, NeverThrowsException> apply(Response response) {
111                        if (response.getStatus().equals(FORBIDDEN)) {
112                            final SessionContext sessionContext = context.asContext(SessionContext.class);
113                            sessionContext.getSession().remove(SSO_TOKEN_KEY);
114                            return createSsoToken(context, request)
115                                    .thenAsync(executeRequestWithToken);
116                        }
117                        return newResponsePromise(response);
118                    }
119                };
120
121        return findSsoToken(context, request)
122                .thenAsync(executeRequestWithToken)
123                .thenAsync(checkResponse);
124    }
125
126    private Promise<String, NeverThrowsException> findSsoToken(final Context context, final Request request) {
127        final SessionContext sessionContext = context.asContext(SessionContext.class);
128        if (sessionContext.getSession().containsKey(SSO_TOKEN_KEY)) {
129            return newResultPromise((String) sessionContext.getSession().get(SSO_TOKEN_KEY));
130        } else {
131            return createSsoToken(context, request);
132        }
133    }
134
135    private Promise<String, NeverThrowsException> createSsoToken(final Context context, final Request request) {
136        return ssoClientHandler.handle(context, authenticationRequest(bindings(context, request)))
137                               .then(extractSsoToken(context));
138    }
139
140    private Function<Response, String, NeverThrowsException> extractSsoToken(final Context context) {
141        return new Function<Response, String, NeverThrowsException>() {
142            @Override
143            public String apply(Response response) {
144                String token = null;
145                try {
146                    @SuppressWarnings("unchecked")
147                    final Map<String, String> result = (Map<String, String>) response.getEntity().getJson();
148                    token = result.get("tokenId");
149                    context.asContext(SessionContext.class).getSession().put(SSO_TOKEN_KEY, token);
150                } catch (IOException e) {
151                    logger.warning("Couldn't parse as JSON the OpenAM authentication response");
152                    logger.warning(e);
153                }
154                return token;
155            }
156        };
157    }
158
159    @VisibleForTesting
160    Request authenticationRequest(final Bindings bindings) {
161        final Request request = new Request();
162        request.setMethod("POST");
163        request.setUri(openamUrl.resolve(BASE_ENDPOINT + realm + AUTHENTICATION_ENDPOINT));
164        request.setEntity(json(object()).asMap());
165        request.getHeaders().put("X-OpenAM-Username", username.eval(bindings));
166        request.getHeaders().put("X-OpenAM-Password", password.eval(bindings));
167        return request;
168    }
169}