├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── THIRD-PARTY-LICENSES.txt ├── cdk.json ├── files ├── helloworld.html └── web │ └── reauth.html ├── images ├── Generate.jpg ├── HelloWorldPage.jpg ├── InvalidFile.jpg └── singleusesignedurl.jpg ├── lambda ├── CloudFrontViewRequest.js ├── CreateSignedURL.js └── generate.html ├── pom.xml └── src ├── main └── java │ └── com │ └── amazonaws │ └── singleusesignedurl │ ├── SingleUseSignedUrlApp.java │ └── SingleUseSignedUrlStack.java └── test └── java └── com └── amazonaws └── singleusesignedurl └── SingleUseSignedUrlTest.java /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath.txt 2 | target 3 | .classpath 4 | .project 5 | .idea 6 | .settings 7 | .vscode 8 | *.iml 9 | .DS_Store 10 | lambda/uuid.txt 11 | lambda/region.txt 12 | 13 | # CDK asset staging directory 14 | .cdk.staging 15 | cdk.out 16 | 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Single Use SignedURL 2 | AWS CDK to create a CloudFront distribution with a request Lambda to allow single use signed URL file downloads. Each file is tracked by an identifier which is stored in a DynamoDB database. 3 | Each request will check the identifier against values stored in the database. 4 | If the identifier is found the file process continues and the files is received, the id is then removed from the database. 5 | If the identifier is not found the system will perform a 302 redirect to a specified URL. 6 | 7 | ### Architecture 8 | Architecture 9 | 10 | ### Requirements 11 | * A CloudFront Key Pair 12 | * The CloudFront Key Pair private key PEM file 13 | * AWS CDK Toolkit 14 | * CloudFront Triggers for Lambda Functions must execute in US East (N. Virginia) Region see requirements doc 15 | 16 | ### Setup 17 | 1. Create a CloudFront Key Pair (**Root Account required**). 18 | You can configure your CloudFront key pair through the Security Credentials page in the IAM console. 19 | Make sure you download your private key, and make a note of the key pair ID listed in the AWS Management Console. 20 | 1. Next we will store the private key file (PEM) in Secrets Manager. 21 | * First store a new secret 22 | * Select "Other type of secrets" 23 | * Select "Plaintext" 24 | * Replace the entire contents of the edit box with the entire contents of the private key PEM file 25 | * Enter a secret name (SignedURLPem is used in this sample) 26 | * Save the secret 27 | 1. Edit the cdk.json file and update the following values: 28 | * UUID - A unique string value used in bucket creation and service linking. This value must be unique across all AWS customers. It is suggested to generate a UUID for this value. 29 | * keyPairId - The Id of the CloudFront Key Pair 30 | * secretName - The name of the secrets manager value that holds the PEM file used to sign URLs 31 | * region - The region your DynamoDB and parameter store are located in. Due to CloudFront Edge Lambda requirement to execute in us-east-1 this value is required to execute the calls to other services in another region. 32 | 1. From a terminal window at the root directory of this project do ```cdk synth``` 33 | 1. From a terminal window at the root directory of this project do ```cdk deploy``` 34 | 1. Once the deployment is complete the terminal window will display outputs of the deployment. One of the outputs will be ```CreateSignedURLEndpoint```, navigating to this endpoint will display a web page used to generate single use signed URLS. 35 | * Click the **Generate Single SignedURL** button on this page to generate a signed url with the given sample helloworld.html sample file.
Generate Web Page 36 | * Click the **Open URL** button to display the file
Hello World Web Page 37 | * Once the file is displayed try refreshing to the page and notice **Invalid File** is now displayed.
Invalid Web Page 38 | 39 | ### Resource Cleanup 40 | 1. From a terminal window at the root directory of this project do ```cdk destroy``` 41 | * The ```cdk destroy``` command will sometimes fail due to the ```CloudFrontViewRequest``` function currently being use by CloudFront. There can be a long wait period while the CloudFront resources are cleaned up. 42 | * If a failure occurs log into the AWS console and goto the CloudFormation console and manually delete the stack. It is recommended to check the option to retain the ```CloudFrontViewRequest``` function and manually remove it later. 43 | 1. Manually remove the two S3 buckets created which are given as outputs when you deploy. 44 | * The bucket names will begin with ```singleusesingedurl-``` 45 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSES.txt: -------------------------------------------------------------------------------- 1 | ** jquery.min 3.5.1; version 3.5.1 -- https://jquery.com/ 2 | Copyright OpenJS Foundation and other contributors, https://openjsf.org/ 3 | 4 | Copyright OpenJS Foundation and other contributors, https://openjsf.org/ 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "mvn -e -q compile exec:java", 3 | "context": { 4 | "aws-cdk:enableDiffNoFail": "true", 5 | "@aws-cdk/core:stackRelativeExports": "true", 6 | "keyPairId": "ABCD1234567890", 7 | "secretName": "SignedURLPem", 8 | "region": "us-east-1", 9 | "UUID": "" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /files/helloworld.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Hello World

