Cognito-authorized uploads to AWS S3

Andrew Trigg
8 min readJan 15, 2019

--

An earlier story I wrote (link above) described how to upload images/files to S3 using a presigned url via a lambda function. I’m working on an application that requires users to be logged in, and so I wondered if there was a way to use that logging-in mechanism to upload images/files securely.

There is indeed a way to do this, and if anything, it is simpler than using the pre-signed url.

In this walkthrough we will set up an app with AWS appsync and amplify, and use Vue.js for our client SPA.

So ensure you have vue-cli installed (I’m using version 3.1.3) and amplify-cli (I’m using version 0.2.1-multienv.22), and of course, you need an AWS account.

Start a new vue project by typing the following and selecting your preset:

vue create cognito-upload-example

Move into this newly created folder and initialise it as an amplify project:

> cd cognito-upload-example
> amplify init

Answer the questions at the prompt as appropriate to you and once the initialisation process has finished, add an api with the following command:

amplify add api

At the prompt, select the graphql option for the API and choose a name for the API. When you are asked to choose an authorisation type, choose ‘Amazon Cognito User Pool’. After that, just select all the default options.

Once you answer ‘yes’ to editing the schema, your text editor will open with the schema.graphql file. Change it to look like this:

type Modform @model {
id: ID!
files: [String]
site: String
page: String
changes: String
}

Once you have saved the schema, go back to the console window and press enter to continue.

Next we need to add our S3 bucket. In the console type:

amplify add storage

You will be prompted to answer a few more questions. Make sure for the first question you select ‘Content (Images, audio, video, etc.)’ . And for the question about who gets access, select ‘Auth users only’. Give them read and write access.

Now we have set up our API and S3 storage details on our local machine, we need to push these details to AWS:

amplify push

This will set up all the resources we defined, in the cloud. Before the process begins, we will be shown a table in the console indicating the pending operations. We should have a ‘Create’ operation pending for the API, the Auth, and the Storage categories. Answer ‘yes’ to continue, and then provide the default answer to the next few questions to generate code for our API.

When this process finishes you will see a message indicating that all resources in the cloud are updated, and your GraphQL endpoint will be displayed.

We need to install some more packages before we continue:

npm install --save graphql-tag@2.10.0 vue-apollo@3.0.0-beta.27 aws-appsync@1.7.0 aws-amplify@1.1.7 aws-amplify-vue@0.1.4

Open the src/main.js file in our project and change it to the following:

import Vue from 'vue'
import App from './App.vue'
import Amplify, * as AmplifyModules from 'aws-amplify';
import { AmplifyPlugin } from 'aws-amplify-vue'
import awsConfig from './aws-exports';import VueApollo from 'vue-apollo'
import AWSAppSyncClient from 'aws-appsync'
const config = {
url: awsConfig.aws_appsync_graphqlEndpoint,
region: awsConfig.aws_appsync_region,
auth: {
type: awsConfig.aws_appsync_authenticationType,
// the below function is run on each request, so it is always
// kept up to date
jwtToken: async () => (await AmplifyModules.Auth.currentSession()).getIdToken().getJwtToken()
}
}
// Both Amplify and Appsync need to be configured, despite taking
// many of the same settings
Amplify.configure(awsConfig);
const client = new AWSAppSyncClient(config)
const appsyncProvider = new VueApollo({
defaultClient: client
})
// Ensure we have the use of Amplify components throughout the app
Vue.use(AmplifyPlugin, AmplifyModules)
Vue.use(VueApollo)Vue.config.productionTip = falsenew Vue({
render: h => h(App),
apolloProvider: appsyncProvider
}).$mount('#app')

And then change the src/App.vue file to look like this:

<template>
<div id="app">
<amplify-sign-out v-if="user.sub" class="sign-out" />
<amplify-authenticator :authConfig="authConfig" />
<demo-page v-if="user.sub" :user="user" />
</div>
</template>
<script>
import { AmplifyEventBus } from 'aws-amplify-vue'
import Amplify from 'aws-amplify'
import DemoPage from '@/components/DemoPage'
export default {
name: 'app',
async beforeCreate () {
AmplifyEventBus.$on('authState', async (state) => {
switch (state) {
case 'signedIn':
this.authenticateUser()
break
case 'signedOut':
this.user = {}
break
default:
console.log('Event:', state)
}
})
},
async mounted() {
this.authenticateUser()
},
data() {
return {
user: {},
authConfig: {
signUpConfig: {
hideDefaults: true,
signUpFields: [
{
label: 'Name',
key: 'username',
required: true,
displayOrder: 1,
type: 'string'
},
{
label: 'Email',
key: 'email',
required: true,
displayOrder: 2,
type: 'string'
},
{
label: 'Password',
key: 'password',
required: true,
displayOrder: 3,
type: 'password'
}
]
}
}
}
},
components: {
DemoPage
},
methods: {
async authenticateUser () {
try {
// If a user session is in localstorage, store it in the
// component's data
let user = await Amplify.Auth.currentAuthenticatedUser()
if (user && user.signInUserSession) {
this.user = {
...user.attributes,
username: user.username
}
}
} catch (e) {
console.log('error:', e)
}
}
}
}
</script>
<style>
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
}
#app {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
}
.sign-out {
position: fixed;
top: 20px;
right: 20px;
}
</style>

Create a demo page as follows assrc/components/DemoPage.vue:

<template>
<div class="container">
<div class="form">
<h3>Modform</h3>
<label>Site:</label>
<input
type="text"
placeholder="Your site"
v-model="model.site"
/>
<label>Page:</label>
<input
type="text"
placeholder="Your page"
v-model="model.page"
/>
<label>Changes:</label>
<input
type="text"
placeholder="Your changes"
v-model="model.changes"
/>
<amplify-photo-picker
:photoPickerConfig="{path: `${user.sub}/${timestamp}/`}"
/>
<amplify-s3-album
:path="`${user.sub}/${timestamp}/`"
ref="album"
v-show="albumHasItems"
></amplify-s3-album>
<button
class="btn-submit"
@click="handleSubmit"
>
Submit
</button>
</div>
</div>
</template><script>
import gql from 'graphql-tag'
import { createModform } from '@/graphql/mutations'
import { AmplifyEventBus } from 'aws-amplify-vue'
export default {
name: 'DemoPage',
created () {
this.timestamp = Date.now()
AmplifyEventBus.$on('fileUpload', (path) => {
this.model.files.push(`https://cognito-upload-example-dev.s3.amazonaws.com/public/${path}`)
this.$nextTick(() => {
if (this.$refs.album && this.$refs.album.items.length) {
this.albumHasItems = true
}
})
})
},
props: ['user'],
data () {
return {
timestamp: '',
albumHasItems: false,
model: {
files: []
}
}
},
methods: {
handleSubmit (e) {
this.$apollo.mutate({
mutation: gql(createModform),
variables: { input: this.model }
})
.then(form => {
console.log('Form stored in database:', form)
this.model = { files: [] }
this.timestamp = Date.now()
this.albumHasItems = false
})
}
}
}
</script>
<style lang="css" scoped>
.form {
max-width: 500px;
min-height: 310px;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid lightgrey;
padding: 40px;
border-radius: 5px;
}
.form input {
font-size: 1em;
padding-left: 3px;
margin-bottom: 10px;
}
.btn-submit {
background-color: #7979de;
border-radius: 5px;
color: white;
margin-top: 10px;
font-size: 18px;
padding: 3px;
}
.container {
display: flex;
justify-content: space-around;
}
</style>

The front end is set up now. Try to run the app with npm run serve.

I had a couple of strange issues to overcome before this worked for me.

  1. I had an error saying that the vue package was not found. I thought this should have been installed by the vue-cli as it created the project. Nevertheless, we can overcome this with a npm install command.
  2. The browser window was blank. Opening the devtools reveals an error saying Unknown custom element: <amplify-authenticator> . This is something I have come across before with newer versions of aws-amplify and aws-amplify-vue (and why I chose to use older versions for this demo). You can fix this by removing your node_modules folder and package-lock.json . Then reinstall your packages with npm install .

Now you should be able to run the app with npm run serve.

You’ll need to create an account before you log in, make sure you use a real email address of yours because you will need the verification code. And note, if you don’t change the password settings in the AWS console, your password will need to be at least 8 characters long, and include a lowercase character, an uppercase character, a special character and a number.

You can alter these requirements in the policies tab of your userpool as shown below.

