| // Licensed to the Apache Software Foundation (ASF) under one | |
| // or more contributor license agreements. See the NOTICE file | |
| // distributed with this work for additional information | |
| // regarding copyright ownership. The ASF licenses this file | |
| // to you 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.cloud.bridge.util; | |
| import java.io.UnsupportedEncodingException; | |
| import java.net.URLDecoder; | |
| import java.security.InvalidKeyException; | |
| import java.security.SignatureException; | |
| import java.util.Collection; | |
| import java.util.HashSet; | |
| import java.util.Iterator; | |
| import java.util.Set; | |
| import java.util.TreeMap; | |
| import java.util.TreeSet; | |
| import javax.crypto.Mac; | |
| import javax.crypto.spec.SecretKeySpec; | |
| import org.apache.commons.codec.binary.Base64; | |
| import org.apache.log4j.Logger; | |
| /** | |
| * This class expects that the caller pulls the required headers from the standard | |
| * HTTPServeletRequest structure. This class is responsible for providing the | |
| * RFC2104 calculation to ensure that the signature is valid for the signing string. | |
| * The signing string is a representation of the request. | |
| * Notes are given below on what values are expected. | |
| * This class is used for the Authentication check for REST requests and Query String | |
| * Authentication requests. | |
| * | |
| */ | |
| public class RestAuth { | |
| protected final static Logger logger = Logger.getLogger(RestAuth.class); | |
| // TreeMap: used when constructing the CanonicalizedAmzHeaders Element of the StringToSign | |
| protected TreeMap<String, String> AmazonHeaders = null; // not always present | |
| protected String bucketName = null; // not always present | |
| protected String queryString = null; // for CanonicalizedResource - only interested in a string starting with particular values | |
| protected String uriPath = null; // only interested in the resource path | |
| protected String date = null; // only if x-amz-date is not set | |
| protected String contentType = null; // not always present | |
| protected String contentMD5 = null; // not always present | |
| protected boolean amzDateSet = false; | |
| protected boolean useSubDomain = false; | |
| protected Set<String> allowedQueryParams; | |
| public RestAuth() { | |
| // these must be lexicographically sorted | |
| AmazonHeaders = new TreeMap<String, String>(); | |
| allowedQueryParams = new HashSet<String>() { | |
| { | |
| add("acl"); | |
| add("lifecycle"); | |
| add("location"); | |
| add("logging"); | |
| add("notification"); | |
| add("partNumber"); | |
| add("policy"); | |
| add("requestPayment"); | |
| add("torrent"); | |
| add("uploadId"); | |
| add("uploads"); | |
| add("versionId"); | |
| add("versioning"); | |
| add("versions"); | |
| add("website"); | |
| add("delete"); | |
| } | |
| }; | |
| } | |
| public RestAuth(boolean useSubDomain) { | |
| //invoke the other constructor | |
| this(); | |
| this.useSubDomain = useSubDomain; | |
| } | |
| public void setUseSubDomain(boolean value) { | |
| useSubDomain = value; | |
| } | |
| public boolean getUseSubDomain() { | |
| return useSubDomain; | |
| } | |
| /** | |
| * This header is used iff the "x-amz-date:" header is not defined. | |
| * Value is used in constructing the StringToSign for signature verification. | |
| * | |
| * @param date - the contents of the "Date:" header, skipping the 'Date:' preamble. | |
| * OR pass in the value of the "Expires=" query string parameter passed in | |
| * for "Query String Authentication". | |
| */ | |
| public void setDateHeader(String date) { | |
| if (this.amzDateSet) | |
| return; | |
| if (null != date) | |
| date = date.trim(); | |
| this.date = date; | |
| } | |
| /** | |
| * Value is used in constructing the StringToSign for signature verification. | |
| * | |
| * @param type - the contents of the "Content-Type:" header, skipping the 'Content-Type:' preamble. | |
| */ | |
| public void setContentTypeHeader(String type) { | |
| if (null != type) | |
| type = type.trim(); | |
| this.contentType = type; | |
| } | |
| /** | |
| * Value is used in constructing the StringToSign for signature verification. | |
| * @param type - the contents of the "Content-MD5:" header, skipping the 'Content-MD5:' preamble. | |
| */ | |
| public void setContentMD5Header(String md5) { | |
| if (null != md5) | |
| md5 = md5.trim(); | |
| this.contentMD5 = md5; | |
| } | |
| /** | |
| * The bucket name can be in the "Host:" header but it does not have to be. It can | |
| * instead be in the uriPath as the first step in the path. | |
| * | |
| * Used as part of the CanonalizedResource element of the StringToSign. | |
| * If we get "Host: static.johnsmith.net:8080", then the bucket name is "static.johnsmith.net" | |
| * | |
| * @param header - contents of the "Host:" header, skipping the 'Host:' preamble. | |
| */ | |
| public void setHostHeader(String header) { | |
| if (null == header) { | |
| this.bucketName = null; | |
| return; | |
| } | |
| // -> is there a port on the name? | |
| header = header.trim(); | |
| int offset = header.indexOf(":"); | |
| if (-1 != offset) | |
| header = header.substring(0, offset); | |
| this.bucketName = header; | |
| } | |
| /** | |
| * Used as part of the CanonalizedResource element of the StringToSign. | |
| * CanonicalizedResource = [ "/" + Bucket ] + | |
| * <HTTP-Request-URI, from the protocol name up to the query string> + [sub-resource] | |
| * The list of sub-resources that must be included when constructing the CanonicalizedResource Element are: acl, lifecycle, location, | |
| * logging, notification, partNumber, policy, requestPayment, torrent, uploadId, uploads, versionId, versioning, versions and website. | |
| * (http://docs.amazonwebservices.com/AmazonS3/latest/dev/RESTAuthentication.html) | |
| * @param query - results from calling "HttpServletRequest req.getQueryString()" | |
| */ | |
| public void setQueryString(String query) { | |
| if (null == query) { | |
| this.queryString = null; | |
| return; | |
| } | |
| // Sub-resources (i.e.: query params) must be lex sorted | |
| Set<String> subResources = new TreeSet<String>(); | |
| String[] queryParams = query.split("&"); | |
| StringBuffer builtQuery = new StringBuffer(); | |
| for (String queryParam : queryParams) { | |
| // lookup parameter name | |
| String paramName = queryParam.split("=")[0]; | |
| if (allowedQueryParams.contains(paramName)) { | |
| subResources.add(queryParam); | |
| } | |
| } | |
| for (String subResource : subResources) { | |
| builtQuery.append(subResource + "&"); | |
| } | |
| // If anything inside the string buffer, add a "?" at the beginning, | |
| // and then remove the last '&' | |
| if (builtQuery.length() > 0) { | |
| builtQuery.insert(0, "?"); | |
| builtQuery.deleteCharAt(builtQuery.length() - 1); | |
| } | |
| this.queryString = builtQuery.toString(); | |
| } | |
| /** | |
| * Used as part of the CanonalizedResource element of the StringToSign. | |
| * Append the path part of the un-decoded HTTP Request-URI, up-to but not including the query string. | |
| * | |
| * @param path - - results from calling "HttpServletRequest req.getPathInfo()" | |
| */ | |
| public void addUriPath(String path) { | |
| if (null != path) | |
| path = path.trim(); | |
| this.uriPath = path; | |
| } | |
| /** | |
| * Pass in each complete Amazon header found in the HTTP request one at a time. | |
| * Each Amazon header added will become part of the signature calculation. | |
| * We are using a TreeMap here because of the S3 definition: | |
| * "Sort the collection of headers lexicographically by header name." | |
| * | |
| * @param headerAndValue - needs to be the complete amazon header (i.e., starts with "x-amz"). | |
| */ | |
| public void addAmazonHeader(String headerAndValue) { | |
| if (null == headerAndValue) | |
| return; | |
| String canonicalized = null; | |
| // [A] First Canonicalize the header and its value | |
| // -> we use the header 'name' as the key since we have to sort on that | |
| int offset = headerAndValue.indexOf(":"); | |
| String header = headerAndValue.substring(0, offset + 1).toLowerCase(); | |
| String value = headerAndValue.substring(offset + 1).trim(); | |
| // -> RFC 2616, Section 4.2: unfold the header's value by replacing linear white space with a single space character | |
| // -> does the HTTPServeletReq already do this for us? | |
| value = value.replaceAll(" ", " "); // -> multiple spaces to one space | |
| value = value.replaceAll("(\r\n|\t|\n)", " "); // -> CRLF, tab, and LF to one space | |
| // [B] Does this header already exist? | |
| if (AmazonHeaders.containsKey(header)) { | |
| // -> combine header fields with the same name into one "header-name:comma-separated-value-list" pair as prescribed by RFC 2616, section 4.2, without any white-space between values. | |
| canonicalized = AmazonHeaders.get(header); | |
| canonicalized = new String(canonicalized + "," + value + "\n"); | |
| canonicalized = canonicalized.replaceAll("\n,", ","); // remove the '\n' from the first stored value | |
| } else | |
| canonicalized = new String(header + value + "\n"); // -> as per spec, no space between header and its value | |
| AmazonHeaders.put(header, canonicalized); | |
| // [C] "x-amz-date:" takes precedence over the "Date:" header | |
| if (header.equals("x-amz-date:")) { | |
| this.amzDateSet = true; | |
| if (null != this.date) | |
| this.date = null; | |
| } | |
| } | |
| /** | |
| * The request is authenticated if we can regenerate the same signature given | |
| * on the request. Before calling this function make sure to set the header values | |
| * defined by the public values above. | |
| * | |
| * @param httpVerb - the type of HTTP request (e.g., GET, PUT) | |
| * @param secretKey - value obtained from the AWSAccessKeyId | |
| * @param signature - the signature we are trying to recreate, note can be URL-encoded | |
| * | |
| * @throws SignatureException | |
| * | |
| * @return true if request has been authenticated, false otherwise | |
| * @throws UnsupportedEncodingException | |
| */ | |
| public boolean verifySignature(String httpVerb, String secretKey, String signature) throws SignatureException, UnsupportedEncodingException { | |
| if (null == httpVerb || null == secretKey || null == signature) | |
| return false; | |
| httpVerb = httpVerb.trim(); | |
| secretKey = secretKey.trim(); | |
| signature = signature.trim(); | |
| // First calculate the StringToSign after the caller has initialized all the header values | |
| String StringToSign = genStringToSign(httpVerb); | |
| String calSig = calculateRFC2104HMAC(StringToSign, secretKey); | |
| // Was the passed in signature URL encoded? (it must be base64 encoded) | |
| int offset = signature.indexOf("%"); | |
| if (-1 != offset) | |
| signature = URLDecoder.decode(signature, "UTF-8"); | |
| boolean match = signature.equals(calSig); | |
| if (!match) | |
| logger.error("Signature mismatch, [" + signature + "] [" + calSig + "] over [" + StringToSign + "]"); | |
| return match; | |
| } | |
| /** | |
| * This function generates the single string that will be used to sign with a users | |
| * secret key. | |
| * | |
| * StringToSign = HTTP-Verb + "\n" + | |
| * Content-MD5 + "\n" + | |
| * Content-Type + "\n" + | |
| * Date + "\n" + | |
| * CanonicalizedAmzHeaders + | |
| * CanonicalizedResource; | |
| * | |
| * @return The single StringToSign or null. | |
| */ | |
| private String genStringToSign(String httpVerb) { | |
| StringBuffer canonicalized = new StringBuffer(); | |
| String temp = null; | |
| String canonicalizedResourceElement = genCanonicalizedResourceElement(); | |
| canonicalized.append(httpVerb).append("\n"); | |
| if ((null != this.contentMD5)) | |
| canonicalized.append(this.contentMD5); | |
| canonicalized.append("\n"); | |
| if ((null != this.contentType)) | |
| canonicalized.append(this.contentType); | |
| canonicalized.append("\n"); | |
| if (null != this.date) | |
| canonicalized.append(this.date); | |
| canonicalized.append("\n"); | |
| if (null != (temp = genCanonicalizedAmzHeadersElement())) | |
| canonicalized.append(temp); | |
| if (null != canonicalizedResourceElement) | |
| canonicalized.append(canonicalizedResourceElement); | |
| if (0 == canonicalized.length()) | |
| return null; | |
| return canonicalized.toString(); | |
| } | |
| /** | |
| * CanonicalizedResource represents the Amazon S3 resource targeted by the request. | |
| * CanonicalizedResource = [ "/" + Bucket ] + | |
| * <HTTP-Request-URI, from the protocol name up to the query string> + | |
| * [ sub-resource, if present. For example "?acl", "?location", "?logging", or "?torrent"]; | |
| * | |
| * @return A single string representing CanonicalizedResource or null. | |
| */ | |
| private String genCanonicalizedResourceElement() { | |
| StringBuffer canonicalized = new StringBuffer(); | |
| if (this.useSubDomain && this.bucketName != null) | |
| canonicalized.append("/").append(this.bucketName); | |
| if (null != this.uriPath) | |
| canonicalized.append(this.uriPath); | |
| if (null != this.queryString) | |
| canonicalized.append(this.queryString); | |
| if (0 == canonicalized.length()) | |
| return null; | |
| return canonicalized.toString(); | |
| } | |
| /** | |
| * Construct the Canonicalized Amazon headers element of the StringToSign by | |
| * concatenating all headers in the TreeMap into a single string. | |
| * | |
| * @return A single string with all the Amazon headers glued together, or null | |
| * if no Amazon headers appeared in the request. | |
| */ | |
| private String genCanonicalizedAmzHeadersElement() { | |
| Collection<String> headers = AmazonHeaders.values(); | |
| Iterator<String> itr = headers.iterator(); | |
| StringBuffer canonicalized = new StringBuffer(); | |
| while (itr.hasNext()) | |
| canonicalized.append(itr.next()); | |
| if (0 == canonicalized.length()) | |
| return null; | |
| return canonicalized.toString(); | |
| } | |
| /** | |
| * Create a signature by the following method: | |
| * new String( Base64( SHA1( key, byte array ))) | |
| * | |
| * @param signIt - the data to generate a keyed HMAC over | |
| * @param secretKey - the user's unique key for the HMAC operation | |
| * @return String - the recalculated string | |
| * @throws SignatureException | |
| */ | |
| private String calculateRFC2104HMAC(String signIt, String secretKey) throws SignatureException { | |
| String result = null; | |
| try { | |
| SecretKeySpec key = new SecretKeySpec(secretKey.getBytes(), "HmacSHA1"); | |
| Mac hmacSha1 = Mac.getInstance("HmacSHA1"); | |
| hmacSha1.init(key); | |
| byte[] rawHmac = hmacSha1.doFinal(signIt.getBytes()); | |
| result = new String(Base64.encodeBase64(rawHmac)); | |
| } catch (InvalidKeyException e) { | |
| throw new SignatureException("Failed to generate keyed HMAC on REST request because key " + secretKey + " is invalid" + e.getMessage()); | |
| } catch (Exception e) { | |
| throw new SignatureException("Failed to generate keyed HMAC on REST request: " + e.getMessage()); | |
| } | |
| return result.trim(); | |
| } | |
| } |