6 | 7 |

This file can be access a single time, secondary accesses will be redirected to an auth page.

8 | 9 | 10 | -------------------------------------------------------------------------------- /files/web/reauth.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Invalid File

6 | 7 |

The requested file is invalid or expired, please request a new url.

8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /images/Generate.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/single-use-signed-url/7a3e8a5698da5a409e8ee7ff8500cde65e1393ce/images/Generate.jpg -------------------------------------------------------------------------------- /images/HelloWorldPage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/single-use-signed-url/7a3e8a5698da5a409e8ee7ff8500cde65e1393ce/images/HelloWorldPage.jpg -------------------------------------------------------------------------------- /images/InvalidFile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/single-use-signed-url/7a3e8a5698da5a409e8ee7ff8500cde65e1393ce/images/InvalidFile.jpg -------------------------------------------------------------------------------- /images/singleusesignedurl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/single-use-signed-url/7a3e8a5698da5a409e8ee7ff8500cde65e1393ce/images/singleusesignedurl.jpg -------------------------------------------------------------------------------- /lambda/CloudFrontViewRequest.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | 'use strict'; 20 | const AWS = require('aws-sdk'); 21 | const fs = require('fs'); 22 | const uuid = fs.readFileSync('uuid.txt'); 23 | const dynamoDBRegion = fs.readFileSync('region.txt'); 24 | let dynamoDB; 25 | const cfDomainParamName = "singleusesignedurl-domain-" + uuid, 26 | activeKeysTableParamName = "singleusesignedurl-activekeys-" + uuid; 27 | const paramQuery = { 28 | "Names": [cfDomainParamName, activeKeysTableParamName], 29 | "WithDecryption": true 30 | } 31 | let dynamoDBTableName = '', 32 | domain = '', 33 | redirectURL = ''; 34 | 35 | function redirectReponse(err, callback) { 36 | const response = { 37 | status: '302', 38 | statusDescription: 'Not Found', 39 | headers: { 40 | location: [{ 41 | key: 'Location', 42 | value: redirectURL + '?err=' + err, 43 | }], 44 | }, 45 | }; 46 | callback(null, response); 47 | } 48 | 49 | function notFoundResponse(callback) { 50 | const response = { 51 | status: '404', 52 | statusDescription: 'Not Found' 53 | }; 54 | callback(null, response); 55 | } 56 | 57 | function badRequestReponse(err, callback) { 58 | const response = { 59 | status: '400', 60 | statusDescription: 'Bad Request: ' + err 61 | }; 62 | callback(null, response); 63 | } 64 | 65 | const getSystemsManagerValues = (query) => { 66 | return new Promise((resolve, reject) => { 67 | const ssm = new AWS.SSM({'region': '' + dynamoDBRegion}); 68 | ssm.getParameters(query, function (err, data) { 69 | if (err) { 70 | return reject(err); 71 | } 72 | for (const i of data.Parameters) { 73 | if (i.Name === activeKeysTableParamName) { 74 | dynamoDBTableName = i.Value 75 | } else if (i.Name === cfDomainParamName) { 76 | domain = i.Value 77 | } 78 | } 79 | resolve({}); 80 | }) 81 | }); 82 | } 83 | 84 | exports.handler = (event, context, callback) => { 85 | AWS.config.update({'region': '' + dynamoDBRegion}); 86 | console.info("Event:" + JSON.stringify(event)); 87 | if (typeof event == "undefined" 88 | || typeof event.Records == "undefined" 89 | || event.Records.length === 0 90 | || typeof event.Records[0].cf == "undefined" 91 | || typeof event.Records[0].cf.request == "undefined" 92 | || typeof event.Records[0].cf.request.querystring == "undefined") { 93 | badRequestReponse('Invalid parameters', callback); 94 | return; 95 | } 96 | let querystring = event.Records[0].cf.request.querystring; 97 | let vars = querystring.split('&'); 98 | let id = ''; 99 | for (let i = 0; i < vars.length; i++) { 100 | let pair = vars[i].split('='); 101 | if (decodeURIComponent(pair[0]) === 'id') { 102 | id = decodeURIComponent(pair[1]); 103 | break; 104 | } 105 | } 106 | 107 | if (id === '') { 108 | notFoundResponse(callback); 109 | return; 110 | } 111 | 112 | getSystemsManagerValues(paramQuery).then(() => { 113 | redirectURL = "https://" + domain + "/web/reauth.html"; 114 | dynamoDB = new AWS.DynamoDB({maxRetries: 0, endpoint: "https://dynamodb." + dynamoDBRegion + ".amazonaws.com"}); 115 | let dbQuery = { 116 | TableName: dynamoDBTableName, 117 | Key: { 118 | "id": { 119 | "S": id 120 | }, 121 | } 122 | }; 123 | dynamoDB 124 | .getItem(dbQuery) 125 | .promise() 126 | .then(res => { 127 | if (res.Item) { // we found the item so allow access and remove key 128 | dynamoDB 129 | .deleteItem(dbQuery) 130 | .promise() 131 | .then(() => { 132 | callback(null, event.Records[0].cf.request); 133 | }) 134 | .catch(err => { 135 | console.error("DynamoDB Delete Error: " + JSON.stringify(err)); 136 | badRequestReponse(JSON.stringify(err), callback); 137 | }); 138 | } else { // item not found so redirect to fallback page 139 | redirectReponse('Item not found', callback); 140 | } 141 | }) 142 | .catch(err => { 143 | console.error("Error: " + JSON.stringify(err)) 144 | badRequestReponse(JSON.stringify(err), callback); 145 | }); 146 | }).catch(err => { 147 | console.error("Error getting parameter: " + JSON.stringify(err)); 148 | badRequestReponse(JSON.stringify(err), callback); 149 | }); 150 | }; 151 | -------------------------------------------------------------------------------- /lambda/CreateSignedURL.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | 'use strict'; 20 | const AWS = require('aws-sdk'); 21 | const fs = require('fs'); 22 | const uuid = fs.readFileSync('uuid.txt'); 23 | const ssm = new AWS.SSM(); 24 | const dynamoDB = new AWS.DynamoDB.DocumentClient(); 25 | const secretsManager = new AWS.SecretsManager(); 26 | const cfDomainParamName = "singleusesignedurl-domain-" + uuid, 27 | activeKeysTableParamName = "singleusesignedurl-activekeys-" + uuid, 28 | keyPairIdParamName = "singleusesignedurl-keyPairId-" + uuid, 29 | secretNameParamName = "singleusesignedurl-secretName-" + uuid, 30 | apiendpointParamName = "singleusesignedurl-api-endpoint-" + uuid; 31 | const paramQuery = { 32 | "Names": [cfDomainParamName, activeKeysTableParamName, keyPairIdParamName, secretNameParamName, apiendpointParamName], 33 | "WithDecryption": true 34 | } 35 | let dynamoDBTableName = '', 36 | domain = '', 37 | cloudFrontURL = '', 38 | secretName = '', 39 | keyPairId = '', 40 | apiendpoint = ''; 41 | 42 | /* Use the Secrets Manager to get the PEM file from secret variable 'secretName' which was previously 43 | created by CloudFront 44 | see: 45 | https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html#private-content-creating-cloudfront-key-pairs 46 | */ 47 | const getSecurePEM = (secretName) => { 48 | return new Promise((resolve, reject) => { 49 | secretsManager.getSecretValue({SecretId: secretName}, function (err, data) { 50 | if (err) { 51 | return reject(err); 52 | } else { 53 | var secretData; 54 | if ('SecretString' in data) { 55 | secretData = data.SecretString; 56 | } else { 57 | let buff = new Buffer(data.SecretBinary, 'base64'); 58 | secretData = buff.toString('ascii'); 59 | } 60 | //console.log("PEM: " + secretData); 61 | resolve(secretData); 62 | } 63 | }) 64 | }); 65 | } 66 | 67 | const getSystemsManagerValues = (query) => { 68 | return new Promise((resolve, reject) => { 69 | ssm.getParameters(query, function (err, data) { 70 | if (err) { 71 | return reject(err); 72 | } 73 | for (const i of data.Parameters) { 74 | if (i.Name === activeKeysTableParamName) { 75 | dynamoDBTableName = i.Value 76 | } else if (i.Name === cfDomainParamName) { 77 | domain = i.Value 78 | } else if (i.Name === secretNameParamName) { 79 | secretName = i.Value 80 | } else if (i.Name === keyPairIdParamName) { 81 | keyPairId = i.Value 82 | } else if (i.Name === apiendpointParamName) { 83 | apiendpoint = i.Value 84 | } 85 | } 86 | resolve({}); 87 | }) 88 | }); 89 | } 90 | 91 | /* Create a signed URL 92 | */ 93 | const getSignedURL = (signer, options) => { 94 | return new Promise((resolve, reject) => { 95 | signer.getSignedUrl(options, function (err, data) { 96 | if (err) { 97 | reject(err); 98 | } else { 99 | resolve(data); 100 | } 101 | }); 102 | }) 103 | } 104 | 105 | /* Write the UUID, SignedURL, and valid until date to the database, 106 | Pass along the url as the result if successful 107 | */ 108 | const writeRecordToDynamoDB = (url, uuid, file, validuntil) => { 109 | return new Promise((resolve, reject) => { 110 | let params = { 111 | TableName: dynamoDBTableName, 112 | Item: { 113 | id: uuid, 114 | file: file, 115 | validuntil: validuntil 116 | } 117 | }; 118 | dynamoDB 119 | .put(params) 120 | .promise() 121 | .then(res => { 122 | console.info("Sent data to DynamoDB"); 123 | resolve(url); // pass the data along 124 | }).catch(err => { 125 | reject(err); 126 | }); 127 | }) 128 | } 129 | exports.handler = function (event, context) { 130 | console.info("event: " + JSON.stringify(event)); 131 | console.info("context: " + JSON.stringify(context)); 132 | 133 | let timeout = 0; 134 | let file = ''; 135 | let epoch = 0; 136 | 137 | getSystemsManagerValues(paramQuery).then(smParams => { 138 | cloudFrontURL = "https://" + domain + "/"; 139 | if (!event.queryStringParameters) { 140 | let html = fs.readFileSync('generate.html', 'utf8'); 141 | html = html.replace('{API_ENDPOINT}', apiendpoint) 142 | context.succeed({ 143 | statusCode: 200, 144 | headers: { 145 | 'Content-Type': 'text/html' 146 | }, 147 | body: html 148 | }); 149 | } 150 | timeout = parseInt(event.queryStringParameters.timeout); 151 | file = event.queryStringParameters.file; 152 | epoch = parseInt(((Date.now() + 0) / 1000) + timeout); 153 | console.info("Timeout: " + timeout + " Expires: " + epoch + " File: " + file); 154 | 155 | return getSecurePEM(secretName); 156 | }).then(pem => { 157 | return getSignedURL(new AWS.CloudFront.Signer(keyPairId, pem), { 158 | "url": cloudFrontURL + file + "?id=" + context.awsRequestId, 159 | expires: epoch 160 | }); 161 | }).then(signedURL => { 162 | console.info("URL: " + signedURL); 163 | return writeRecordToDynamoDB(signedURL, context.awsRequestId, file, epoch); 164 | }).then(data => { 165 | // singed url created and store in dynamodb at this point so return a success code with detailed body. 166 | context.succeed({ 167 | statusCode: 200, 168 | headers: { 169 | "Access-Control-Allow-Headers": "Content-Type", 170 | "Access-Control-Allow-Origin": "*", 171 | "Access-Control-Allow-Methods": "OPTIONS,GET" 172 | }, 173 | body: JSON.stringify({ 174 | id: context.awsRequestId, 175 | url: data, 176 | validuntil: '' + epoch 177 | }) 178 | }); 179 | }).catch(err => { 180 | context.fail(err); 181 | }); 182 | }; 183 | -------------------------------------------------------------------------------- /lambda/generate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Generate Single Use Singed URL 6 | 7 | 8 | 26 | 27 |
28 |
29 |
30 |
31 |
32 |


33 |
34 | 39 | 40 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.amazonaws 7 | singleusesignedurl 8 | 1.0.0 9 | 10 | 11 | UTF-8 12 | 2.0.0 13 | 14 | 15 | 16 | 17 | 18 | org.apache.maven.plugins 19 | maven-compiler-plugin 20 | 3.8.1 21 | 22 | 1.8 23 | 1.8 24 | 25 | 26 | 27 | 28 | org.codehaus.mojo 29 | exec-maven-plugin 30 | 1.6.0 31 | 32 | com.amazonaws.singleusesignedurl.SingleUseSignedUrlApp 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | software.amazon.awscdk 42 | aws-cdk-lib 43 | 2.0.0 44 | 45 | 46 | 47 | junit 48 | junit 49 | [4.13.1,) 50 | test 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/singleusesignedurl/SingleUseSignedUrlApp.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | package com.amazonaws.singleusesignedurl; 20 | 21 | import software.amazon.awscdk.App; 22 | 23 | import java.io.FileNotFoundException; 24 | 25 | public class SingleUseSignedUrlApp { 26 | public static void main(final String[] args) throws FileNotFoundException { 27 | App app = new App(); 28 | 29 | new SingleUseSignedUrlStack(app, "SingleUseSignedUrlStack"); 30 | 31 | app.synth(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/singleusesignedurl/SingleUseSignedUrlStack.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | package com.amazonaws.singleusesignedurl; 20 | 21 | import software.amazon.awscdk.CfnOutput; 22 | import software.amazon.awscdk.RemovalPolicy; 23 | import software.amazon.awscdk.Stack; 24 | import software.amazon.awscdk.StackProps; 25 | import software.amazon.awscdk.services.apigateway.LambdaRestApi; 26 | import software.amazon.awscdk.services.apigateway.StageOptions; 27 | import software.amazon.awscdk.services.cloudfront.*; 28 | import software.amazon.awscdk.services.dynamodb.Attribute; 29 | import software.amazon.awscdk.services.dynamodb.AttributeType; 30 | import software.amazon.awscdk.services.dynamodb.Table; 31 | import software.amazon.awscdk.services.iam.PolicyStatement; 32 | import software.amazon.awscdk.services.lambda.*; 33 | import software.amazon.awscdk.services.lambda.Function; 34 | import software.amazon.awscdk.services.lambda.Runtime; 35 | import software.amazon.awscdk.services.s3.Bucket; 36 | import software.amazon.awscdk.services.s3.deployment.BucketDeployment; 37 | import software.amazon.awscdk.services.s3.deployment.Source; 38 | import software.amazon.awscdk.services.ssm.ParameterTier; 39 | import software.amazon.awscdk.services.ssm.StringParameter; 40 | import software.constructs.Construct; 41 | 42 | import java.io.FileNotFoundException; 43 | import java.io.FileOutputStream; 44 | import java.io.PrintStream; 45 | import java.security.InvalidParameterException; 46 | import java.util.*; 47 | 48 | public class SingleUseSignedUrlStack extends Stack { 49 | public SingleUseSignedUrlStack(final Construct scope, final String id) throws FileNotFoundException, InvalidParameterException { 50 | this(scope, id, null); 51 | } 52 | 53 | public SingleUseSignedUrlStack(final Construct scope, final String id, final StackProps props) throws FileNotFoundException, InvalidParameterException { 54 | super(scope, id, props); 55 | String uuid = getShortenedUUID(); 56 | outputRegionFile(); 57 | 58 | Table fileKeyTable = createFileKeyTable(uuid, "singleusesignedurl-activekeys-" + uuid); 59 | PolicyStatement secretValuePolicy = createGetSecretValuePolicyStatement(); 60 | PolicyStatement getParameterPolicy = createGetParametersPolicyStatement(uuid); 61 | Function createSignedURLHandler = createCreateSignedURLHandlerFunction(uuid, secretValuePolicy, getParameterPolicy, fileKeyTable); 62 | 63 | Version cloudFrontViewRequestHandlerV1 = createcloudFrontViewRequestHandlerFunction(uuid, secretValuePolicy, getParameterPolicy, fileKeyTable); 64 | Bucket cfLogsBucket = createCloudFrontLogBucket(uuid); 65 | Bucket filesBucket = createFilesBucket(uuid, "singleusesignedurl-files-" + uuid); 66 | CloudFrontWebDistribution cloudFrontWebDistribution = createCloudFrontWebDistribution(uuid, cloudFrontViewRequestHandlerV1, cfLogsBucket, filesBucket); 67 | 68 | LambdaRestApi createSignedURLApi = createCreateSignedURLRestApi(uuid, createSignedURLHandler); 69 | createParameters(uuid, fileKeyTable, createSignedURLApi, cloudFrontWebDistribution); 70 | } 71 | 72 | private Table createFileKeyTable(String uuid, String activekeysTableName) { 73 | return Table.Builder.create(this, "activekeys" + uuid) 74 | .tableName(activekeysTableName) 75 | .removalPolicy(RemovalPolicy.DESTROY) 76 | .partitionKey(Attribute.builder() 77 | .name("id") 78 | .type(AttributeType.STRING) 79 | .build()) 80 | .build(); 81 | } 82 | 83 | private PolicyStatement createGetSecretValuePolicyStatement() { 84 | return PolicyStatement.Builder.create() 85 | .resources(Collections.singletonList("arn:aws:secretsmanager:*:*:secret:*" + this.getNode().tryGetContext("secretName") + "*")) 86 | .actions(Collections.singletonList("secretsmanager:GetSecretValue")) 87 | .build(); 88 | } 89 | 90 | private PolicyStatement createGetParametersPolicyStatement(String uuid) { 91 | return PolicyStatement.Builder.create() 92 | .resources(Collections.singletonList("arn:aws:ssm:*:*:parameter/*" + uuid + "*")) 93 | .actions(Collections.singletonList("ssm:GetParameters")) 94 | .build(); 95 | } 96 | 97 | private Function createCreateSignedURLHandlerFunction(String uuid, PolicyStatement secretValuePolicy, PolicyStatement getParameterPolicy, Table fileKeyTable) { 98 | Function createSignedURLHandler = Function.Builder.create(this, "CreateSignedURL" + uuid) 99 | .runtime(Runtime.NODEJS_12_X) 100 | .functionName("CreateSignedURL" + uuid) 101 | .handler("CreateSignedURL.handler") 102 | .code(Code.fromAsset("lambda")) 103 | .build(); 104 | 105 | createSignedURLHandler.addToRolePolicy(secretValuePolicy); 106 | createSignedURLHandler.addToRolePolicy(getParameterPolicy); 107 | fileKeyTable.grantReadWriteData(createSignedURLHandler); 108 | 109 | return createSignedURLHandler; 110 | } 111 | 112 | private LambdaRestApi createCreateSignedURLRestApi(String uuid, Function createSignedURLHandler) { 113 | LambdaRestApi createSignedURLApi = LambdaRestApi.Builder.create(this, "CreateSignedURLAPI" + uuid) 114 | .handler(createSignedURLHandler) 115 | .restApiName("CreateSignedURL" + uuid) 116 | .deploy(true) 117 | .deployOptions(StageOptions.builder().stageName("prod").build()) 118 | .build(); 119 | 120 | CfnOutput.Builder.create(this, "CreateSignedURL-Output") 121 | .exportName("CreateSignedURLEndpoint") 122 | .value(createSignedURLApi.getUrl() + "CreateSignedURL" + uuid) 123 | .build(); 124 | return createSignedURLApi; 125 | } 126 | 127 | private Version createcloudFrontViewRequestHandlerFunction(String uuid, PolicyStatement secretValuePolicy, PolicyStatement getParameterPolicy, Table fileKeyTable) { 128 | Function cloudFrontViewRequestHandler = Function.Builder.create(this, "CloudFrontViewRequest" + uuid) 129 | .runtime(Runtime.NODEJS_12_X) 130 | .functionName("CloudFrontViewRequest" + uuid) 131 | .handler("CloudFrontViewRequest.handler") 132 | .code(Code.fromAsset("lambda")) 133 | .build(); 134 | 135 | cloudFrontViewRequestHandler.addToRolePolicy(secretValuePolicy); 136 | cloudFrontViewRequestHandler.addToRolePolicy(getParameterPolicy); 137 | 138 | Version cloudFrontViewRequestHandlerV1 = Version.Builder.create(this, "A" + uuid) 139 | .lambda(cloudFrontViewRequestHandler) 140 | .build(); 141 | 142 | fileKeyTable.grantReadWriteData(cloudFrontViewRequestHandler); 143 | return cloudFrontViewRequestHandlerV1; 144 | } 145 | 146 | private Bucket createCloudFrontLogBucket(String uuid) { 147 | Bucket cfLogsBucket = Bucket.Builder.create(this, "singleusesignedurl-cf-logs" + uuid) 148 | .bucketName("singleusesignedurl-cf-logs-" + uuid) 149 | .removalPolicy(RemovalPolicy.RETAIN) 150 | .build(); 151 | CfnOutput.Builder.create(this, "singleusesignedurl-cf-logs-output" + uuid) 152 | .exportName("singleusesignedurl-cf-logs") 153 | .value(cfLogsBucket.getBucketName()) 154 | .build(); 155 | return cfLogsBucket; 156 | } 157 | 158 | private Bucket createFilesBucket(String uuid, String s3FileBucketName) { 159 | Bucket filesBucket = Bucket.Builder.create(this, "singleusesignedurl-files-bucket" + uuid) 160 | .bucketName(s3FileBucketName) 161 | .removalPolicy(RemovalPolicy.RETAIN) 162 | .build(); 163 | CfnOutput.Builder.create(this, "singleusesignedurl-files-bucket-output" + uuid) 164 | .exportName("singleusesignedurl-files-bucket") 165 | .value(filesBucket.getBucketName()) 166 | .build(); 167 | BucketDeployment.Builder.create(this, "DeployTestFiles" + uuid) 168 | .destinationKeyPrefix("") 169 | .sources(Collections.singletonList(Source.asset("./files"))) 170 | .destinationBucket(filesBucket) 171 | .build(); 172 | return filesBucket; 173 | } 174 | 175 | private CloudFrontWebDistribution createCloudFrontWebDistribution(String uuid, Version cloudFrontViewRequestHandlerV1, Bucket cfLogsBucket, Bucket filesBucket) { 176 | CloudFrontWebDistribution.Builder cloudFrontWebDistributionBuilder = CloudFrontWebDistribution.Builder.create(this, "SingleUseSignedURL" + uuid); 177 | 178 | Behavior webBehavior = Behavior.builder() 179 | .pathPattern("web/*") 180 | .allowedMethods(CloudFrontAllowedMethods.GET_HEAD) 181 | .build(); 182 | 183 | LambdaFunctionAssociation lambdaFunctionAssociation = LambdaFunctionAssociation.builder() 184 | .lambdaFunction(cloudFrontViewRequestHandlerV1) 185 | .includeBody(true) 186 | .eventType(LambdaEdgeEventType.VIEWER_REQUEST) 187 | .build(); 188 | 189 | Behavior distroBehavior = Behavior.builder() 190 | .allowedMethods(CloudFrontAllowedMethods.GET_HEAD) 191 | .isDefaultBehavior(true) 192 | .lambdaFunctionAssociations(Collections.singletonList(lambdaFunctionAssociation)) 193 | .build(); 194 | OriginAccessIdentity oadIdentity = OriginAccessIdentity.Builder.create(this, "OAI").build(); 195 | S3OriginConfig s3OriginConfig = S3OriginConfig.builder() 196 | .s3BucketSource(filesBucket) 197 | .originAccessIdentity(oadIdentity) 198 | .build(); 199 | SourceConfiguration scDistro = SourceConfiguration.builder() 200 | .s3OriginSource(s3OriginConfig) 201 | .behaviors(Arrays.asList(webBehavior, distroBehavior)) 202 | .build(); 203 | 204 | CloudFrontWebDistribution cloudFrontWebDistribution = cloudFrontWebDistributionBuilder 205 | .comment("Cloud Front distribution to handle single use signed URLs") 206 | .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS) 207 | .httpVersion(HttpVersion.HTTP2) 208 | .loggingConfig(LoggingConfiguration.builder().bucket(cfLogsBucket).prefix("cf-logs/").build()) 209 | .originConfigs(Collections.singletonList(scDistro)) 210 | .build(); 211 | 212 | CfnOutput.Builder.create(this, "singleusesignedurl-domain") 213 | .exportName("singleusesignedurl-domain") 214 | .value(cloudFrontWebDistribution.getDistributionDomainName()) 215 | .build(); 216 | 217 | return cloudFrontWebDistribution; 218 | } 219 | 220 | private void createParameters(String uuid, Table fileKeyTable, LambdaRestApi createSignedURLApi, CloudFrontWebDistribution cloudFrontWebDistribution) { 221 | //Parameter names 222 | String cfDomainParamName = "singleusesignedurl-domain-" + uuid; 223 | String activeKeysTableParamName = "singleusesignedurl-activekeys-" + uuid; 224 | String keyPairIdParamName = "singleusesignedurl-keyPairId-" + uuid; 225 | String secretNameParamName = "singleusesignedurl-secretName-" + uuid; 226 | String apiEndpointParamName = "singleusesignedurl-api-endpoint-" + uuid; 227 | StringParameter.Builder.create(this, cfDomainParamName) 228 | .allowedPattern(".*") 229 | .description("The cloud front domain name") 230 | .parameterName(cfDomainParamName) 231 | .stringValue(cloudFrontWebDistribution.getDistributionDomainName()) 232 | .tier(ParameterTier.STANDARD) 233 | .build(); 234 | StringParameter.Builder.create(this, apiEndpointParamName) 235 | .allowedPattern(".*") 236 | .description("The api endpoint used in the demo generate response") 237 | .parameterName(apiEndpointParamName) 238 | .stringValue(createSignedURLApi.getUrl() + "CreateSignedURL" + uuid) 239 | .tier(ParameterTier.STANDARD) 240 | .build(); 241 | StringParameter.Builder.create(this, keyPairIdParamName) 242 | .allowedPattern(".*") 243 | .description("The key pair id to use when signing") 244 | .parameterName(keyPairIdParamName) 245 | .stringValue((String) this.getNode().tryGetContext("keyPairId")) 246 | .tier(ParameterTier.STANDARD) 247 | .build(); 248 | StringParameter.Builder.create(this, secretNameParamName) 249 | .allowedPattern(".*") 250 | .description("The secret id that holds the CloudFront PEM file") 251 | .parameterName(secretNameParamName) 252 | .stringValue((String) this.getNode().tryGetContext("secretName")) 253 | .tier(ParameterTier.STANDARD) 254 | .build(); 255 | StringParameter.Builder.create(this, activeKeysTableParamName) 256 | .allowedPattern(".*") 257 | .description("The database name") 258 | .parameterName(activeKeysTableParamName) 259 | .stringValue(fileKeyTable.getTableName()) 260 | .tier(ParameterTier.STANDARD) 261 | .build(); 262 | } 263 | 264 | public String getShortenedUUID() throws FileNotFoundException, InvalidParameterException { 265 | Object uuidObj = this.getNode().tryGetContext("UUID"); 266 | if (uuidObj != null) { 267 | String uuid = ((String) this.getNode().tryGetContext("UUID")).replace("-", ""); 268 | try (PrintStream out = new PrintStream(new FileOutputStream("./lambda/uuid.txt"))) { 269 | out.print(uuid); 270 | } 271 | return uuid; 272 | } else { 273 | throw new InvalidParameterException("Missing UUID in cdk.json: " + (uuidObj == null ? "null" : uuidObj.toString())); 274 | } 275 | } 276 | 277 | // Using a region file to the lack of being able to use environment variables with CloudFront Lambda functions 278 | public void outputRegionFile() throws FileNotFoundException { 279 | Object region = this.getNode().tryGetContext("region"); 280 | if (region != null) { 281 | try (PrintStream out = new PrintStream(new FileOutputStream("./lambda/region.txt"))) { 282 | out.print((String)region); 283 | } 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/test/java/com/amazonaws/singleusesignedurl/SingleUseSignedUrlTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | package com.amazonaws.singleusesignedurl; 20 | 21 | import software.amazon.awscdk.App; 22 | import com.fasterxml.jackson.databind.ObjectMapper; 23 | import com.fasterxml.jackson.databind.SerializationFeature; 24 | import org.junit.Test; 25 | 26 | import java.io.IOException; 27 | import java.util.HashMap; 28 | 29 | import static org.junit.Assert.assertEquals; 30 | 31 | public class SingleUseSignedUrlTest { 32 | private final static ObjectMapper JSON = 33 | new ObjectMapper().configure(SerializationFeature.INDENT_OUTPUT, true); 34 | 35 | @Test 36 | public void testStack() throws IOException { 37 | java.util.Map context = new HashMap<>(); 38 | context.put("keyPairId", "123456ABCD"); 39 | context.put("secretName", "123456ABCD"); 40 | context.put("region", "us-east-1"); 41 | context.put("UUID", "testuuid"); 42 | App app = App.Builder.create().context(context).build(); 43 | 44 | SingleUseSignedUrlStack stack = new SingleUseSignedUrlStack(app, "test"); 45 | } 46 | } 47 | --------------------------------------------------------------------------------