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 java.util.concurrent.TimeUnit.MILLISECONDS;
020import static java.util.concurrent.TimeUnit.SECONDS;
021import static org.forgerock.http.handler.Handlers.chainOf;
022import static org.forgerock.http.protocol.Response.newResponsePromise;
023import static org.forgerock.http.protocol.Status.UNAUTHORIZED;
024import static org.forgerock.http.routing.Version.version;
025import static org.forgerock.json.JsonValue.array;
026import static org.forgerock.json.JsonValue.field;
027import static org.forgerock.json.JsonValue.fieldIfNotNull;
028import static org.forgerock.json.JsonValue.json;
029import static org.forgerock.json.JsonValue.object;
030import static org.forgerock.openig.el.Bindings.bindings;
031import static org.forgerock.openig.heap.Keys.CLIENT_HANDLER_HEAP_KEY;
032import static org.forgerock.openig.util.JsonValues.asExpression;
033import static org.forgerock.openig.util.StringUtil.trailingSlash;
034import static org.forgerock.util.Reject.checkNotNull;
035import static org.forgerock.util.promise.Promises.newResultPromise;
036import static org.forgerock.util.time.Duration.duration;
037
038import java.net.URI;
039import java.net.URISyntaxException;
040import java.util.Map;
041import java.util.concurrent.Callable;
042import java.util.concurrent.ExecutionException;
043import java.util.concurrent.Executors;
044import java.util.concurrent.ScheduledExecutorService;
045
046import org.forgerock.http.Filter;
047import org.forgerock.http.Handler;
048import org.forgerock.http.MutableUri;
049import org.forgerock.http.header.AcceptApiVersionHeader;
050import org.forgerock.http.protocol.Request;
051import org.forgerock.http.protocol.Response;
052import org.forgerock.json.JsonValue;
053import org.forgerock.json.resource.ActionRequest;
054import org.forgerock.json.resource.ActionResponse;
055import org.forgerock.json.resource.InternalServerErrorException;
056import org.forgerock.json.resource.NotSupportedException;
057import org.forgerock.json.resource.RequestHandler;
058import org.forgerock.json.resource.Requests;
059import org.forgerock.json.resource.ResourceException;
060import org.forgerock.json.resource.ResourcePath;
061import org.forgerock.json.resource.http.CrestHttp;
062import org.forgerock.openig.el.Bindings;
063import org.forgerock.openig.el.Expression;
064import org.forgerock.openig.heap.GenericHeapObject;
065import org.forgerock.openig.heap.GenericHeaplet;
066import org.forgerock.openig.heap.HeapException;
067import org.forgerock.openig.util.ThreadSafeCache;
068import org.forgerock.services.context.Context;
069import org.forgerock.util.AsyncFunction;
070import org.forgerock.util.Function;
071import org.forgerock.util.annotations.VisibleForTesting;
072import org.forgerock.util.promise.NeverThrowsException;
073import org.forgerock.util.promise.Promise;
074import org.forgerock.util.time.Duration;
075
076/**
077 * This filter requests policy decisions from OpenAM which evaluates the
078 * original URI based on the context and the policies configured, and according
079 * to the decisions, allows or denies the current request.
080 * <p>
081 * Policy decisions are cached for each filter and eviction is based on the
082 * "time-to-live" given in the policy decision returned by AM, if this one
083 * exceed the duration expressed in the cacheMaxExpiration, then the value of
084 * cacheMaxExpiration is used to cache the policy.
085 *
086 * <pre>
087 * {@code {
088 *      "type": "PolicyEnforcementFilter",
089 *      "config": {
090 *          "openamUrl"              :    uriExpression,      [REQUIRED]
091 *          "pepUsername"            :    expression,         [REQUIRED*]
092 *          "pepPassword"            :    expression,         [REQUIRED*]
093 *          "policiesHandler"        :    handler,            [OPTIONAL - by default it uses the 'ClientHandler'
094 *                                                                        provided in heap.]
095 *          "realm"                  :    String,             [OPTIONAL]
096 *          "ssoTokenHeader"         :    String,             [OPTIONAL]
097 *          "application"            :    String,             [OPTIONAL]
098 *          "ssoTokenSubject"        :    expression,         [OPTIONAL - must be specified if no jwtSubject ]
099 *          "jwtSubject"             :    expression,         [OPTIONAL - must be specified if no ssoTokenSubject ]
100 *          "cacheMaxExpiration"     :    duration            [OPTIONAL - default to 1 minute ]
101 *      }
102 *  }
103 *  }
104 * </pre>
105 * <p>
106 * (*) pepUsername and pepPassword are the credentials of the user who has
107 * access to perform the operation, and these fields are required when using
108 * heaplet. This heaplet adds an SsoTokenFilter to the policiesHandler's chain
109 * and its role is to retrieve and set the SSO token header of this given user.
110 * (REST API calls must present the session token, aka SSO Token, in the HTTP
111 * header as proof of authentication)
112 * <p>
113 * Note: Claims are not supported right now.
114 * <p>
115 * Example of use:
116 *
117 * <pre>
118 * {@code {
119 *      "name": "PEPFilter",
120 *      "type": "PolicyEnforcementFilter",
121 *      "config": {
122 *          "openamUrl": "http://example.com:8090/openam/",
123 *          "pepUsername": "bjensen",
124 *          "pepPassword": "${attributes.userpass}",
125 *          "application": "myApplication",
126 *          "ssoTokenSubject": ${attributes.SSOCurrentUser}
127 *      }
128 *  }
129 *  }
130 * </pre>
131 */
132public class PolicyEnforcementFilter extends GenericHeapObject implements Filter {
133
134    private static final String ONE_MINUTE = "1 minute";
135    private static final String POLICY_ENDPOINT = "/policies";
136    private static final String EVALUATE_ACTION = "evaluate";
137    private static final String SUBJECT_ERROR = "The attribute 'ssoTokenSubject' or 'jwtSubject' must be specified";
138
139    private ThreadSafeCache<String, Promise<JsonValue, ResourceException>> policyDecisionCache;
140    private final URI baseUri;
141    private final Duration cacheMaxExpiration;
142    private final Handler policiesHandler;
143    private String application;
144    private Expression<String> ssoTokenSubject;
145    private Expression<String> jwtSubject;
146
147    @VisibleForTesting
148    PolicyEnforcementFilter(final URI baseUri, final Handler policiesHandler) {
149        this(baseUri, policiesHandler, duration(ONE_MINUTE));
150    }
151
152    /**
153     * Creates a new OpenAM enforcement filter.
154     *
155     * @param baseUri
156     *            The location of the selected OpenAM instance, including the
157     *            realm, to the json base endpoint, not {@code null}.
158     * @param policiesHandler
159     *            The handler used to get perform policies requests, not {@code null}.
160     * @param cacheMaxExpiration
161     *            The max duration to set the cache.
162     */
163    public PolicyEnforcementFilter(final URI baseUri,
164                                   final Handler policiesHandler,
165                                   final Duration cacheMaxExpiration) {
166        this.baseUri = checkNotNull(baseUri);
167        this.cacheMaxExpiration = cacheMaxExpiration;
168        this.policiesHandler = chainOf(checkNotNull(policiesHandler),
169                                       new ApiVersionProtocolHeaderFilter());
170    }
171
172    /**
173     * Sets the cache for the policy decisions.
174     *
175     * @param cache
176     *            The cache for policy decisions to set.
177     */
178    public void setCache(final ThreadSafeCache<String, Promise<JsonValue, ResourceException>> cache) {
179        this.policyDecisionCache = cache;
180    }
181
182    @Override
183    public Promise<Response, NeverThrowsException> filter(final Context context,
184                                                          final Request request,
185                                                          final Handler next) {
186
187        return askForPolicyDecision(context, request)
188                    .then(evaluatePolicyDecision(request))
189                    .thenAsync(allowOrDenyAccessToResource(context, request, next),
190                               returnUnauthorizedResponse);
191    }
192
193    /**
194     * Sets the application where the policies are defined. If none, OpenAM will
195     * use the iPlanetAMWebAgentService.
196     *
197     * @param application
198     *            The application where the policies are defined. If none,
199     *            OpenAM will use the iPlanetAMWebAgentService.
200     */
201    public void setApplication(final String application) {
202        this.application = application;
203    }
204
205    /**
206     * Sets the SSO token for the subject.
207     *
208     * @param ssoTokenSubject
209     *            The SSO Token for the subject.
210     */
211    public void setSsoTokenSubject(final Expression<String> ssoTokenSubject) {
212        this.ssoTokenSubject = ssoTokenSubject;
213    }
214
215    /**
216     * Sets the JWT string for the subject.
217     *
218     * @param jwtSubject
219     *            The JWT string for the subject.
220     */
221    public void setJwtSubject(final Expression<String> jwtSubject) {
222        this.jwtSubject = jwtSubject;
223    }
224
225    private AsyncFunction<ResourceException, Response, NeverThrowsException> returnUnauthorizedResponse =
226            new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
227
228                @Override
229                public Promise<Response, NeverThrowsException> apply(ResourceException exception) {
230                    logger.debug("Cannot get the policy evaluation");
231                    logger.debug(exception);
232                    final Response response = new Response(UNAUTHORIZED);
233                    response.setCause(exception);
234                    return newResponsePromise(response);
235                }
236            };
237
238    private AsyncFunction<Boolean, Response, NeverThrowsException> allowOrDenyAccessToResource(
239            final Context context, final Request request, final Handler next) {
240        return new AsyncFunction<Boolean, Response, NeverThrowsException>() {
241
242            @Override
243            public Promise<Response, NeverThrowsException> apply(final Boolean authorized) {
244                if (authorized) {
245                    return next.handle(context, request);
246                }
247                return newResponsePromise(new Response(UNAUTHORIZED));
248            }
249        };
250    }
251
252    private Promise<JsonValue, ResourceException> askForPolicyDecision(final Context context,
253                                                                       final Request request) {
254        final RequestHandler requestHandler = CrestHttp.newRequestHandler(policiesHandler, baseUri);
255        final ActionRequest actionRequest = Requests.newActionRequest(ResourcePath.valueOf(POLICY_ENDPOINT),
256                                                                      EVALUATE_ACTION);
257
258        Bindings bindings = bindings(context, request);
259        final Map<?, ?> subject =
260                json(object(
261                          fieldIfNotNull("ssoToken", ssoTokenSubject != null ? ssoTokenSubject.eval(bindings) : null),
262                          fieldIfNotNull("jwt", jwtSubject != null ? jwtSubject.eval(bindings) : null))).asMap();
263
264        if (subject.isEmpty()) {
265            logger.error(SUBJECT_ERROR);
266            return new NotSupportedException().asPromise();
267        }
268
269        final JsonValue resources = json(object(
270                                            field("resources", array(request.getUri().toASCIIString())),
271                                            field("subject", subject),
272                                            fieldIfNotNull("application", application)));
273        actionRequest.setContent(resources);
274        actionRequest.setResourceVersion(version(2, 0));
275
276        final String key = createKeyCache((String) subject.get("ssoToken"),
277                                          (String) subject.get("jwt"),
278                                          request.getUri().toASCIIString());
279
280        try {
281            return policyDecisionCache.getValue(key,
282                                                getPolicyDecisionCallable(context, requestHandler, actionRequest),
283                                                extractDurationFromTtl());
284        } catch (InterruptedException | ExecutionException e) {
285            return new InternalServerErrorException(e).asPromise();
286        }
287    }
288
289    private AsyncFunction<Promise<JsonValue, ResourceException>, Duration, Exception>
290    extractDurationFromTtl() {
291        return new AsyncFunction<Promise<JsonValue, ResourceException>, Duration, Exception>() {
292
293            @Override
294            public Promise<Duration, Exception> apply(Promise<JsonValue, ResourceException> value) throws Exception {
295                return value.thenAsync(new AsyncFunction<JsonValue, Duration, Exception>() {
296
297                    @Override
298                    public Promise<? extends Duration, ? extends ResourceException> apply(JsonValue value)
299                            throws Exception {
300                        final Duration timeout = new Duration(value.get("ttl").asLong(), MILLISECONDS);
301                        if (timeout.to(MILLISECONDS) > cacheMaxExpiration.to(MILLISECONDS)) {
302                            return newResultPromise(cacheMaxExpiration);
303                        }
304                        return newResultPromise(timeout);
305                    }
306                }, new AsyncFunction<ResourceException, Duration, Exception>() {
307
308                    @Override
309                    public Promise<? extends Duration, ? extends Exception> apply(ResourceException e)
310                            throws Exception {
311                        return newResultPromise(new Duration(1L, SECONDS));
312                    }
313                });
314            }
315        };
316    }
317
318    private static Callable<Promise<JsonValue, ResourceException>> getPolicyDecisionCallable(
319                                                                                  final Context context,
320                                                                                  final RequestHandler requestHandler,
321                                                                                  final ActionRequest actionRequest) {
322        return new Callable<Promise<JsonValue, ResourceException>>() {
323
324            @Override
325            public Promise<JsonValue, ResourceException> call() throws Exception {
326                return requestHandler.handleAction(context, actionRequest)
327                                     .then(EXTRACT_POLICY_DECISION_AS_JSON);
328            }
329        };
330    }
331
332    @VisibleForTesting
333    static String createKeyCache(final String ssoToken, final String jwt, final String requestedUri) {
334        return new StringBuilder(requestedUri).append(ifSpecified(ssoToken)).append(ifSpecified(jwt)).toString();
335    }
336
337    private static String ifSpecified(final String value) {
338        if (value != null && !value.isEmpty()) {
339            return "@" + value;
340        }
341        return "";
342    }
343
344    private static final Function<ActionResponse, JsonValue, ResourceException> EXTRACT_POLICY_DECISION_AS_JSON =
345            new Function<ActionResponse, JsonValue, ResourceException>() {
346
347                @Override
348                public JsonValue apply(final ActionResponse policyResponse) {
349                    // The policy response is an array
350                    return policyResponse.getJsonContent().get(0);
351                }
352            };
353
354    private static Function<JsonValue, Boolean, ResourceException> evaluatePolicyDecision(final Request request) {
355        return new Function<JsonValue, Boolean, ResourceException>() {
356
357            @Override
358            public Boolean apply(final JsonValue policyDecision) {
359                final MutableUri original = request.getUri();
360                if (policyDecision.get("resource").asString().equals(original.toASCIIString())) {
361                    final String method = request.getMethod();
362                    final Map<String, Object> actions = policyDecision.get("actions").asMap();
363                    if (actions.containsKey(method)) {
364                        return (boolean) actions.get(method);
365                    }
366                }
367                return false;
368            }
369        };
370    }
371
372    /** Creates and initializes a policy enforcement filter in a heap environment. */
373    public static class Heaplet extends GenericHeaplet {
374
375        private ScheduledExecutorService executor;
376        private ThreadSafeCache<String, Promise<JsonValue, ResourceException>> cache;
377
378        @Override
379        public Object create() throws HeapException {
380
381            final String openamUrl = trailingSlash(config.get("openamUrl").required().asString());
382            final Expression<String> pepUsername = asExpression(config.get("pepUsername").required(), String.class);
383            final Expression<String> pepPassword = asExpression(config.get("pepPassword").required(), String.class);
384            final String realm = config.get("realm").defaultTo("/").asString();
385            final Handler policiesHandler = heap.resolve(config.get("policiesHandler")
386                                                               .defaultTo(CLIENT_HANDLER_HEAP_KEY),
387                                                         Handler.class);
388            final String ssoTokenHeader = config.get("ssoTokenHeader").asString();
389
390            final Duration cacheMaxExpiration = duration(config.get("cacheMaxExpiration").defaultTo(ONE_MINUTE)
391                                                                                         .asString());
392            if (cacheMaxExpiration.isZero() || cacheMaxExpiration.isUnlimited()) {
393                throw new HeapException("The max expiration value cannot be set to 0 or to 'unlimited'");
394            }
395
396            try {
397                final SsoTokenFilter ssoTokenFilter = new SsoTokenFilter(policiesHandler,
398                                                                         new URI(openamUrl),
399                                                                         realm,
400                                                                         ssoTokenHeader,
401                                                                         pepUsername,
402                                                                         pepPassword);
403
404                final PolicyEnforcementFilter filter = new PolicyEnforcementFilter(normalizeToJsonEndpoint(openamUrl,
405                                                                                                           realm),
406                                                                                   chainOf(policiesHandler,
407                                                                                           ssoTokenFilter),
408                                                                                   cacheMaxExpiration);
409
410                filter.setApplication(config.get("application").asString());
411                filter.setSsoTokenSubject(asExpression(config.get("ssoTokenSubject"), String.class));
412                filter.setJwtSubject(asExpression(config.get("jwtSubject"), String.class));
413                if (config.get("ssoTokenSubject").isNull() && config.get("jwtSubject").isNull()) {
414                    throw new HeapException(SUBJECT_ERROR);
415                }
416
417                // Sets the cache
418                executor = Executors.newSingleThreadScheduledExecutor();
419                cache = new ThreadSafeCache<>(executor);
420                filter.setCache(cache);
421
422                return filter;
423            } catch (URISyntaxException e) {
424                throw new HeapException(e);
425            }
426        }
427
428        @VisibleForTesting
429        static URI normalizeToJsonEndpoint(final String openamUri, final String realm) throws URISyntaxException {
430            final StringBuilder builder = new StringBuilder(openamUri);
431            builder.append("json");
432            if (realm == null || realm.trim().isEmpty()) {
433                builder.append("/");
434            } else {
435                if (!realm.startsWith("/")) {
436                    builder.append("/");
437                }
438                builder.append(trailingSlash(realm.trim()));
439            }
440            return new URI(builder.toString());
441        }
442
443        @Override
444        public void destroy() {
445            if (executor != null) {
446                executor.shutdownNow();
447            }
448            if (cache != null) {
449                cache.clear();
450            }
451        }
452    }
453
454    private class ApiVersionProtocolHeaderFilter implements Filter {
455
456        @Override
457        public Promise<Response, NeverThrowsException> filter(Context context, Request request, Handler next) {
458            // The protocol versions supported in OPENAM-13 is 1.0 and
459            // CREST adapter forces to 2.0, throwing a 'Unsupported major
460            // version: 2.0' exception if not set. CREST operation(action) is
461            // compatible between protocol v1 and v2
462            request.getHeaders().put(AcceptApiVersionHeader.NAME, "protocol=1.0, resource=2.0");
463            return next.handle(context, request);
464        }
465    }
466}