/* * Copyright (C) 2011 Citrix Systems, Inc. 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.cloud.bridge.util; import java.security.InvalidKeyException; import java.security.SignatureException; import java.util.*; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; 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. * * @author Kelven Yang, John Zucker, Salvatore Orlando */ 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(); } }