DEV Community

t3chflicks
t3chflicks

Posted on

🚫 Users Only: Quickstart for creating a SaaS pt. 1 — User Management

Managing users and API keys is a necessary task for creating a Software as a Service (SaaS). In this article, I demonstrate how to create a simple, cost effective serverless SaaS user management application. The frontend is created using Vue JS and the Amplify plugin. The backend uses Cognito for authentication of the user management API, and a key system created with DynamoDB which authorises users to access a test API created with Application Load Balancer. Check out the live demo 💽.

Not everything is intended for everyone. In a scenario where restricting access is necessary, user management must become a component of the architecture. The AWS solution to user management is the Cognito service. This integrates simply with API Gateway — but as described in the previous article, API gateway can get pretty expensive, and for high load the more cost effective alternative is Application Load Balancer.
Cheaper than API Gateway — ALB with Lambda using CloudFormation
*An alternative to API gateway is Application Load Balancer. ALB can be connected with Lambda to produce a highly…*medium.com

The architecture for this user management application (see below) makes use of both API gateway and ALB for their respective benefits. API gateway is chosen for the User API for two reasons:

  • the requests are likely to be low in volume meaning costs will be low

  • the requests are direct from the frontend with authentication using the Amplify package with the Vue JS framework

ALB is chosen for the example service Test API as it is expected to experience a high volume of requests which might rack up a hefty bill on API Gateway. Also, the requests are likely to be from other servers meaning API keys are preferable. Users are generated an API key on sign up which is used to authenticate the Test API. Usage of the API is monitored by incrementing a count on the User table each time a request is made to the Test API.

User Manager Quickstart Architecture Diagram*

User Manager Quickstart Architecture Diagram*

This architecture is cost effective for high-load APIs. A quote from this article might help you decide if your service falls into that category:

$15 (ALB’s cost) worth of API Gateway calls will net you around 4.3 million API calls in the month (well, 5.3 million if you count the free tier). So, if your API is small enough to fit under that number of calls, stick with API Gateway — it’s simpler to use. But, 5.3 million API calls is only around two requests per second. So, if your API is used very much at all — or even if you have DNS health checks enabled — you could easily end up paying more for API Gateway than you would for Application Load Balancer.

I presume this article will not bring in more than two reads per second, meaning an ALB would not be suitable for running the demo of the service. Instead, the test API seen in the diagram has been replaced with another API Gateway. You can see a demonstration of the app in use below.

The full code for this project can be found here ☁️

The live demo can be found here 💽

Video of Use Manager App*

Video of Use Manager App*

Let’s Build! 🔩

Backend

The infrastructure for this system is written as code using the CloudFormation framework.

VPC

ALBs must be placed within a Virtual Private Cloud on AWS. The service uses a stripped down VPC consisting of only two public subnets and an Internet Gateway. A more complete VPC is described in a previous article:
Virtual Private Cloud on AWS — Quickstart with CloudFormation
*A Virtual Private Cloud is the foundation from which to build a new system. In this article, I demonstrate how to…*medium.com

User Management with Cognito

The AWS Cognito service is used to manage users. Users are stored in user pools, and Clients (mobile or web apps) can interact with them using an API. I configured the Cognito user pool to send an email with a verification code on sign up.

CognitoUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
    ClientName: CognitoUserPoolClient
    UserPoolId: !Ref CognitoUserPool
    AllowedOAuthFlows:
    - implicit
    AllowedOAuthFlowsUserPoolClient: true
    AllowedOAuthScopes:
    - email
    - openid
    LogoutURLs:
    - !Sub 
        - https://um-app.${ Domain }
        - Domain: !ImportValue UserManagerApp-RootDomain
    CallbackURLs:
    - !Sub 
        - https://um-app.${ Domain }
        - Domain: !ImportValue UserManagerApp-RootDomain
    SupportedIdentityProviders:
    - COGNITO  
Enter fullscreen mode Exit fullscreen mode

You must also define a client; this allows for OAuth on our frontend client.

NB: Setting the URLs to localhost allows for local development.

CognitoUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
    ClientName: CognitoUserPoolClient
    UserPoolId: !Ref CognitoUserPool
    AllowedOAuthFlows:
    - implicit
    AllowedOAuthFlowsUserPoolClient: true
    AllowedOAuthScopes:
    - email
    - openid
    LogoutURLs:
    - !Sub 
        - https://um-app.${ Domain }
        - Domain: !ImportValue UserManagerApp-RootDomain
    CallbackURLs:
    - !Sub 
        - https://um-app.${ Domain }
        - Domain: !ImportValue UserManagerApp-RootDomain
    SupportedIdentityProviders:
    - COGNITO 
Enter fullscreen mode Exit fullscreen mode

Cognito user pools offer useful event tiggers such as sign up confirmation. I have used the post-confirmation event to trigger a Lambda function which writes the user’s username, a newly generated API key, and a zero initialised counter to a DynamoDB table. The table has a Global Secondary Index (GSI) on the Key attribute which allows for a user lookup with just the Key.

UserTable: 
    Type: AWS::DynamoDB::Table
    Properties: 
    AttributeDefinitions: 
        - AttributeName: "Id"
        AttributeType: "S"
        - AttributeName: "Key"
        AttributeType: "S"
    KeySchema: 
        - AttributeName: "Id"
        KeyType: "HASH"
    ProvisionedThroughput: 
        ReadCapacityUnits: 1
        WriteCapacityUnits: 1
    GlobalSecondaryIndexes: 
        - IndexName: "KeyLookup"
        KeySchema: 
            - AttributeName: "Key"
            KeyType: "HASH"
        Projection: 
            ProjectionType: "ALL"
        ProvisionedThroughput: 
            ReadCapacityUnits: 1
            WriteCapacityUnits: 1
Enter fullscreen mode Exit fullscreen mode

The PostConfirmation Lambda function template:

PostConfirmationLambdaFunction:
    Type: AWS::Serverless::Function
    Properties:
    CodeUri: ./PostConfirmation
    Handler: main.handler
    MemorySize: 128
    Runtime: python3.8
    Timeout: 60
    Role: !GetAtt PostConfirmationLambdaRole.Arn
    Environment:
        Variables:
        TABLE_NAME: !Ref UserTable
Enter fullscreen mode Exit fullscreen mode

The PostConfirmation Lambda is a Python function which creates an API key and stores it in the DynamoDB table:

import boto3
import os

dynamodb = boto3.resource('dynamodb')
TABLE_NAME = os.environ['TABLE_NAME']
table = dynamodb.Table(TABLE_NAME)

def handler(event, context):
try:
    username = event["userName"]
    table.put_item(
        Item={
                'Id': username,
                'Key': os.urandom(64).hex(),
                'Count': 0
        }
        )
except Exception as err:
    print("ERR: ", err)
return event
Enter fullscreen mode Exit fullscreen mode

User API

Endpoints created for users to access their profile and generate an API key are authenticated for the logged in user. Authentication is easily added to API gateway with Cognito (CORS are set open for development purposes).

UserApi:
    Type: 'AWS::ApiGateway::RestApi'
    Properties:
    Body:
        info:
        version: '1.0'
        title: !Ref 'AWS::StackName'
        paths:
        /user:
            options:
            x-amazon-apigateway-integration:
                type: mock
                requestTemplates:
                application/json: |
                    {
                    "statusCode" : 200
                    }
                responses:
                default:
                    statusCode: '200'
                    responseTemplates:
                    application/json: |
                        {}
                    responseParameters:
                    method.response.header.Access-Control-Allow-Origin: '''*'''
                    method.response.header.Access-Control-Allow-Methods: '''*'''
                    method.response.header.Access-Control-Allow-Headers: '''*'''
            consumes:
                - application/json
            summary: CORS support
            responses:
                '200':
                headers:
                    Access-Control-Allow-Origin:
                    type: string
                    Access-Control-Allow-Headers:
                    type: string
                    Access-Control-Allow-Methods:
                    type: string
                description: Default response for CORS method
            produces:
                - application/json
            get:
            x-amazon-apigateway-integration:
                httpMethod: GET
                type: aws_proxy
                uri: !Sub >-
                arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GetUserFunction.Arn}/invocations
            security:
                - CognitoUserPoolAuthorizer: []
            responses: {}
        swagger: '2.0'
        securityDefinitions:
        CognitoUserPoolAuthorizer:
            in: header
            type: apiKey
            name: Authorization
            x-amazon-apigateway-authorizer:
            providerARNs:
                - !ImportValue UserManagerApp-CognitoUserPoolArn
            type: cognito_user_pools
            x-amazon-apigateway-authtype: cognito_user_pools
