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.
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
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.
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
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
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",
},
}
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
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
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.
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
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
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;
}
};
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;
This will now create a UI and flow like this, which is the Amplify UI for Cognito User Pools.
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.
After successful login it's possible to view user attributes on the Profile
tab, also not that the login button now changes to logout.
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!