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}