Enter fullscreen mode Exit fullscreen mode

The code for getting and generating keys is very similar to the post-confirmation Lambda code.

Service API — Test

The API I’ve created with ALB is for the high traffic volume endpoints which are authenticated using an API key. This API key is sent in a POST request to the service.

LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
    SecurityGroups:
        - !Ref LoadBalancerSecGroup
    Subnets:
        - !ImportValue UserManagerApp-PublicSubnetA
        - !ImportValue UserManagerApp-PublicSubnetB

LoadBalancerTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
    TargetType: lambda
    Targets:
        - AvailabilityZone: all
        Id: !GetAtt Lambda.Arn

LoadBalancerListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    DependsOn:
    - LambdaFunctionPermission
    Properties:
    LoadBalancerArn: !Ref LoadBalancer
    DefaultActions:
        - Type: forward
        TargetGroupArn: !Ref LoadBalancerTargetGroup
    Port: 443
    Certificates:
        - CertificateArn: !ImportValue UserManagerApp-RegionalCertArn
    Protocol: HTTPS

Lambda:
    Type: AWS::Lambda::Function
    Properties:
    Code:
        S3Bucket: !ImportValue UserManagerApp-CodeBucketName
        S3Key: TestKey.zip
    Description: Test Service function
    Handler: main.handler
    MemorySize: 256
    Role: !GetAtt LambdaRole.Arn
    Runtime: python3.8
    Timeout: 60
    Environment:
        Variables:
        TABLE_NAME: !ImportValue  UserManagerApp-UserTableName
Enter fullscreen mode Exit fullscreen mode

The service uses the user’s API key for a query on a GSI of the user table to get the username and Count, then increment the Count and return the updated Count. Again, I have added open CORS whilst developing. For security, these should been locked down.

import boto3
from boto3.dynamodb.conditions import Key
import os
import time
import json
from decimal import Decimal


dynamodb = boto3.resource('dynamodb')
TABLE_NAME = os.environ['TABLE_NAME']
table = dynamodb.Table(TABLE_NAME)

def handler(event, context):
response = {
    "isBase64Encoded": False,
    "headers": {
        'access-control-allow-methods': 'POST, OPTIONS',
        'access-control-allow-origin': '*',
        'access-control-allow-headers': 'Content-Type, Access-Control-Allow-Headers'
    }
}

try:
    if event['httpMethod'] == "POST":
    count = "UNKNOWN"
    key = json.loads(event["body"]).get("key")
    keySearchResp = table.query(
        IndexName='KeyLookup',
        KeyConditionExpression=Key('Key').eq(key)
    )
    item = keySearchResp["Items"][0]
    id = item["Id"]

    keyUpdateResp = table.update_item(
        Key={
            'Id': id,
        },
        UpdateExpression="set #c = #c +:num",
        ExpressionAttributeNames={
            "#c": "Count"
        },
        ExpressionAttributeValues={
            ':num': Decimal(1),
        },
        ReturnValues="UPDATED_NEW"
    )

    count =  keyUpdateResp["Attributes"]["Count"]
    response["body"] = json.dumps({ "COUNT": int(count)})
    response["statusCode"] = 200

    elif event['httpMethod'] == "OPTIONS":
    response["statusCode"] = 200

    else:
    raise Exception('Method not accepted')

except Exception as err:
    print("ERR: ", err)
    response["statusCode"] = 500

return response
Enter fullscreen mode Exit fullscreen mode

Frontend

Vue JS

My choice of JavaScript framework is Vue JS and I make quite a lot of use of Vuetify, a Material Design styling framework. After installing, it’s as simple as:

vue create frontend
---step through configurations and use defaults
vue add vuetify
Enter fullscreen mode Exit fullscreen mode

Amplify

AWS Amplify is a JS plugin which consists of some useful functions for serverless Auth and API, as well as some frontend components for Vue JS, enabling user authentication flows.

import Vue from 'vue'
import App from './App.vue'
import '@aws-amplify/ui-vue';
import Amplify from 'aws-amplify';
import  { Auth } from 'aws-amplify';

