Java tutorial
/* * Copyright 2015, 2017 IBM Corp. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file * except in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, * either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ package com.cloudant.http.internal.interceptors; import com.cloudant.http.Http; import com.cloudant.http.HttpConnection; import com.cloudant.http.HttpConnectionInterceptorContext; import com.cloudant.http.HttpConnectionRequestInterceptor; import com.cloudant.http.HttpConnectionResponseInterceptor; import org.apache.commons.io.IOUtils; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.CookieManager; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; import java.net.URLEncoder; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; /** * Adds cookie authentication support to http requests. * * It does this by adding the cookie header for CouchDB * using request interceptor pipeline in {@link HttpConnection}. * * If a response has a response code of 401, it will fetch a cookie from * the server using provided credentials and tell {@link HttpConnection} to reply * the request by setting {@link HttpConnectionInterceptorContext#replayRequest} property to true. * * If the request to get the cookie for use in future request fails with a 401 status code * (or any status that indicates client error) cookie authentication will not be attempted again. */ public class CookieInterceptor implements HttpConnectionRequestInterceptor, HttpConnectionResponseInterceptor { private final static Logger logger = Logger.getLogger(CookieInterceptor.class.getCanonicalName()); private final byte[] sessionRequestBody; private final CookieManager cookieManager = new CookieManager(); private final AtomicBoolean shouldAttemptCookieRequest = new AtomicBoolean(true); private final URL sessionURL; /** * Constructs a cookie interceptor. Credentials should be supplied not URL encoded, this class * will perform the necessary URL encoding. * * @param username The username to use when getting the cookie (not URL encoded) * @param password The password to use when getting the cookie (not URL encoded) * @param baseURL The base URL to use when constructing an `_session` request. */ public CookieInterceptor(String username, String password, String baseURL) { try { this.sessionURL = new URL(String.format("%s/_session", baseURL)); username = URLEncoder.encode(username, "UTF-8"); password = URLEncoder.encode(password, "UTF-8"); this.sessionRequestBody = String.format("name=%s&password=%s", username, password).getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { //all JVMs should support UTF-8, so this should not happen throw new RuntimeException(e); } catch (MalformedURLException e) { // this should be a valid URL since the builder is passing it in logger.log(Level.SEVERE, "Failed to create URL for _session endpoint", e); throw new RuntimeException(e); } } @Override public HttpConnectionInterceptorContext interceptRequest(HttpConnectionInterceptorContext context) { HttpURLConnection connection = context.connection.getConnection(); // First time we will have no cookies if (cookieManager.getCookieStore().getCookies().isEmpty() && shouldAttemptCookieRequest.get()) { if (!requestCookie(context)) { // Requesting a cookie failed, set a flag if we failed so we won't try again shouldAttemptCookieRequest.set(false); } } if (shouldAttemptCookieRequest.get()) { // Debug logging if (logger.isLoggable(Level.FINEST)) { logger.finest("Attempt to add cookie to request."); logger.finest("Cookies are stored for URIs: " + cookieManager.getCookieStore().getURIs()); } // Apply any saved cookies to the request try { Map<String, List<String>> requestCookieHeaders = cookieManager.get(connection.getURL().toURI(), connection.getRequestProperties()); for (Map.Entry<String, List<String>> requestCookieHeader : requestCookieHeaders.entrySet()) { List<String> cookies = requestCookieHeader.getValue(); if (cookies != null && !cookies.isEmpty()) { connection.setRequestProperty(requestCookieHeader.getKey(), listToSemicolonSeparatedString(cookies)); } else { logger.finest("No cookie values to set."); } } } catch (IOException e) { logger.log(Level.SEVERE, "Failed to read request properties", e); } catch (URISyntaxException e) { logger.log(Level.SEVERE, "Failed to convert request URL to URI for cookie " + "retrieval."); } } return context; } @Override public HttpConnectionInterceptorContext interceptResponse(HttpConnectionInterceptorContext context) { // Check if this interceptor is valid before attempting any kind of renewal if (shouldAttemptCookieRequest.get()) { HttpURLConnection connection = context.connection.getConnection(); // If we got a 401 or 403 we might need to renew the cookie try { boolean renewCookie = false; int statusCode = connection.getResponseCode(); if (statusCode == HttpURLConnection.HTTP_FORBIDDEN || statusCode == HttpURLConnection.HTTP_UNAUTHORIZED) { InputStream errorStream = connection.getErrorStream(); String errorString = null; if (errorStream != null) { try { // Get the string value of the error stream errorString = IOUtils.toString(errorStream, "UTF-8"); } finally { IOUtils.closeQuietly(errorStream); } } logger.log(Level.FINE, String.format(Locale.ENGLISH, "Intercepted " + "response %d %s", statusCode, errorString)); switch (statusCode) { case HttpURLConnection.HTTP_FORBIDDEN: //403 // Check if it was an expiry case // Check using a regex to avoid dependency on a JSON library. // Note (?siu) flags used for . to also match line breaks and for // unicode // case insensitivity. if (errorString != null && errorString .matches("(?siu)" + ".*\\\"error\\\"\\s*:\\s*\\\"credentials_expired\\\".*")) { // Was expired - set boolean to renew cookie renewCookie = true; } else { // Wasn't a credentials expired, throw exception HttpConnectionInterceptorException toThrow = new HttpConnectionInterceptorException( errorString); // Set the flag for deserialization toThrow.deserialize = errorString != null; throw toThrow; } break; case HttpURLConnection.HTTP_UNAUTHORIZED: //401 // We need to get a new cookie renewCookie = true; break; default: break; } if (renewCookie) { logger.finest("Cookie was invalid attempt to get new cookie."); boolean success = requestCookie(context); if (success) { // New cookie obtained, replay the request context.replayRequest = true; } else { // Didn't successfully renew, maybe creds are invalid context.replayRequest = false; // Don't replay shouldAttemptCookieRequest.set(false); // Set the flag to stop trying } } } else { // Store any cookies provided on the response storeCookiesFromResponse(connection); } } catch (IOException e) { logger.log(Level.SEVERE, "Error reading response code or body from request", e); } } return context; } private boolean requestCookie(HttpConnectionInterceptorContext context) { try { HttpConnection conn = Http.POST(sessionURL, "application/x-www-form-urlencoded"); conn.setRequestBody(sessionRequestBody); //when we request the session we need all interceptors except this one conn.requestInterceptors.addAll(context.connection.requestInterceptors); conn.requestInterceptors.remove(this); conn.responseInterceptors.addAll(context.connection.responseInterceptors); conn.responseInterceptors.remove(this); HttpURLConnection connection = conn.execute().getConnection(); int responseCode = connection.getResponseCode(); if (responseCode / 100 == 2) { if (sessionHasStarted(connection.getInputStream())) { return storeCookiesFromResponse(connection); } else { return false; } } else { InputStream errorStream = connection.getErrorStream(); try { if (errorStream != null) { // Consume the error stream to avoid leaking connections String error = IOUtils.toString(errorStream, "UTF-8"); // Log the error stream content logger.fine(error); } } finally { IOUtils.closeQuietly(errorStream); } if (responseCode == 401) { logger.severe("Credentials are incorrect, cookie authentication will not be" + " attempted again by this interceptor object"); } else { // catch any other response code logger.log(Level.SEVERE, "Failed to get cookie from server, response code {0}, " + "cookie authentication will not be attempted again", responseCode); } } } catch (IOException e) { logger.log(Level.SEVERE, "Failed to read cookie response", e); } return false; } private boolean sessionHasStarted(InputStream responseStream) throws IOException { try { // Get the response body as a string String response = IOUtils.toString(responseStream, "UTF-8"); // Only check for ok:true, https://issues.apache.org/jira/browse/COUCHDB-1356 // means we cannot check that the name returned is the one we sent. // Check the response body for "ok" : true using a regex because we don't want a JSON // library dependency for something so simple in a shared HTTP artifact used in both // java-cloudant and sync-android. Note (?siu) flags used for . to also match line // breaks and for unicode case insensitivity. return response.matches("(?s)(?i)(?u).*\\\"ok\\\"\\s*:\\s*true.*"); } finally { IOUtils.closeQuietly(responseStream); } } private boolean storeCookiesFromResponse(HttpURLConnection connection) { // Store any cookies from the response in the CookieManager try { logger.finest("Storing cookie."); cookieManager.put(connection.getURL().toURI(), connection.getHeaderFields()); return true; } catch (IOException e) { logger.log(Level.SEVERE, "Failed to read cookie response header", e); return false; } catch (URISyntaxException e) { logger.log(Level.SEVERE, "Failed to convert request URL to URI for cookie storage."); return false; } } private String listToSemicolonSeparatedString(List<String> cookieStrings) { // RFC 6265 says multiple cookie pairs should be "; " separated StringBuilder builder = new StringBuilder(); int index = 0; // Count from 0 since we will increment before comparing to size for (String cookieString : cookieStrings) { builder.append(cookieString); if (++index != cookieStrings.size()) { builder.append("; "); } } return builder.toString(); } }