// Copyright 2013 Michel Kraemer // // 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 de.undercouch.citeproc.helper.oauth; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; import java.net.URL; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.xml.bind.DatatypeConverter; import de.undercouch.citeproc.helper.CSLUtils; /** * Performs OAuth authentication. Very minimal implementation, only used * for Zotero authentication currently. * @author Michel Kraemer */ public class OAuth1 implements OAuth { /** * Service response item specifying a token's value */ protected static final String OAUTH_TOKEN = "oauth_token"; /** * Service response item specifying a token's secret */ protected static final String OAUTH_TOKEN_SECRET = "oauth_token_secret"; private static final String OAUTH_CALLBACK = "oauth_callback"; private static final String OAUTH_CONSUMER_KEY = "oauth_consumer_key"; private static final String OAUTH_NONCE = "oauth_nonce"; private static final String OAUTH_SIGNATURE = "oauth_signature"; private static final String OAUTH_SIGNATURE_METHOD = "oauth_signature_method"; private static final String OAUTH_TIMESTAMP = "oauth_timestamp"; private static final String OAUTH_VERIFIER = "oauth_verifier"; private static final String OAUTH_VERSION = "oauth_version"; private static final String UTF8 = "UTF-8"; private static final String OAUTH_IMPL_VERSION = "1.0"; private static final String HMAC_SHA1_METHOD = "HMAC-SHA1"; private static final String HMAC_SHA1_ALGORITHM = "HmacSHA1"; private static final String HEADER_AUTHORIZATION = "Authorization"; private static final String HEADER_HOST = "Host"; /** * Out-of-band callback. Use this if you don't want to provide a callback * URL to the OAuth server */ private static final String CALLBACK_OOB = "oob"; private final String consumerKey; private final String consumerSecret; private final SecureRandom random = new SecureRandom(); /** * Creates a new OAuth client * @param consumerKey the consumer key * @param consumerSecret the consumer secret */ public OAuth1(String consumerKey, String consumerSecret) { this.consumerKey = consumerKey; this.consumerSecret = consumerSecret; } @Override public Token requestTemporaryCredentials(URL url, Method method) throws IOException { Map<String, String> aap = new HashMap<>(); aap.put(OAUTH_CALLBACK, CALLBACK_OOB); return requestCredentials(url, method, null, aap); } @Override public Token requestTokenCredentials(URL url, Method method, Token temporaryCredentials, String verifier) throws IOException { Map<String, String> aap = new HashMap<>(); aap.put(OAUTH_VERIFIER, verifier); return requestCredentials(url, method, temporaryCredentials, aap); } @Override public Response request(URL url, Method method, Token token) throws IOException { return request(url, method, token, null); } @Override public Response request(URL url, Method method, Token token, Map<String, String> additionalHeaders) throws IOException { return requestInternal(url, method, token, null, additionalHeaders); } /** * Sends a request to the server and returns a token * @param url the URL to send the request to * @param method the HTTP request method * @param token a token used for authorization (may be null if the * app is not authorized yet) * @param additionalAuthParams additional parameters that should be * added to the <code>Authorization</code> header * @return the token * @throws IOException if the request was not successful * @throws RequestException if the server returned an error * @throws UnauthorizedException if the request is not authorized */ private Token requestCredentials(URL url, Method method, Token token, Map<String, String> additionalAuthParams) throws IOException { Response r = requestInternal(url, method, token, additionalAuthParams, null); InputStream is = r.getInputStream(); String response = CSLUtils.readStreamToString(is, UTF8); //create token for temporary credentials Map<String, String> sr = splitResponse(response); return responseToToken(sr); } /** * Parses a service response and creates a token * @param response the response * @return the token */ protected Token responseToToken(Map<String, String> response) { return new Token(response.get(OAUTH_TOKEN), response.get(OAUTH_TOKEN_SECRET)); } /** * Sends a request to the server and returns an input stream from which * the response can be read. The caller is responsible for consuming * the input stream's content and for closing the stream. * @param url the URL to send the request to * @param method the HTTP request method * @param token a token used for authorization (may be null if the * app is not authorized yet) * @param additionalAuthParams additional parameters that should be * added to the <code>Authorization</code> header (may be null) * @param additionalHeaders additional HTTP headers (may be null) * @return a response * @throws IOException if the request was not successful * @throws RequestException if the server returned an error * @throws UnauthorizedException if the request is not authorized */ private Response requestInternal(URL url, Method method, Token token, Map<String, String> additionalAuthParams, Map<String, String> additionalHeaders) throws IOException { //prepare HTTP connection HttpURLConnection conn = (HttpURLConnection)url.openConnection(); conn.setInstanceFollowRedirects(true); conn.setRequestMethod(method.toString()); conn.setRequestProperty(HEADER_HOST, makeBaseUri(url)); String timestamp = makeTimestamp(); String nonce = makeNonce(timestamp); //create OAuth parameters Map<String, String> authParams = new HashMap<>(); if (additionalAuthParams != null) { authParams.putAll(additionalAuthParams); } if (token != null) { authParams.put(OAUTH_TOKEN, token.getToken()); } authParams.put(OAUTH_CONSUMER_KEY, consumerKey); authParams.put(OAUTH_SIGNATURE_METHOD, HMAC_SHA1_METHOD); authParams.put(OAUTH_TIMESTAMP, timestamp); authParams.put(OAUTH_NONCE, nonce); authParams.put(OAUTH_VERSION, OAUTH_IMPL_VERSION); //create signature from method, url, and OAuth parameters String signature = makeSignature(method.toString(), url, authParams, token); //put OAuth parameters into "Authorization" header StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> e : authParams.entrySet()) { appendAuthParam(sb, e.getKey(), e.getValue()); } appendAuthParam(sb, OAUTH_SIGNATURE, signature); conn.setRequestProperty(HEADER_AUTHORIZATION, "OAuth " + sb.toString()); if (additionalHeaders != null) { for (Map.Entry<String, String> e : additionalHeaders.entrySet()) { conn.setRequestProperty(e.getKey(), e.getValue()); } } //perform request conn.connect(); //check response if (conn.getResponseCode() == 401) { throw new UnauthorizedException("Not authorized"); } else if (conn.getResponseCode() != 200) { throw new RequestException("HTTP request failed with error code: " + conn.getResponseCode()); } return new Response(conn); } /** * Appends an authorization parameter to the given string builder * @param sb the string builder * @param key the parameter's key * @param value the parameter's value */ private static void appendAuthParam(StringBuilder sb, String key, String value) { if (sb.length() > 0) { sb.append(","); } sb.append(key); sb.append("=\""); sb.append(value); sb.append("\""); } /** * Generates a timestamp for a request * @return the timestamp */ private static String makeTimestamp() { return String.valueOf(System.currentTimeMillis() / 1000); } /** * Generates a nonce for a request using a secure random number generator * @param timestamp the request's timestamp (generated by {@link #makeTimestamp()}) * @return the nonce */ private String makeNonce(String timestamp) { byte[] bytes = new byte[10]; random.nextBytes(bytes); StringBuilder sb = new StringBuilder(timestamp); sb.append("-"); for (int i = 0; i < bytes.length; ++i) { String b = Integer.toHexString(bytes[i] & 0xff); if (b.length() < 2) { sb.append("0"); } sb.append(b); } return sb.toString(); } /** * Generates a base URI from a given URL. The base URI consists of the * protocol, the host, and also the port if its not the default port * for the given protocol. * @param url the URL from which the URI should be generated * @return the base URI */ private static String makeBaseUri(URL url) { String r = url.getProtocol().toLowerCase() + "://" + url.getHost().toLowerCase(); if ((url.getProtocol().equalsIgnoreCase("http") && url.getPort() != -1 && url.getPort() != 80) || (url.getProtocol().equalsIgnoreCase("https") && url.getPort() != -1 && url.getPort() != 443)) { r += ":" + url.getPort(); } return r; } /** * Splits the query parameters of the given URL, encodes their * keys and values and puts each key-value pair in a list * @param url the URL * @return the encoded parameters */ private static List<String> splitAndEncodeParams(URL url) { if (url.getQuery() == null) { return new ArrayList<>(); } String[] params = url.getQuery().split("&"); List<String> result = new ArrayList<>(params.length); for (String p : params) { String[] kv = p.split("="); kv[0] = PercentEncoding.decode(kv[0]); kv[1] = PercentEncoding.decode(kv[1]); kv[0] = PercentEncoding.encode(kv[0]); kv[1] = PercentEncoding.encode(kv[1]); String np = kv[0] + "=" + kv[1]; result.add(np); } return result; } /** * Splits a <code>application/x-www-form-urlencoded</code> response * @param response the response * @return the key-value pairs */ private static Map<String, String> splitResponse(String response) { String[] params = response.split("&"); Map<String, String> result = new HashMap<>(params.length); for (String p : params) { String[] kv = p.split("="); result.put(PercentEncoding.decode(kv[0]), PercentEncoding.decode(kv[1])); } return result; } /** * Generates a OAuth signature from the HTTP method, the URL, and the * authorization parameters * @param method the HTTP method * @param url the URL * @param authParams the authorization parameters * @param token the authorization token used for this request (may be null) * @return the signature */ private String makeSignature(String method, URL url, Map<String, String> authParams, Token token) { //encode method and URL StringBuilder sb = new StringBuilder(method + "&" + PercentEncoding.encode(makeBaseUri(url) + url.getPath())); //encode parameters List<String> params = splitAndEncodeParams(url); for (Map.Entry<String, String> p : authParams.entrySet()) { params.add(PercentEncoding.encode(p.getKey()) + "=" + PercentEncoding.encode(p.getValue())); } //sort parameters and append them to the base string Collections.sort(params); StringBuilder pb = new StringBuilder(); for (String p : params) { if (pb.length() > 0) { pb.append("&"); } pb.append(p); } sb.append("&"); sb.append(PercentEncoding.encode(pb.toString())); //create base string and key for hash function String baseString = sb.toString(); String tokenSecret = token != null ? token.getSecret() : ""; String key = PercentEncoding.encode(consumerSecret) + "&" + PercentEncoding.encode(tokenSecret); //hash base string with the secret key try { SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(UTF8), HMAC_SHA1_ALGORITHM); Mac mac = Mac.getInstance(HMAC_SHA1_ALGORITHM); mac.init(secretKey); byte[] bytes = mac.doFinal(baseString.getBytes(UTF8)); return PercentEncoding.encode(DatatypeConverter.printBase64Binary(bytes)); } catch (UnsupportedEncodingException e) { //should never happen throw new RuntimeException(e); } catch (NoSuchAlgorithmException e) { //should never happen throw new RuntimeException(e); } catch (InvalidKeyException e) { //should never happen throw new RuntimeException(e); } } }