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}