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 2009 Sun Microsystems Inc.
015 * Portions Copyright 2010-2011 ApexIdentity Inc.
016 * Portions Copyright 2011-2015 ForgeRock AS.
017 */
018
019// TODO: distinguish between basic and other schemes that use 401 (Digest, OAuth, ...)
020
021package org.forgerock.openig.filter;
022
023import static org.forgerock.openig.el.Bindings.bindings;
024import static org.forgerock.openig.http.Responses.blockingCall;
025import static org.forgerock.openig.http.Responses.newInternalServerError;
026import static org.forgerock.openig.util.JsonValues.asExpression;
027import static org.forgerock.util.Utils.closeSilently;
028import static org.forgerock.util.promise.Promises.newResultPromise;
029
030import java.nio.charset.Charset;
031import java.util.Arrays;
032
033import org.forgerock.http.Filter;
034import org.forgerock.http.Handler;
035import org.forgerock.http.protocol.Request;
036import org.forgerock.http.protocol.Response;
037import org.forgerock.http.protocol.Status;
038import org.forgerock.http.session.Session;
039import org.forgerock.http.session.SessionContext;
040import org.forgerock.http.util.CaseInsensitiveSet;
041import org.forgerock.openig.el.Bindings;
042import org.forgerock.openig.el.Expression;
043import org.forgerock.openig.heap.GenericHeapObject;
044import org.forgerock.openig.heap.GenericHeaplet;
045import org.forgerock.openig.heap.HeapException;
046import org.forgerock.services.context.Context;
047import org.forgerock.util.encode.Base64;
048import org.forgerock.util.promise.NeverThrowsException;
049import org.forgerock.util.promise.Promise;
050
051/**
052 * Performs authentication through the HTTP Basic authentication scheme. For more information,
053 * see <a href="http://www.ietf.org/rfc/rfc2617.txt">RFC 2617</a>.
054 * <p>
055 * If challenged for authentication via a {@code 401 Unauthorized} status code by the server,
056 * this filter will retry the request with credentials attached. Therefore, the request entity
057 * will be branched and stored for the duration of the processing.
058 * <p>
059 * Once an HTTP authentication challenge (status code 401) is issued from the remote server,
060 * all subsequent requests to that remote server that pass through the filter will include the
061 * user credentials.
062 * <p>
063 * Credentials are cached in the session to allow subsequent requests to automatically include
064 * authentication credentials. If authentication fails (including the case of no credentials
065 * yielded from the {@code username} or {@code password} expressions, then the processing is diverted
066 * to the authentication failure handler.
067 */
068public class HttpBasicAuthFilter extends GenericHeapObject implements Filter {
069
070    /** Headers that are suppressed from incoming request. */
071    private static final CaseInsensitiveSet SUPPRESS_REQUEST_HEADERS =
072            new CaseInsensitiveSet(Arrays.asList("Authorization"));
073
074    /** Headers that are suppressed for outgoing response. */
075    private static final CaseInsensitiveSet SUPPRESS_RESPONSE_HEADERS =
076            new CaseInsensitiveSet(Arrays.asList("WWW-Authenticate"));
077
078    /** Expression that yields the username to supply during authentication. */
079    private final Expression<String> username;
080
081    /** Expression that yields the password to supply during authentication. */
082    private final Expression<String> password;
083
084    /** Handler dispatch to if authentication fails. */
085    private final Handler failureHandler;
086
087    /** Decide if we cache the password header result. */
088    private boolean cacheHeader = true;
089
090    /**
091     * Builds a {@code HttpBasicAuthFilter} with required expressions and error handler.
092     * @param username the expression that yields the username to supply during authentication.
093     * @param password the expression that yields the password to supply during authentication.
094     * @param failureHandler the Handler to dispatch to if authentication fails.
095     */
096    public HttpBasicAuthFilter(final Expression<String> username,
097            final Expression<String> password,
098            final Handler failureHandler) {
099        this.username = username;
100        this.password = password;
101        this.failureHandler = failureHandler;
102    }
103
104    /**
105     * Decide if we cache the password header result (defaults to {@literal true}).
106     * @param cacheHeader cache (or not) the {@literal Authorization} header
107     */
108    public void setCacheHeader(final boolean cacheHeader) {
109        this.cacheHeader = cacheHeader;
110    }
111
112    /**
113     * Resolves a session attribute name for the remote server specified in the specified
114     * request.
115     *
116     * @param request the request of the attribute to resolve.
117     * @return the session attribute name, fully qualified the request remote server.
118     */
119    private String attributeName(Request request) {
120        return getClass().getName() + ':' + request.getUri().getScheme() + ':'
121                + request.getUri().getHost() + ':' + request.getUri().getPort() + ':' + "userpass";
122    }
123
124    @Override
125    public Promise<Response, NeverThrowsException> filter(final Context context,
126                                                          final Request request,
127                                                          final Handler next) {
128
129        Session session = context.asContext(SessionContext.class).getSession();
130
131        // Remove existing headers from incoming message
132        for (String header : SUPPRESS_REQUEST_HEADERS) {
133            request.getHeaders().remove(header);
134        }
135
136        String userpass = null;
137
138        // loop to retry for initially retrieved (or refreshed) credentials
139        try {
140            for (int n = 0; n < 2; n++) {
141                // put a branch of the trunk in the entity to allow retries
142                request.getEntity().push();
143                Response response;
144                try {
145                    // because credentials are sent in every request, this class caches them in the session
146                    if (cacheHeader) {
147                        userpass = (String) session.get(attributeName(request));
148                    }
149                    if (userpass != null) {
150                        request.getHeaders().add("Authorization", "Basic " + userpass);
151                    }
152                    response = blockingCall(next, context, request);
153                } finally {
154                    request.getEntity().pop();
155                }
156                // successful response from this filter's standpoint
157                if (!Status.UNAUTHORIZED.equals(response.getStatus())) {
158                    // Remove headers from outgoing message
159                    for (String header : SUPPRESS_RESPONSE_HEADERS) {
160                        response.getHeaders().remove(header);
161                    }
162                    return newResultPromise(response);
163                }
164                // close the incoming response because it's about to be dereferenced
165                closeSilently(response);
166
167                // credentials might be stale, so fetch them
168                Bindings bindings = bindings(context, request);
169                String user = username.eval(bindings);
170                String pass = password.eval(bindings);
171                // no credentials is equivalent to invalid credentials
172                if (user == null || pass == null) {
173                    break;
174                }
175                // ensure conformance with specification
176                if (user.indexOf(':') >= 0) {
177                    return newResultPromise(
178                            newInternalServerError("username must not contain a colon ':' character"));
179                }
180                if (cacheHeader) {
181                    // set in session for fetch in next iteration of this loop
182                    session.put(attributeName(request),
183                                         Base64.encode((user + ":" + pass).getBytes(Charset.defaultCharset())));
184                } else {
185                    userpass = Base64.encode((user + ":" + pass).getBytes(Charset.defaultCharset()));
186                }
187            }
188        } catch (Exception e) {
189            Response response = new Response().setStatus(Status.FORBIDDEN)
190                                              .setEntity("Can't authenticate user with Basic Http Authorization");
191            return newResultPromise(response);
192        }
193
194
195        // credentials were missing or invalid; let failure handler deal with it
196        return failureHandler.handle(context, request);
197    }
198
199    /** Creates and initializes an HTTP basic authentication filter in a heap environment. */
200    public static class Heaplet extends GenericHeaplet {
201        @Override
202        public Object create() throws HeapException {
203            Handler failureHandler =
204                    heap.resolve(config.get("failureHandler"), Handler.class);
205
206            Expression<String> usernameExpr = asExpression(config.get("username").required(), String.class);
207            Expression<String> passwordExpr = asExpression(config.get("password").required(), String.class);
208            HttpBasicAuthFilter filter = new HttpBasicAuthFilter(usernameExpr, passwordExpr, failureHandler);
209
210            filter.cacheHeader = config.get("cacheHeader").defaultTo(filter.cacheHeader).asBoolean();
211
212            logger.debug("HttpBasicAuthFilter: cacheHeader set to " + filter.cacheHeader);
213
214            return filter;
215        }
216    }
217}