import store from './store'
import router from './router'
import vuetify from './plugins/vuetify';

const ROOT_DOMAIN = 't3chflicks.org';

Amplify.configure({
    Auth: {
        region: '',
        userPoolId: '',
        userPoolWebClientId: '',
        mandatorySignIn: false,
        oauth: {
            scope: [ 'email', 'openid'],
            redirectSignIn: `https://um-app.${ROOT_DOMAIN}/`,
            redirectSignOut: `https://um-app.${ROOT_DOMAIN}/`,
            responseType: 'code'
        }
    },
    API: {
        endpoints: [
            {
                name: "UserAPI",
                endpoint: `https://um-user.${ROOT_DOMAIN}`,
                custom_header: async () => { 
                    return { Authorization: `Bearer ${(await Auth.currentSession()).getIdToken().getJwtToken()}` }                  
                }
            },
            {
                name: "TestAPIKey",
                endpoint: `https://um-test.${ROOT_DOMAIN}`,
            }
        ]
    }
});

Vue.prototype.$Amplify = Amplify;
new Vue({
    store,
    router,
    vuetify,
    render: h => h(App)
}).$mount('#app')
Enter fullscreen mode Exit fullscreen mode

In the Vue App, I use Amplify’s Authenticator UI components for user authentication flows.

<template>
<v-container fluid>
    <v-row align="center">
        <v-spacer/>
        <v-col cols="10">
            <amplify-authenticator username-alias="email" initial-auth-state="signup" v-if="!signedIn">
            <amplify-sign-up username-alias="email"  :form-fields.prop="formFields" slot="sign-up"></amplify-sign-up>
            </amplify-authenticator>
            <div v-if="signedIn ">
            <user-info class="my-2" />
            <amplify-sign-out />
            </div>
        </v-col>
        <v-spacer/>
    </v-row>
</v-container>
</template>
Enter fullscreen mode Exit fullscreen mode

This is what an authenticated user sees:

Unauthenticated User Page*

Unauthenticated User Page*

With the API configured in main.js, calling an API inside a Vue component is pretty simple.

methods: {
  async testKey() {
    if(this.testAPIKey !== ""){
      const myInit = { 
          body: {
            key: this.testAPIKey
          },
      };
      const response = await API.post('TestAPIKey', '/', myInit);
      this.response = JSON.stringify(response);
    }
    else {
      alert("enter your key")
    }
  }
Enter fullscreen mode Exit fullscreen mode

The authorisation header is set for the current logged in user. The code for accessing the User API is just as clean.

methods: {
    async getUserKey(){
    const resp = await API.get('UserAPI', '/user');
    this.APIKey = resp.KEY;
    },
}
Enter fullscreen mode Exit fullscreen mode

full code ☁️ . . . . . . . . . . . . . . . . . . . . . . . . . . .live demo 💽

Deploy

Using CloudFormation templates mean it is simple to deploy this infrastructure using the AWS CLI.

aws cloudformation deploy --template-name ./00-infra.yml
Enter fullscreen mode Exit fullscreen mode

Vue JS builds a static site which is uploaded to S3 and deployed using CloudFront.

npm run build;
aws s3 cp ./dist s3://<< your bucket name >> --recursive
Enter fullscreen mode Exit fullscreen mode

Video of Use Manager App*

Video of Use Manager App*

After Thoughts

This Architecture is the basis for a SaaS, the next step is to link to a payment service such as Stripe to handle subscriptions, which is done in part 2:
**💸 Pay Me: Quickstart for creating a SaaS pt.2 — Stripe Payments
**C*reating a SaaS solution is fun, but creating a payment system can be a minefield. In this article, I demonstrate how…m*edium.com

Some popular APIs from Rapid API Top 100:

URL Shortener ServiceLearn More
Investors Exchange (IEX) TradingLearn More
Crime DataLearn More
Youtube To Mp3 DownloadLearn More
Web SearchLearn More
JokeAPILearn More
GeniusLearn More
Crypto Asset TickersLearn More

Thanks for reading

I hope you have enjoyed this article. If you like the style, check out T3chFlicks.org for more tech-focused educational content (YouTube, Instagram, Facebook, Twitter).

The full code for this project can be found here ☁️

The live demo can be found here 💽

Resources:

Top comments (0)