DEV Community

Cover image for Building a serverless connected BBQ as SaaS - Part 2 - User Creation
Jimmy Dahlqvist for AWS Community Builders

Posted on • Originally published at jimmydqv.com

Building a serverless connected BBQ as SaaS - Part 2 - User Creation

The time has come for Part 2 in the series of creating a Serverless Connected BBQ as SaaS. In this second post we'll look into user creation, authentication, and authorization. We'll setup idP using Cognito User Pool, and create an event-driven system to store data in our user service. We'll also start building out the frontend part so users can interact with the solution.

If you have not already checked it out, here is part 1.

User in a SaaS

In the connected BBQ IoT SaaS solution, I have opted in for a user management strategy that ensure secure and efficient handling of user data. We leverage AWS Cognito User Pools, enhanced with custom attributes to store tenant-specific information, coupled with DynamoDB for external metadata storage. This approach streamlines user authentication and authorization and maintains the scalability and flexibility needed.

Single User Pool with Custom Attributes

Our primary strategy involves using a single Cognito User Pool, enriched with custom attributes to capture tenant information. Each user is assigned attributes that identify their tenant, enabling the system to differentiate and manage users across various organizations within the same pool. This approach simplifies user management by centralizing all users in one pool while still allowing for tenant-specific operations.

External Metadata Storage

To complement our user pool strategy, we store metadata about users in an external DynamoDB table. This can include information such as user preferences, and additional tenant-specific data that might not be suitable for storage within Cognito. This also enables a easy listing of users per tenant, and a quick way to fetch and display user information, instead of querying Cognito. In this solution users will update information in the user service, that stores it in DynamoDB, changes are then reflected into Cognito.

One User Pool per Tenant

Another common approach in SaaS user management is to use one Cognito User Pool per tenant. This method provides a very strong isolation between tenants, simplifying access control and data segregation.

Thoughts

By using a single user pool with custom attributes and external metadata storage, we have a balanced approach that combines the advantages of centralized management and flexible.

Architecture Overview

We'll create two parts when it comes to user management, the idP which consists of Cognito User Pool and a user service that will be storing user information and relationships. When a user sign up for our solution the user pool will invoke a Lambda function when the user has been confirmed Post Confirmation. The function will put a an event on the application event-bus that a user was created. The user service will react on this event and store information about the user in a DynamoDB table. User service ends by posting a new event on the bus saying a new user was created.

Image showing architecture overview

We will also start creating our dashboard, which is a React application. We'll let users sign up for our solution, login / logout, and see some basic information about their profile.

Create EventBridge

We will use the event-bus design with a single central bus, this design pattern is a good start which makes it easy to expand with more services, and in a later stage maybe move to a multi-bus approach. Starting with a single central bus setup is normally what I recommend. So let's introduce our common stack that will contain our centrally managed resources.

AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Connected BBQ Application Common Infra
Parameters:
  Application:
    Type: String
    Description: Name of owning application
    Default: bbq-iot

Resources:
  EventBridgeBus:
    Type: AWS::Events::EventBus
    Properties:
      Name: !Sub ${Application}-application-eventbus
      Tags:
        - Key: Application
          Value: !Ref Application

Outputs:
  EventBridgeName:
    Description: The EventBus Name
    Value: !Ref EventBridgeBus
    Export:
      Name: !Sub ${AWS::StackName}:eventbridge-bus-name
  EventBridgeArn:
    Description: The EventBus ARN
    Value: !GetAtt EventBridgeBus.Arn
    Export:
      Name: !Sub ${AWS::StackName}:eventbridge-bus-arn
Enter fullscreen mode Exit fullscreen mode

Create idP setup

First of all we need to create our idP, for this we use Cognito User Pool. E-mail will be used as username, which also need to be verified. Password policy is created and also a schema where the user need to specify e-mail and name, in the schema we also add a field tenant that will be populated by our system.

When a user sign up, e-mail, password, and name will be added by the user. Cognito will then validate the e-mail and when that is done a Lambda function will be invoked that adds a message on the event-bus.

Image showing signup flow

So let's start by creating the User Pool

AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Connected BBQ Application idP setup Authentication
Parameters:
  ApplicationName:
    Type: String
    Description: The application that owns this setup.
  HostedAuthDomainPrefix:
    Type: String
    Description: The domain prefix to use for the UserPool hosted UI <HostedAuthDomainPrefix>.auth.[region].amazoncognito.com
  CommonStackName:
    Type: String
    Description: The name of the common stack that contains the EventBridge Bus and more