Generic file upload

Although our app works, it is certainly not polished. If you add more than one file to a form, although the functionality works, a warning is given because of a duplicate key. This seems to be related to a hardcoded key in the aws-amplify-vue package. Also, when you add an image in a new form, the images from the last form appear in the amplify-s3-album component. And, once you click ‘upload’, it seems there is no easy way to disable our form’s submit button during the upload process. I didn’t resolve myself to work around these issues because I felt it would involve too much struggle against the aws-amplify-vue package. Better to write our own, which can handle other file types too.

Here is how I updated the demo page. This way I can upload all types of files, and by clicking the get link button, I get a link to download the file.

Updated demo page:

<template>
<div class="container">
<form class="form" @submit.prevent="handleSubmit">
<h3>Modform</h3>
<label>Site:</label>
<input
type="text"
placeholder="Your site"
v-model="model.site"
/>
<label>Page:</label>
<input
type="text"
placeholder="Your page"
v-model="model.page"
/>
<label>Changes:</label>
<input
type="text"
placeholder="Your changes"
v-model="model.changes"
/>
<input type="file" @change="addFile" multiple/>
<ul v-if="model.files.length">
<li v-for="(filename, index) in model.files" :key="filename">
{{filename.split('/').reverse()[0]}}
<span>
<button @click.prevent="removeFile(filename)" class="btn">remove</button>
<a :href="downloadableLink" class="btn" v-if="index === downloadableFileIndex">download</a>
<button v-else @click.prevent="getFile(filename, index)" class="btn">get link</button>
</span>
</li>
</ul>
<input
type="submit"
class="btn-submit"
:disabled="uploading"
>
</button>
</form>
</div>
</template>
<script>
import gql from 'graphql-tag'
import { createModform } from '@/graphql/mutations'
import { Storage } from 'aws-amplify'
export default {
name: 'DemoPage',props: ['user'],
mounted () {
this.timestamp = Date.now()
},
data () {
return {
timestamp: '',
uploading: false,
downloadableFileIndex: null,
downloadableLink: '',
model: {
files: []
}
}
},
methods: {
resetDownloadableLink () {
this.downloadableLink = ''
this.downloadableFileIndex = null
},
async getFile (filename, index) {
this.downloadableLink = await Storage.get(filename)
this.downloadableFileIndex = index
},
async addFile ({target}) {
this.uploading = true
let result = await Promise.all(
[].map.call(target.files, file => Storage.put(`${this.timestamp}/${file.name}`, file))
)
let filenames = result.map(name => name.key)
this.model.files = this.model.files.concat(filenames)
this.uploading = false
this.resetDownloadableLink()
},
async removeFile (filename) {
try {
await Storage.remove(filename)
this.model.files = this.model.files.filter(name => name !== filename)
this.resetDownloadableLink ()
} catch (err) {
console.log('Error:', err)
}
},
handleSubmit ({target}) {
this.$apollo.mutate({
mutation: gql(createModform),
variables: { input: this.model }
})
.then(form => {
target.reset()
this.model = { files: [] }
this.timestamp = Date.now()
})
}
}
}
</script>
<style lang="css" scoped>
.form {
max-width: 500px;
width: 500px;
min-height: 310px;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid lightgrey;
padding: 40px;
border-radius: 5px;
}
.form input {
font-size: 1em;
padding-left: 3px;
margin-bottom: 10px;
}
.btn-submit {
background-color: #7979de;
border-radius: 5px;
color: white;
margin-top: 10px;
font-size: 18px;
padding: 3px;
}
.btn-submit:disabled {
opacity: .5;
}
.container {
display: flex;
justify-content: space-around;
}
li {
display: flex;
justify-content: space-between;
}
/* thanks for help with the button styling from
https://css-tricks.com/overriding-default-button-styles/
*/
.btn {
-webkit-appearance: button;
font-family: sans-serif;
text-decoration: none;
text-transform: none;
border: none;
display: inline-block;
cursor: default;
padding: 2px 4px;
margin: 2px;
background: var(--amazonOrange);
color: var(--button-color);
font-size: 12px;
-webkit-appearance: none;
-moz-appearance: none;
}
.btn:hover {
background: var(--lightAmazonOrange);
}
</style>

--

--