Last active
April 7, 2021 15:01
-
-
Save patrick-fischer/cec45adfdb83dd97ab806215b8a2467b to your computer and use it in GitHub Desktop.
AWS Authentication using Query Parameters (AWS Signature Version 4): https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
// Example implementation as follows: | |
public class AWSS3_GetService extends AWS { | |
public override void init() { | |
resource = '/[your bucket]/[your folder 1]/[your folder 2]/[file name].[extension]'; | |
region = 'eu-west-2'; | |
service = 's3'; | |
endpoint = new Url('https://' + service + '.' + region + '.amazonaws.com/'); | |
accessKey = 'SET HERE'; | |
method = HttpMethod.XGET; | |
createSigningKey('SET HERE'); | |
} | |
public String yourMethod() { | |
String url = getPreSignedUrl(); | |
return url; | |
} | |
} | |
*/ | |
public abstract class AWS { | |
// Post initialization logic (after constructor, before call) | |
protected abstract void init(); | |
// Things we need to know about the service. Set these values in init() | |
protected String host; | |
protected String region; | |
protected String service; | |
protected String resource; | |
protected String accessKey; | |
protected String payloadSha256; | |
protected String contentType; | |
protected Url endpoint; | |
protected HttpMethod method; | |
protected Blob payload; | |
protected Integer expires; | |
// Not used externally, so we hide these values | |
Blob signingKey; | |
String signature; | |
String canonicalRequest; | |
DateTime requestTime; | |
Map<String, String> queryParams; | |
Map<String, String> headerParams; | |
public enum HttpMethod {XGET, XPUT, XHEAD, XOPTIONS, XDELETE, XPOST} | |
// Call this constructor with super() in subclasses | |
protected AWS() { | |
requestTime = DateTime.now(); | |
queryParams = new Map<String, String>(); | |
headerParams = new Map<String, String>(); | |
expires = 10; | |
} | |
// Send a default request | |
protected HttpResponse sendRequest() { | |
return sendRequest(new Set<Integer> { 200 }); | |
} | |
// Get URL to download file | |
protected String getPreSignedUrl() { | |
return createQuerySignedRequest().getEndpoint(); | |
} | |
// Actually perform the request, and throw exception if response code is not valid | |
protected HttpResponse sendRequest(Set<Integer> validCodes) { | |
HttpRequest request = createQuerySignedRequest(); | |
HttpResponse response = new Http().send(request); | |
if(!validCodes.contains(response.getStatusCode())) { | |
throw new ServiceException(response.getBodyDocument().getRootElement()); | |
} | |
return response; | |
} | |
HttpRequest createQuerySignedRequest() { | |
String stringToSign; | |
String finalEndpoint; | |
String queryString; | |
String canonicalHeaders; | |
HttpRequest request = new HttpRequest(); | |
init(); | |
request.setMethod(method.name().removeStart('X')); | |
if(payload == null || payload == Blob.valueOf('UNSIGNED-PAYLOAD')) { | |
payloadSha256 = 'UNSIGNED-PAYLOAD'; | |
} else { | |
payloadSha256 = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', payload)); | |
request.setBodyAsBlob(payload); | |
} | |
if(host == null) { | |
host = service + '.' + region + '.amazonaws.com'; | |
} | |
if(String.isNotBlank(contentType)) { | |
request.setHeader('Content-type', contentType); | |
setHeader('Content-type', contentType); | |
} | |
setHeader('Host', host); | |
//Set AUTHPARAMS in the query | |
setQueryParam('X-Amz-Algorithm','AWS4-HMAC-SHA256'); | |
setQueryParam('X-Amz-Credential', String.join(new List<String> { | |
accessKey, | |
requestTime.formatGMT('yyyyMMdd'), | |
region, | |
service, | |
'aws4_request' | |
},'/')); | |
setQueryParam('X-Amz-Date',requestTime.formatGMT('yyyyMMdd\'T\'HHmmss\'Z\'')); | |
setQueryParam('X-Amz-Expires', String.valueOf(expires)); | |
setQueryParam('X-Amz-SignedHeaders', + String.join(new List<String>(headerParams.keySet()),';').toLowerCase()); | |
//Create the signature | |
queryString = createCanonicalQueryString(); | |
canonicalHeaders = createCanonicalHeaders(); | |
canonicalRequest = createCanonicalRequest(queryString, canonicalHeaders); | |
stringToSign = createStringToSign(canonicalRequest); | |
createSignature(stringToSign); | |
//Add the signature at the end | |
queryString += '&X-Amz-Signature=' + signature; | |
finalEndpoint = new Url(endpoint, resource).toExternalForm() + '?'+ queryString; | |
System.debug('finalEndpoint: ' + finalEndpoint); | |
request.setEndpoint(finalEndpoint); | |
return request; | |
} | |
protected void createSigningKey(String secretKey) { | |
Blob dateKey = signString(Blob.valueOf(requestTime.formatGMT('yyyyMMdd')),Blob.valueOf('AWS4'+secretKey)); | |
Blob dateRegionKey = signString(Blob.valueOf(region),dateKey); | |
Blob dateRegionServiceKey = signString(Blob.valueOf(service),dateRegionKey); | |
signingKey = signString(Blob.valueOf('aws4_request'),dateRegionServiceKey); | |
} | |
// Create a canonical query string (used during signing) | |
String createCanonicalQueryString() { | |
List<String> results = new List<String>(); | |
List<String> keys = new List<String>(queryParams.keySet()); | |
keys.sort(); | |
for(String key: keys) { | |
results.add(key + '=' + queryParams.get(key)); | |
} | |
return String.join(results, '&'); | |
} | |
// Create the canonical headers (used for signing) | |
String createCanonicalHeaders() { | |
List<String> results = new List<String>(); | |
List<String> keys = new List<String>(headerParams.keySet()); | |
keys.sort(); | |
for(String key: keys) { | |
results.add(key + ':' + headerParams.get(key)); | |
} | |
return String.join(results, '\n')+'\n'; | |
} | |
// Create the entire canonical request | |
String createCanonicalRequest(String queryString, String canonicalHeaders) { | |
String result = String.join( | |
new List<String> { | |
method.name().removeStart('X'), // METHOD | |
new Url(endPoint, resource).getPath(), // RESOURCE | |
queryString, // CANONICAL QUERY STRING | |
canonicalHeaders, // CANONICAL HEADERS | |
String.join(new List<String>(headerParams.keySet()), ';'), // SIGNED HEADERS | |
payloadSha256 // SHA256 PAYLOAD | |
}, | |
'\n'); | |
return result; | |
} | |
// Create the entire string to sign | |
String createStringToSign(String canonicalRequest) { | |
String result = String.join( | |
new List<String> { | |
'AWS4-HMAC-SHA256', | |
requestTime.formatGMT('yyyyMMdd\'T\'HHmmss\'Z\''), | |
String.join(new List<String>{requestTime.formatGMT('yyyyMMdd'), region, service, 'aws4_request' },'/'), | |
EncodingUtil.convertToHex(Crypto.generateDigest('sha256', Blob.valueof(canonicalRequest))) | |
}, | |
'\n' | |
); | |
return result; | |
} | |
private void createSignature(String stringToSign){ | |
signature = EncodingUtil.convertToHex(signString(blob.valueof(stringToSign),signingKey)); | |
} | |
// We have to replace ~ and " " correctly, or we'll break AWS on those two characters | |
protected string uriEncode(String value) { | |
return value==null? null: EncodingUtil.urlEncode(value, 'utf-8').replaceAll('%7E','~').replaceAll('\\+','%20'); | |
} | |
protected void setHeader(String key, String value) { | |
headerParams.put(key.toLowerCase(), value); | |
} | |
protected void setQueryParam(String key, String value) { | |
queryParams.put(key.capitalize(), UriEncode(value)); | |
} | |
private Blob signString(Blob msg, Blob key) { | |
return Crypto.generateMac('HMACSHA256', msg, key); | |
} | |
private static String getChildNodeText(Dom.XmlNode node, String ns, String name) { | |
try { | |
return node.getChildElement(name, ns).getText(); | |
} catch(Exception e) { | |
return null; | |
} | |
} | |
// Turns an Amazon exception into something we can present to the user/catch | |
public class ServiceException extends Exception { | |
public String Code, Message, Resource, RequestId; | |
public ServiceException(Dom.XmlNode node) { | |
String ns = node.getNamespace(); | |
Code = getChildNodeText(node, ns, 'Code'); | |
Message = getChildNodeText(node, ns, 'Message'); | |
Resource = getChildNodeText(node, ns, 'Resource'); | |
RequestId = getChildNodeText(node, ns, 'RequestId'); | |
} | |
public String toString() { | |
return JSON.serialize(this); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I will implement this class and will let you know if fix it.
@patrik-fisher Great work! Thank you, man.