Resources:
  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Sub ${ApplicationName}-user-pool
      UsernameConfiguration:
        CaseSensitive: false
      UsernameAttributes:
        - "email"
      AutoVerifiedAttributes:
        - email
      Policies:
        PasswordPolicy:
          MinimumLength: 12
          RequireLowercase: true
          RequireUppercase: true
          RequireNumbers: true
          RequireSymbols: true
      AccountRecoverySetting:
        RecoveryMechanisms:
          - Name: "verified_email"
            Priority: 1
          - Name: "verified_phone_number"
            Priority: 2
      Schema:
        - Name: email
          AttributeDataType: String
          Mutable: false
          Required: true
        - Name: name
          AttributeDataType: String
          Mutable: true
          Required: true
        - Name: tenant
          AttributeDataType: String
          DeveloperOnlyAttribute: true
          Mutable: true
          Required: false
Enter fullscreen mode Exit fullscreen mode

To be able to interact with the User Pool from our Webb application we also need to create a User Pool Client. In the webb application we will use Amplify and Amplify UI for user sign up and sign in. For this to work properly it's important that we don't generate an secret, as that will then block Amplify UI. So we need GenerateSecret: False set. Now let's add the client to the template from before.

  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      GenerateSecret: False
      AllowedOAuthFlowsUserPoolClient: true
      CallbackURLs:
        - http://localhost:3000
        #- !Sub https://${DomainName}/signin
      AllowedOAuthFlows:
        - code
        - implicit
      AllowedOAuthScopes:
        - phone
        - email
        - openid
        - profile
      SupportedIdentityProviders:
        - COGNITO
Enter fullscreen mode Exit fullscreen mode

The final part is to add the Lambda function for the post confirmation hook and integrate that with the User Pool. When posting a event to the event-bus we will use the metadata / data pattern.

{
        "metadata": {
            "domain": "idp",
            "application": "application_name",
            "event_type": "signup",
            "version": "1.0",
        },
        "data": {
            "email": "user e-mail",
            "userName": "user name",
            "name": "name",
            "verified": "verified",
            "status": "status",
        },
    }
Enter fullscreen mode Exit fullscreen mode

Now let's add the Lambda function to the template and set the User Pool to call it. We also need to add Lambda Permission so the User Pool is allowed to invoke the function.

  PostSignUpHook:
    Type: AWS::Serverless::Function
    Properties:
      AutoPublishAlias: "true"
      CodeUri: ./PostSignUpLambda
      Handler: hook.handler
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - EventBridgePutEventsPolicy:
            EventBusName: 
              Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
      Environment:
        Variables:
          EventBusName: 
            Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
          ApplicationName: !Ref ApplicationName

  PostSignUpHookPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt PostSignUpHook.Arn
      Principal: cognito-idp.amazonaws.com

  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      .....
      LambdaConfig:
        PostConfirmation: !GetAtt PostSignUpHook.Arn
Enter fullscreen mode Exit fullscreen mode

The code for the Lambda function is not that complicated, it will just post a message to the event-bus.

import boto3
import os
import json


def handler(event, context):
    application_name = os.environ["ApplicationName"]
    event_bus = os.environ["EventBusName"]
    event_bus_client = boto3.client("events")

    user_event = {
        "metadata": {
            "domain": "idp",
            "application": application_name,
            "event_type": "signup",
            "version": "1.0",
        },
        "data": {
            "email": event["request"]["userAttributes"]["email"],
            "userName": event["userName"],
            "name": event["request"]["userAttributes"]["name"],
            "verified": event["request"]["userAttributes"]["email_verified"],
            "status": event["request"]["userAttributes"]["cognito:user_status"],
        },
    }

    response = event_bus_client.put_events(
        Entries=[
            {
                "Source": f"{application_name}.idp",
                "DetailType": "signup",
                "Detail": json.dumps(user_event),
                "EventBusName": event_bus,
            },
        ]
    )

    return event
Enter fullscreen mode Exit fullscreen mode

With that created the sign up flow for the User Pool is completed.

Create User Service

The next part in the user handling is the User Service that will be used to store additional metadata about the users in the system. It will also be a crucial part in the permission and data isolation, that will be discussed in later parts.

When a user has signed up, we like to react on the event sent by the User Pool Lambda integration, and create a user in the user database. When user is stored we send an event about that on the bus for other services to react on.

Image showing user create flow

So lets go ahead and create the state machine and user DynamoDB table.

AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Connected BBQ Application User Service
Parameters:
  ApplicationName:
    Type: String
    Description: Name of owning application
    Default: bbq-iot
  CommonStackName:
    Type: String
    Description: The name of the common stack that contains the EventBridge Bus and more

