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.filter; 018 019import static java.lang.Boolean.TRUE; 020import static org.forgerock.http.handler.Handlers.chainOf; 021import static org.forgerock.http.protocol.Response.newResponsePromise; 022import static org.forgerock.openig.el.Bindings.bindings; 023import static org.forgerock.openig.util.JsonValues.asExpression; 024import static org.forgerock.openig.util.MessageType.RESPONSE; 025 026import java.util.ArrayList; 027import java.util.List; 028import java.util.Map; 029 030import org.forgerock.http.Filter; 031import org.forgerock.http.Handler; 032import org.forgerock.http.protocol.Request; 033import org.forgerock.http.protocol.Response; 034import org.forgerock.json.JsonValue; 035import org.forgerock.openig.el.Bindings; 036import org.forgerock.openig.el.Expression; 037import org.forgerock.openig.el.ExpressionException; 038import org.forgerock.openig.heap.GenericHeapObject; 039import org.forgerock.openig.heap.GenericHeaplet; 040import org.forgerock.openig.heap.HeapException; 041import org.forgerock.openig.regex.PatternTemplate; 042import org.forgerock.services.context.AttributesContext; 043import org.forgerock.services.context.Context; 044import org.forgerock.util.AsyncFunction; 045import org.forgerock.util.promise.NeverThrowsException; 046import org.forgerock.util.promise.Promise; 047import org.forgerock.util.promise.ResultHandler; 048 049/** 050 * Supports password replay feature in a composite filter. 051 * Two use cases are supported: 052 * <ul> 053 * <li>Replaying credentials when a login page is <em>queried</em> (on the request flow)</li> 054 * <li>Replaying credentials when a login page is <em>returned</em> (on the response flow)</li> 055 * </ul> 056 * 057 * <p>A variation on the first case is possible: it can let the request flow and extract values from the server's 058 * response. 059 * 060 * <p>This filter supports value extraction for any server-provided values that would be re-used in the 061 * authentication request. 062 * 063 * <p>Credentials must be installed by a filter that will only be placed in a chain when needed (in case they're not 064 * already there). If required (and if the credentials are available as request headers), we can decrypt them using a 065 * {@link CryptoHeaderFilter}. 066 * 067 * <p>Then an authentication request is built (using a {@link StaticRequestFilter}) and sent down the chain. 068 * 069 * Note that: 070 * <ul> 071 * <li>There is no retry in case of authentication failure</li> 072 * <li>Extracted patterns need to have at least a single regex group, the first one is always used</li> 073 * </ul> 074 * 075 * <h3>Usage examples:</h3> 076 * 077 * <h4>Authenticate on behalf of the user when a login page is GET</h4> 078 * 079 * <p>When a {@literal GET} request to the login page is intercepted, OpenIG will generate an alternative 080 * authentication request and send it in place of the original request. The response is forwarded as-is to the caller. 081 * All other requests are forwarded untouched. 082 * 083 * <pre> 084 * {@code { 085 * "loginPage": "${matches(request.uri.path, '/login') and (request.method == 'GET')}", 086 * "request": { 087 * "method": "POST", 088 * "uri": "http://internal.example.com/login", 089 * "form": { 090 * "username": [ "${attributes.username}" ], 091 * "password": [ "${attributes.password}" ] 092 * } 093 * } 094 * } 095 * } 096 * </pre> 097 * 098 * <h4>Authenticate on behalf of the user when a login page is returned</h4> 099 * 100 * <p>When a response that is identified to be a login page is intercepted, OpenIG will generate an authentication 101 * request and send it. The authentication response is ignored ATM. Then OpenIG replays the original incoming request. 102 * 103 * <pre> 104 * {@code { 105 * "loginPageContentMarker": "I'm a login page", 106 * "request": { 107 * "method": "POST", 108 * "uri": "http://internal.example.com/login", 109 * "headers": { 110 * "X-OpenAM-Username": [ "${attributes.username}" ], 111 * "X-OpenAM-Password": [ "${attributes.password}" ] 112 * } 113 * } 114 * } 115 * } 116 * </pre> 117 * 118 * <h2>Options</h2> 119 * 120 * <h3>Obtain credentials</h3> 121 * 122 * The PasswordReplay Filter can be configured (with the {@code credentials} attribute) to invoke an additional 123 * Filter that would be responsible to obtain credentials and make them available in the request processing data 124 * structures. These values can then be used to create an appropriate authentication request. 125 * 126 * <p>The {@code credentials} attribute expects a reference to a {@link Filter} heap object. 127 * 128 * <p>Examples of such filters can be {@link FileAttributesFilter} (to load credentials from a local CSV file) 129 * or {@link SqlAttributesFilter} (to load credentials from a database). 130 * 131 * <pre> 132 * {@code { 133 * "loginPageContentMarker": "I'm a login page", 134 * "credentials": { 135 * "type": "FileAttributesFilter", 136 * "config": { 137 * "file": "${system.home}/users.csv", 138 * "key": "uid", 139 * "value": "${attributes.whoami}", 140 * "target": "${attributes.user}" 141 * } 142 * } 143 * "request": { 144 * "method": "POST", 145 * "uri": "http://internal.example.com/login", 146 * "headers": { 147 * "X-OpenAM-Username": [ "${attributes.user.uid}" ], 148 * "X-OpenAM-Password": [ "${attributes.user.password}" ] 149 * } 150 * } 151 * } 152 * } 153 * </pre> 154 * 155 * <h3>Extract custom values from intercepted response page</h3> 156 * 157 * It may happen that the login page contains a form with hidden fields that will be send back to the IDP when submit 158 * button will be hit. 159 * As this filter doesn't interpret the returned page content and generate a new authentication request, it needs a 160 * way to extract some values from the response's entity. 161 * 162 * <p>Multiple values can be extracted at once, extraction is based on pattern matching (and use a 163 * {@link EntityExtractFilter} under the hood). 164 * As opposed to the {@literal EntityExtractFilter}, only 1 group is supported, and matched group value is placed in 165 * the results. All extracted values will be placed in a Map available in 166 * {@literal attributes.extracted}. 167 * 168 * <pre> 169 * {@code { 170 * "loginPageContentMarker": "I'm a login page", 171 * "loginPageExtractions": [ 172 * { 173 * "name": "nonce", 174 * "pattern": " nonce='(.*)'" 175 * } 176 * ], 177 * "request": { 178 * "method": "POST", 179 * "uri": "http://internal.example.com/login", 180 * "form": { 181 * "username": [ "${attributes.username}" ], 182 * "password": [ "${attributes.password}" ] 183 * "nonce": [ "${attributes.extracted.nonce}" ] 184 * } 185 * } 186 * } 187 * } 188 * </pre> 189 * 190 * <h3>Decrypt values provided as request headers</h3> 191 * 192 * When using an OpenAM policy agent in front of OpenIG, the agent configured to retrieve username and password, the 193 * request will contains encrypted headers. 194 * For replaying them, this filter needs to decrypt theses values before creating the authentication request. 195 * 196 * <p>This filter use a {@link CryptoHeaderFilter} to do the decryption of values. Note that it only decrypts and 197 * always acts on the request flow. All other attributes are the same as those used for configuring a normal 198 * {@link CryptoHeaderFilter}. 199 * 200 * <p>Note that this is only one example usage, as soon as there are encrypted values in headers, this function is 201 * here to decrypt them in place if needed. 202 * 203 * <pre> 204 * {@code { 205 * "loginPageContentMarker": "I'm a login page", 206 * "headerDecryption": { 207 * "algorithm": "DES/ECB/NoPadding", 208 * "key": "....", 209 * "keyType": "DES", 210 * "headers": [ "X-OpenAM-Password" ] 211 * }, 212 * "request": { 213 * "method": "POST", 214 * "uri": "http://internal.example.com/login", 215 * "form": { 216 * "username": [ "${request.headers['X-OpenAM-Username'][0]}" ], 217 * "password": [ "${request.headers['X-OpenAM-Password'][0]}" ] 218 * } 219 * } 220 * } 221 * } 222 * </pre> 223 */ 224public class PasswordReplayFilter extends GenericHeapObject { 225 226 /** Creates and initializes an password-replay filter in a heap environment. */ 227 public static class Heaplet extends GenericHeaplet { 228 229 static final String IS_LOGIN_PAGE_ATTR = "isLoginPage"; 230 private EntityExtractFilter extractFilter; 231 private StaticRequestFilter createRequestFilter; 232 private Expression<Boolean> loginPage; 233 private Filter credentialsFilter; 234 private CryptoHeaderFilter decryptFilter; 235 236 @Override 237 public Object create() throws HeapException { 238 239 boolean hasLoginPageMarker = config.isDefined("loginPageContentMarker"); 240 boolean hasLoginPage = config.isDefined("loginPage"); 241 if (!hasLoginPage && !hasLoginPageMarker) { 242 throw new HeapException("Either 'loginPage' or 'loginPageContentMarker' (or both) must have a value"); 243 } 244 245 loginPage = hasLoginPage ? asExpression(config.get("loginPage"), Boolean.class) : null; 246 247 createRequestFilter = (StaticRequestFilter) new StaticRequestFilter.Heaplet() 248 .create(qualified.child("$request-creator"), 249 config.get("request").required(), 250 heap); 251 252 credentialsFilter = heap.resolve(config.get("credentials"), Filter.class, true); 253 254 JsonValue headerDecryption = config.get("headerDecryption"); 255 if (headerDecryption.isNotNull()) { 256 headerDecryption.put("messageType", "request"); 257 headerDecryption.put("operation", "decrypt"); 258 decryptFilter = (CryptoHeaderFilter) 259 new CryptoHeaderFilter.Heaplet().create(qualified.child("$decrypt"), headerDecryption, heap); 260 } 261 262 extractFilter = null; 263 if (hasLoginPageMarker) { 264 extractFilter = createEntityExtractFilter(); 265 extractFilter.getExtractor() 266 .getPatterns() 267 .put(IS_LOGIN_PAGE_ATTR, config.get("loginPageContentMarker").asPattern()); 268 extractFilter.getExtractor() 269 .getTemplates() 270 .put(IS_LOGIN_PAGE_ATTR, new PatternTemplate("true")); 271 } 272 273 for (JsonValue extraction : config.get("loginPageExtractions")) { 274 if (extractFilter == null) { 275 extractFilter = createEntityExtractFilter(); 276 } 277 String name = extraction.get("name").required().asString(); 278 extractFilter.getExtractor() 279 .getPatterns() 280 .put(name, extraction.get("pattern").required().asPattern()); 281 extractFilter.getExtractor() 282 .getTemplates() 283 .put(name, new PatternTemplate("$1")); 284 } 285 286 if (hasLoginPage) { 287 if (!config.isDefined("loginPageExtractions")) { 288 // case 1: 289 // a loginPage, but no loginPageExtractions (we don't care about result) 290 return new Filter() { 291 @Override 292 public Promise<Response, NeverThrowsException> filter(final Context context, 293 final Request request, 294 final Handler next) { 295 // Request targeting the login page ? 296 if (isLoginPageRequest(bindings(context, request))) { 297 return authentication(next).handle(context, request); 298 } 299 // pass through 300 return next.handle(context, request); 301 } 302 303 private Handler authentication(final Handler next) { 304 List<Filter> filters = new ArrayList<>(); 305 if (credentialsFilter != null) { 306 filters.add(credentialsFilter); 307 } 308 if (decryptFilter != null) { 309 filters.add(decryptFilter); 310 } 311 filters.add(createRequestFilter); 312 return chainOf(next, filters); 313 } 314 }; 315 } else { 316 // case 2: 317 // a loginPage, with loginPageExtractions 318 // need to extract values from login page response 319 return new Filter() { 320 @Override 321 public Promise<Response, NeverThrowsException> filter(final Context context, 322 final Request request, 323 final Handler next) { 324 // Request targeting the login page ? 325 if (isLoginPageRequest(bindings(context, request))) { 326 return extractFilter.filter(context, request, next) 327 .thenOnResult(markAsLoginPage(context)) 328 .thenAsync(authenticateIfNeeded(context, request, next, false)); 329 } 330 // pass through 331 return next.handle(context, request); 332 } 333 }; 334 } 335 } else { 336 // no login page pattern 337 // need to intercept all responses 338 // TODO maybe we can do that only when not authenticated ? 339 // but that would assume that we know when we're no longer logged into the application 340 return new Filter() { 341 @Override 342 public Promise<Response, NeverThrowsException> filter(final Context context, 343 final Request request, 344 final Handler next) { 345 346 // Call the filter responsible for extracting values 347 return extractFilter.filter(context, request, next) 348 .thenAsync(authenticateIfNeeded(context, request, next, true)); 349 } 350 }; 351 } 352 } 353 354 private ResultHandler<Response> markAsLoginPage(final Context context) { 355 return new ResultHandler<Response>() { 356 @Override 357 public void handleResult(final Response result) { 358 getExtractedValues(context).put(IS_LOGIN_PAGE_ATTR, "true"); 359 } 360 }; 361 } 362 363 private boolean isLoginPageRequest(final Bindings bindings) { 364 return TRUE.equals(loginPage.eval(bindings)); 365 } 366 367 private AsyncFunction<Response, Response, NeverThrowsException> authenticateIfNeeded(final Context context, 368 final Request request, 369 final Handler next, 370 final boolean replay) { 371 return new AsyncFunction<Response, Response, NeverThrowsException>() { 372 @Override 373 public Promise<Response, NeverThrowsException> apply(final Response response) { 374 List<Filter> filters = new ArrayList<>(); 375 if (credentialsFilter != null) { 376 filters.add(credentialsFilter); 377 } 378 379 if (decryptFilter != null) { 380 filters.add(decryptFilter); 381 } 382 383 // values have been extracted 384 Map<String, String> values = getExtractedValues(context); 385 if (values.get(IS_LOGIN_PAGE_ATTR) != null) { 386 // we got a login page, we need to authenticate the user 387 filters.add(createRequestFilter); 388 } 389 390 // Fast exit 391 if (filters.isEmpty()) { 392 // let the response flow back 393 return newResponsePromise(response); 394 } 395 // Go through the authentication chain 396 Promise<Response, NeverThrowsException> promise = chainOf(next, filters).handle(context, request); 397 if (replay) { 398 return promise.thenAsync(replayOriginalRequest(context, request, next)); 399 } 400 return promise; 401 } 402 }; 403 } 404 405 private AsyncFunction<Response, Response, NeverThrowsException> replayOriginalRequest(final Context context, 406 final Request request, 407 final Handler next) { 408 return new AsyncFunction<Response, Response, NeverThrowsException>() { 409 @Override 410 public Promise<Response, NeverThrowsException> apply(final Response value) { 411 // Ignore response and replay original request 412 return next.handle(context, request); 413 } 414 }; 415 } 416 417 @SuppressWarnings("unchecked") 418 private Map<String, String> getExtractedValues(final Context context) { 419 AttributesContext attributesContext = context.asContext(AttributesContext.class); 420 return (Map<String, String>) attributesContext.getAttributes().get("extracted"); 421 } 422 423 private EntityExtractFilter createEntityExtractFilter() throws HeapException { 424 try { 425 Expression<Object> target = Expression.valueOf("${attributes.extracted}", 426 Object.class); 427 return new EntityExtractFilter(RESPONSE, target); 428 } catch (ExpressionException e) { 429 // Should never happen: expression is under control 430 throw new HeapException(e); 431 } 432 } 433 } 434}