Resources:
  UserSignUpHookStateMachineLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub ${ApplicationName}/userservice/signuphookstatemachine
      RetentionInDays: 5

  UserSignUpHookExpress:
    Type: AWS::Serverless::StateMachine
    Properties:
      DefinitionUri: statemachine/statemachine.asl.yaml
      Tracing:
        Enabled: true
      Logging:
        Destinations:
          - CloudWatchLogsLogGroup:
              LogGroupArn: !GetAtt UserSignUpHookStateMachineLogGroup.Arn
        IncludeExecutionData: true
        Level: ALL
      DefinitionSubstitutions:
        EventBridgeBusName:
          Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
        UserTable: !Ref UserTable
        ApplicationName: !Ref ApplicationName
      Policies:
        - Statement:
            - Effect: Allow
              Action:
                - logs:*
              Resource: "*"
        - EventBridgePutEventsPolicy:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
        - DynamoDBCrudPolicy:
            TableName: !Ref UserTable
      Events:
        UserSignUp:
          Type: EventBridgeRule
          Properties:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
            Pattern:
              source:
                - !Sub ${ApplicationName}.idp
              detail-type:
                - signup
      Type: EXPRESS

  UserTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub ${ApplicationName}-users
      AttributeDefinitions:
        - AttributeName: userid
          AttributeType: S
      KeySchema:
        - AttributeName: userid
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST
Enter fullscreen mode Exit fullscreen mode

The definition for the state machine is not that complicated.

Comment: User service - User Signup Hook State Machine
StartAt: Debug
States:
  Debug:
    Type: Pass
    Next: Create User
  Create User:
    Type: Task
    Resource: arn:aws:states:::dynamodb:putItem
    Parameters:
      TableName: ${UserTable}
      Item:
        userid:
          S.$: $.detail.data.userName
        name:
          S.$: $.detail.data.name
        email:
          S.$: $.detail.data.email
        status:
          S.$: $.detail.data.status
        verified:
          S.$: $.detail.data.verified
    ResultPath: null
    Next: Post Event
  Post Event:
    Type: Task
    Resource: arn:aws:states:::events:putEvents
    Parameters:
      Entries:
        - Source: ${ApplicationName}.user
          DetailType: created
          Detail.$: $
          EventBusName: ${EventBridgeBusName}
    End: true
Enter fullscreen mode Exit fullscreen mode

Create Dashboard

Let us now start creating our dashboard, that we will continue building on in this series. The dashboard is a react app created with create-react-app. For styling we will use Tailwind CSS.

For user login and signup we will rely on Amplify, so first of all, let's create a small utils class that will check if a user is already logged in.

import { getCurrentUser } from "aws-amplify/auth";

export const isAuthenticated = async () => {
  try {
    await getCurrentUser();
    return true;
  } catch {
    return false;
  }
};
Enter fullscreen mode Exit fullscreen mode

Next let's create our Login page, that we will route users to when they are not logged in.

import React, { useEffect } from "react";
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { isAuthenticated } from "../utils/auth";
import Header from "../components/Header";
import Footer from "../components/Footer";
import { Authenticator } from "@aws-amplify/ui-react";
import "@aws-amplify/ui-react/styles.css";

const Login = () => {
  const navigate = useNavigate();

  useEffect(() => {
    isAuthenticated().then((loggedIn) => {
      if (loggedIn) {
        navigate("/dashboard");
      }
    });
  }, [navigate]);

  return (
    <div className="min-h-screen flex flex-col">
      <Header />
      <main className="flex-grow flex items-center justify-center">
        <Authenticator signUpAttributes={["name"]} loginMechanisms={["email"]}>
          {({ signOut, user }) => (
            <Routes>
              <Route path="/" element={<Navigate replace to="/dashboard" />} />
            </Routes>
          )}
        </Authenticator>
      </main>
      <Footer />
    </div>
  );
};

export default Login;
Enter fullscreen mode Exit fullscreen mode

This will now create a UI and flow like this, which is the Amplify UI for Cognito User Pools.

Image showing login page

To sign up the user click Create Account and fill in e-mail and password, in the next step the e-mail address must be verified.

Image showing login page

After successful login it's possible to view user attributes on the Profile tab, also not that the login button now changes to logout.

Image showing login page

Get the code

The complete setup with all the code is available on Serverless Handbook

Final Words

This was the second part in building a connected BBQ as a SaaS solution. Where we start to create the user sign up and registration using Cognito User Pool.

Check out My serverless Handbook for some of the concepts mentioned in this post.

Don't forget to follow me on LinkedIn and X for more content, and read rest of my Blogs

As Werner says! Now Go Build!

Top comments (0)