Introduction#
GitHub this repository shows
- Build REST APIs with API Gateway and Lambda
- Authentication using Cognito UserPool
- Protect APIs using WAF and CloudFront
- Manage APIs with KEY and usage plan
- Load test with Artellery
- Experiment 429 throttling error
Overall architecture of the app
Exchange cognito id token for credentials
API Gateway#
First create role for the API Gateway which enable logging to CloudWatch Logs
Detail Role
const role = new aws_iam.Role(this, 'RoleForApiGwInvokeLambda', {roleName: 'ApiGwInvokeLambda',assumedBy: new aws_iam.ServicePrincipal('apigateway.amazonaws.com')})role.addToPolicy(new aws_iam.PolicyStatement({effect: aws_iam.Effect.ALLOW,actions: ['lambda:InvokeFunction'],resources: [read_image_ddb_func.functionArn,write_image_ddb_func.functionArn,write_ddb_func.functionArn,read_ddb_func.functionArn,polly_func.functionArn]}))role.addToPolicy(new aws_iam.PolicyStatement({effect: aws_iam.Effect.ALLOW,actions: ['logs:CreateLogGroup','logs:CreateLogStream','logs:DescribeLogGroups','logs:DescribeLogStreams','logs:PutLogEvents','logs:GetLogEvents','logs:FilterLogEvents'],resources: ['*']}))
Then create a API Gateway
// create an api prod stageconst apigw = new aws_apigateway.RestApi(this, 'DevApigwDemo', {restApiName: 'pollyapi',deploy: false,cloudWatchRole: true})// integrate lambda with apigwconst message = apigw.root.addResource('message')const image = apigw.root.addResource('image')const book = apigw.root.addResource('book')const polly_api_resource = apigw.root.addResource('polly')
Integrate the API Gateway with Lambda functions
book.addMethod('GET',new aws_apigateway.LambdaIntegration(test_lambda_func, {proxy: true}),{authorizationType: aws_apigateway.AuthorizationType.COGNITO,authorizer: new aws_apigateway.CognitoUserPoolsAuthorizer(this,'TestAuthorizer',{cognitoUserPools: [userPool]})})message.addMethod('POST',new aws_apigateway.LambdaIntegration(write_ddb_func, {proxy: true,allowTestInvoke: false,credentialsRole: role,integrationResponses: [{statusCode: '200'}]}),{methodResponses: [{ statusCode: '200' }],authorizer: new aws_apigateway.CognitoUserPoolsAuthorizer(this,'messagePostAuthorizer',{cognitoUserPools: [userPool]}),authorizationType: aws_apigateway.AuthorizationType.COGNITO})message.addMethod('GET',new aws_apigateway.LambdaIntegration(read_ddb_func, {credentialsRole: role}),{authorizer: new aws_apigateway.CognitoUserPoolsAuthorizer(this,'messageGetAuthorizer',{cognitoUserPools: [userPool]}),authorizationType: aws_apigateway.AuthorizationType.COGNITO})polly_api_resource.addMethod('POST',new aws_apigateway.LambdaIntegration(polly_func, {proxy: true,allowTestInvoke: false,credentialsRole: role// integrationResponses: [// {// statusCode: "200",// },// ],})// {// methodResponses: [{ statusCode: "200" }],// authorizer: new aws_apigateway.CognitoUserPoolsAuthorizer(// this,// "messagePostAuthorizer",// {// cognitoUserPools: [userPool],// }// ),// authorizationType: aws_apigateway.AuthorizationType.COGNITO,// })image.addMethod('GET',new aws_apigateway.LambdaIntegration(read_image_ddb_func, {credentialsRole: role}),{authorizer: new aws_apigateway.CognitoUserPoolsAuthorizer(this,'ImageGetAuthorizer',{cognitoUserPools: [userPool]}),authorizationType: aws_apigateway.AuthorizationType.COGNITO})image.addMethod('POST',new aws_apigateway.LambdaIntegration(write_image_ddb_func, {proxy: true,allowTestInvoke: false,credentialsRole: role,integrationResponses: [{statusCode: '200'}]}),{methodResponses: [{ statusCode: '200' }],authorizer: new aws_apigateway.CognitoUserPoolsAuthorizer(this,'ImagePostAuthorizer',{cognitoUserPools: [userPool]}),authorizationType: aws_apigateway.AuthorizationType.COGNITO})
CORS enable and preflight
// cors per resourcesmessage.addCorsPreflight({allowOrigins: ['*'],allowMethods: ['GET', 'POST', 'OPTIONS'],allowHeaders: ['*']})image.addCorsPreflight({allowOrigins: ['*'],allowMethods: ['GET', 'POST', 'OPTIONS'],allowHeaders: ['*']})polly_api_resource.addCorsPreflight({allowOrigins: ['*'],allowMethods: ['GET', 'POST', 'OPTIONS'],allowHeaders: ['*']})
Integrate with CloudWatch Log Group
// access log groupconst logGroup = new aws_logs.LogGroup(this, 'AccessLogApi', {logGroupName: 'AccessLogApiDevClass',removalPolicy: RemovalPolicy.DESTROY,retention: RetentionDays.ONE_WEEK})
Finally create deployment stage
const deployment = new aws_apigateway.Deployment(this, 'Deployment', {api: apigw})const prodStage = new aws_apigateway.Stage(this, 'ProdStage', {stageName: 'prod',deployment,dataTraceEnabled: true,accessLogDestination: new aws_apigateway.LogGroupLogDestination(logGroup),accessLogFormat: aws_apigateway.AccessLogFormat.jsonWithStandardFields()})
Cognito Stack#
Let create a cognito userpool
const userPool = new aws_cognito.UserPool(this, 'UserPoolDemo', {userPoolName: 'UserPoolDemo',selfSignUpEnabled: true,signInAliases: {email: true},autoVerify: {email: true},removalPolicy: RemovalPolicy.DESTROY})
Then create a client without secret for client application integration
const clientWithoutSecret = new aws_cognito.UserPoolClient(this,'ClientWithoutSecret',{userPool: userPool,authFlows: {userPassword: true,adminUserPassword: true,custom: true,userSrp: true},userPoolClientName: 'ClientWithoutSecret'})
And create a client with secret for server side application which use cognito hosted ui later on
const clientWithSecret = new aws_cognito.UserPoolClient(this,'ClientWithSecret',{userPool: userPool,authFlows: {userPassword: true,adminUserPassword: true,custom: true,userSrp: true},userPoolClientName: 'ClientWithSecret',generateSecret: true,oAuth: {flows: {authorizationCodeGrant: true},callbackUrls: props.callbackUrls,logoutUrls: props.logoutUrls,scopes: [aws_cognito.OAuthScope.EMAIL,aws_cognito.OAuthScope.OPENID,aws_cognito.OAuthScope.PHONE]}})
Create a domain for cognito hosted ui
const domain = userPool.addDomain('domain', {cognitoDomain: {domainPrefix: 'tech-sharing'}})domain.signInUrl(clientWithSecret, {redirectUri: ''})
Finally create a identity pool and associate with the two userpools above
const identityPool = new IdentityPool(this, 'IdentityPoolDemo', {identityPoolName: 'IdentityPoolDemo',authenticationProviders: {userPools: [new UserPoolAuthenticationProvider({userPool,userPoolClient: clientWithSecret}),new UserPoolAuthenticationProvider({userPool,userPoolClient: clientWithoutSecret})]}})
Create a S3 bucket and grant permission to the identity pool
const bucket = new aws_s3.Bucket(this, 'CognitoDemoBucket', {bucketName: `cognito-demo-bucket-${this.account}-1`,removalPolicy: RemovalPolicy.DESTROY,// so webapp runnning local host can access s3cors: [{allowedHeaders: ['*'],allowedMethods: [aws_s3.HttpMethods.GET,aws_s3.HttpMethods.PUT,aws_s3.HttpMethods.DELETE,aws_s3.HttpMethods.POST],allowedOrigins: ['*'],exposedHeaders: ['x-amz-server-side-encryption','x-amz-request-id','x-amz-id-2','ETag'],maxAge: 3000}]})
Grant permissions
// this identity pool can access s3bucket.grantReadWrite(identityPool.authenticatedRole)bucket.grantRead(identityPool.authenticatedRole)
WAF Stack#
Let integrate WAF to protect API endpoints
import { v4 as uuidv4 } from 'uuid'import { aws_wafv2, Stack, StackProps } from 'aws-cdk-lib'import { Construct } from 'constructs'interface WafApigwProps extends StackProps {resourceArns: string[]}export class WafApigwStack extends Stack {constructor(scope: Construct, id: string, props: WafApigwProps) {super(scope, id, props)/*** 1. AWS managed WAF rule* block IP addresses typically associated with bots* from Amazon internal threat intelligence*/const awsMangedRuleIPReputationList: aws_wafv2.CfnWebACL.RuleProperty = {name: 'AWSManagedRulesCommonRuleSet',priority: 10,statement: {managedRuleGroupStatement: {name: 'AWSManagedRulesCommonRuleSet',vendorName: 'AWS'}},overrideAction: { none: {} },visibilityConfig: {sampledRequestsEnabled: true,cloudWatchMetricsEnabled: true,metricName: 'AWSIPReputationList'}}/*** 2. Geo restrict rule. Block from a list.*/const ruleGeoRestrict: aws_wafv2.CfnWebACL.RuleProperty = {name: 'RuleGeoRestrict',priority: 2,action: {block: {}},statement: {geoMatchStatement: {countryCodes: ['SG']}},visibilityConfig: {sampledRequestsEnabled: true,cloudWatchMetricsEnabled: true,metricName: 'GeoMatch'}}/*** 3. Rate limite rule. in five-minute period,* if number of requests over the limit 100,* block the IP.*/const ruleLimiteRequestsThreshold: aws_wafv2.CfnWebACL.RuleProperty = {name: 'LimiteRequestsThreshold',priority: 1,action: {block: {}},statement: {// 2000 requests within 5 minutesrateBasedStatement: {limit: 2000,aggregateKeyType: 'IP'}},visibilityConfig: {sampledRequestsEnabled: true,cloudWatchMetricsEnabled: true,metricName: 'LimitRequestsThreshold'}}// Push rules into ACLconst webAcl = new aws_wafv2.CfnWebACL(this, 'WafToProtectApigwDemo', {defaultAction: { allow: {} },// scope: "CLOUDFRONT",scope: 'REGIONAL',visibilityConfig: {cloudWatchMetricsEnabled: true,metricName: 'waf-regional-apigw',sampledRequestsEnabled: true},description: 'WAFv2 ACL for CloudFront',name: 'WafToProtectApigwDemo',// push all rules into an ACLrules: [awsMangedRuleIPReputationList,ruleLimiteRequestsThreshold,ruleGeoRestrict]})//props.resourceArns.map(arn => {new aws_wafv2.CfnWebACLAssociation(this, `WafProtectApi-${uuidv4()}`, {resourceArn: arn,webAclArn: webAcl.attrArn})})}}
Cognito Client#
Here are some functions using cognito client to sign up, confirm, log in and exchange token for credentials
Cognito Client
// cognito userpool and identity pool// haimtran 10/10/2023// 1. create a cognito userpool// 2. signup a user// 3. setup password// 4. get credentials for guest and auth usersimport { config } from './config'import {PutObjectCommand,GetObjectCommand,S3Client} from '@aws-sdk/client-s3'import {CognitoIdentityProviderClient,AdminSetUserPasswordCommand,ConfirmSignUpCommand,InitiateAuthCommand,SignUpCommand} from '@aws-sdk/client-cognito-identity-provider'import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers'import { decode } from 'next-auth/jwt'import { CognitoJwtVerifier } from 'aws-jwt-verify'import axios from 'axios'const cognitoClient = new CognitoIdentityProviderClient({region: config.REGION})const signUp = async (username: string, password: string) => {try {const response = await cognitoClient.send(new SignUpCommand({ClientId: config.CLIENT_ID,Username: username,Password: password}))console.log(response)} catch (error) {console.log(error)}}const confirmSignUp = async (username: string, code: string) => {try {const response = await cognitoClient.send(new ConfirmSignUpCommand({ClientId: config.CLIENT_ID,Username: username,ConfirmationCode: code}))} catch (error) {console.log(error)}}const setPass = async () => {const response = await cognitoClient.send(new AdminSetUserPasswordCommand({Password: 'Demo@2023',Username: '6a7533f5-ea77-462e-a8b0-07d34a1c238b',UserPoolId: config.USER_POOL_ID,Permanent: true}))console.log(response)}export const signIn = async (username: string, password: string) => {try {const response = await cognitoClient.send(new InitiateAuthCommand({AuthFlow: 'USER_PASSWORD_AUTH',AuthParameters: {USERNAME: username,PASSWORD: password},ClientId: config.CLIENT_ID}))console.log('cognito auth: ', response)return response} catch (error) {console.log(error)return null}}// for authenticated user and guest userconst getCredentials = async (username: string, password: string) => {// login and get idtokenconst response = await cognitoClient.send(new InitiateAuthCommand({AuthFlow: 'USER_PASSWORD_AUTH',AuthParameters: {USERNAME: username,PASSWORD: password},ClientId: config.CLIENT_ID}))console.log(response)const IdToken = response['AuthenticationResult']!['IdToken'] as stringconst cognitoPoolId = config.COGNITO_POOL_ID// exchange toke for credentialsconst credentials = fromCognitoIdentityPool({clientConfig: { region: config.REGION },identityPoolId: config.IDENTITY_POOL_ID as string// should not specify for guest users// logins: {// [config.COGNITO_POOL_ID]: IdToken,// },})const retrievs = await credentials.call(this)console.log(retrievs)}const decodeToken = async () => {// const token = "";// const response = (await decode({// token: token,// secret: "b10cda68fe67233283a06a30a76eb161",// })) as any;// console.log(response.id_token);// const idToken = response.id_token;const idToken = ''const verifier = CognitoJwtVerifier.create({userPoolId: config.USER_POOL_ID,tokenUse: 'id',clientId: config.CLIENT_ID})try {const payload = await verifier.verify(idToken, {tokenUse: 'id',clientId: config.CLIENT_ID})console.log('Token is valid. Payload:', payload)} catch {console.log('Token not valid!')}}const getImages = async () => {const token = ''const { data, status } = await axios.get(config.API_URL_IMAGE, {headers: {Authorization: `Bearer ${token}`,'Content-Type': 'application/json'}})console.log(data)}const KEY = ''const ENDPOINT = ''const testApiKey = async () => {const { data, status } = await axios.get(ENDPOINT, {headers: {'x-api-key': KEY}})console.log(data)console.log(status)}const loadTestApi = async () => {for (let i = 0; i < 100; i++) {const { data, status } = await axios.get(ENDPOINT, {headers: {'x-api-key': KEY}})console.log(data)}}// loadTestApi();// testApiKey();// getImages();// decodeToken();// getCredentials("htranminhhai20@gmail.com", "Demo@2023");// setPass();// signIn("hai@entest.io", "Demo@2023");// signUp("demo@entest.io", "Demo@2023");// confirmSignUp("demo@entest.io", "777502");
API Gateway and Cognito#
Let write simple api request with id token from cognito
const token = ''const { data, status } = await axios.get(config.API_URL_IMAGE, {headers: {Authorization: `Bearer ${token}`,'Content-Type': 'application/json'}})
Exchange id token for credentials
const getCredentials = async (username: string, password: string) => {// login and get idtokenconst response = await cognitoClient.send(new InitiateAuthCommand({AuthFlow: 'USER_PASSWORD_AUTH',AuthParameters: {USERNAME: username,PASSWORD: password},ClientId: config.CLIENT_ID}))console.log(response)const IdToken = response['AuthenticationResult']!['IdToken'] as stringconst cognitoPoolId = config.COGNITO_POOL_ID// exchange toke for credentialsconst credentials = fromCognitoIdentityPool({clientConfig: { region: config.REGION },identityPoolId: config.IDENTITY_POOL_ID as string// should not specify for guest users// logins: {// [config.COGNITO_POOL_ID]: IdToken,// },})const retrievs = await credentials.call(this)console.log(retrievs)}
API Key and Usage Plan#
- create api key
- create usage plan
- associate the api key with the usage plan
- enable KEY in api resource and deploy to the sage
- call api with KEY in the header
Use curl with x-api-key in the header
curl -H "x-api-key: XSHqSZ7RfB8ENqmwOcXBB8SybyFeqrB21CBXRMP5" https://sf9awstayc.execute-api.us-east-1.amazonaws.com/prod/book
Use axios and typescript
const testApiKey = async () => {const { data, status } = await axios.get(ENDPOINT, {headers: {"x-api-key": KEY,},});
Then we can send multiple requests in parallel
const loadTestApi = async () => {for (let i = 0; i < 100; i++) {const { data, status } = await axios.get(ENDPOINT, {headers: {'x-api-key': KEY}})console.log(data)}}
Use Aterllery tool to test the api endpoint
npm install -g artillery
Run a very simple test
artillery quick -n 2100 --count 10 ENDPOINT
API Load Test#
Let write a simple python script with threadpool to test the api endpoint
import timefrom concurrent.futures import ThreadPoolExecutorimport boto3import requestsimport jsonNUM_CONCUR_REQUEST = 101KEY = ""ENDPOINT = ""def send_request(id: int):"""send get request"""print(f'send request {id}')response = requests.get(url=ENDPOINT)print(f'response from request {id}')print(response)print(response.status_code)print(response.text)def sequential_request():for k in range(1001):send_request(k)def load_test():"""load test"""with ThreadPoolExecutor(max_workers=NUM_CONCUR_REQUEST) as executor:for k in range(1, NUM_CONCUR_REQUEST):executor.submit(send_request, k)if __name__=="__main__":# send_request(id=1)# load_test()# sequential_request()while True:load_test()time.sleep(5)
Next let write a simple Artellery test scenior. Here duration in seconds and arrival rate 50 means there are 50 users sending 50 request per second.
config:target: https://abc/bookphases:- duration: 10arrivalRate: 50scenarios:- flow:- get:url: '/book'headers:x-api-key: ''
Lambda Invocation Test#
Simple test to see how lambda scale
import timefrom concurrent.futures import ThreadPoolExecutorimport boto3# function nameFUNCTION_NAME = "HelloLambdaTest"# lambda clientlambda_client = boto3.client("lambda")# number of concurrent requestNUM_CONCUR_REQUEST = 100def invoke_lambda(id: int) -> str:"""invoke lambda"""res = lambda_client.invoke(FunctionName=FUNCTION_NAME)print(f'lamda {id} {res["Payload"].read()}')print("\n")return res['Payload'].read()def test_scale_lambda() -> None:"""Test how lambda scale"""with ThreadPoolExecutor(max_workers=NUM_CONCUR_REQUEST) as executor:for k in range(1, NUM_CONCUR_REQUEST):executor.submit(invoke_lambda, k)if __name__ == "__main__":while True:test_scale_lambda()time.sleep(5)
Use Aterllery tool to test the api endpoint
npm install -g artillery
Run a very simple test
artillery quick -n 2100 --count 10 ENDPOINT
Amplify Hosting#
Let create a stack to deploy the GitHub repostiory using AmplifyHosting
import { SecretValue, Stack, StackProps, aws_codebuild } from 'aws-cdk-lib'import { Construct } from 'constructs'import * as Amplify from '@aws-cdk/aws-amplify-alpha'interface AmplifyHostingProps extends StackProps {owner: stringrepository: stringtoken: stringenvVariables: anycommands: any}export class AmplifyHosting extends Stack {constructor(scope: Construct, id: string, props: AmplifyHostingProps) {super(scope, id, props)const amplify = new Amplify.App(this, 'CDKForAamplifyHosting', {sourceCodeProvider: new Amplify.GitHubSourceCodeProvider({owner: props.owner,repository: props.repository,oauthToken: SecretValue.secretsManager(props.token)}),buildSpec: aws_codebuild.BuildSpec.fromObjectToYaml({version: '1.0',frontend: {phases: {preBuild: {commands: ['npm ci']},build: {commands: props.commands}},artifacts: {baseDirectory: '.next',files: ['**/*']},cache: {path: ['node_modules/**/*']}}}),platform: Amplify.Platform.WEB_COMPUTE,environmentVariables: props.envVariables})amplify.addBranch('main', { stage: 'PRODUCTION' })}}
CloudFront#
Let create a CloudFront stack for caching and pre-signed url for protected content
import {CfnOutput,RemovalPolicy,Stack,StackProps,aws_cloudfront,aws_iam,aws_s3} from 'aws-cdk-lib'import { Construct } from 'constructs'interface CloudFrontProps extends StackProps {publicGroupId: string}export class CloudFrontSignStack extends Stack {constructor(scope: Construct, id: string, props: CloudFrontProps) {super(scope, id, props)// create s3 bucketconst bucket = new aws_s3.Bucket(this, 'BucketForCloudFrontSignedUrlDemo', {bucketName: 'bucket-cloudfront-signed-url-demo',// not production recommendedremovalPolicy: RemovalPolicy.DESTROY,// not production recommendedautoDeleteObjects: true,// block public readpublicReadAccess: false,// block public access - production recommendedblockPublicAccess: aws_s3.BlockPublicAccess.BLOCK_ALL})// legacy origin access identityconst oai = new aws_cloudfront.CfnCloudFrontOriginAccessIdentity(this,'OriginAccessIdentityForSignedUrlDemo',{cloudFrontOriginAccessIdentityConfig: {comment: 'demo'}})// new origin access control// const oac = new aws_cloudfront.CfnOriginAccessControl(// this,// "OriginAccessControlForSignedUrlDemo",// {// originAccessControlConfig: {// name: "OriginAccessControlForSignedUrlDemo",// originAccessControlOriginType: "s3",// signingBehavior: "always",// signingProtocol: "sigv4",// },// }// );// cloudfront keygroupconst keygroup = new aws_cloudfront.CfnKeyGroup(this,'CloudFrontKeyGroupDemo',{keyGroupConfig: {items: [props.publicGroupId],name: 'CloudFrontKeyGroupDemo',comment: 'demo keygroup to generate signed url'}})// create cloudfront districutionconst dist = new aws_cloudfront.CfnDistribution(this,'CloudFrontDistributionSignedUrlDemo',{distributionConfig: {// aliases: [],enabled: true,defaultCacheBehavior: {targetOriginId: bucket.bucketDomainName,viewerProtocolPolicy: 'allow-all',compress: true,forwardedValues: {queryString: false,cookies: {forward: 'none'// whitelistedNames: [""],}}},cacheBehaviors: [{pathPattern: '/private-data/*',targetOriginId: bucket.bucketDomainName,viewerProtocolPolicy: 'allow-all',allowedMethods: ['GET', 'HEAD'],// cachedMethods: [],cachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6',compress: true,trustedKeyGroups: [keygroup.attrId]// trustedSigners: [],}],origins: [{domainName: bucket.bucketDomainName,id: bucket.bucketDomainName,// originAccessControlId: oac.attrId,// originPath: "",// originAccessControlId: "",s3OriginConfig: {originAccessIdentity: `origin-access-identity/cloudfront/${oai.attrId}`}}]// s3Origin: {// dnsName: bucket.bucketDomainName,// originAccessIdentity: oai.ref,// },}})// s3 bucket policy grant oac accessbucket.addToResourcePolicy(new aws_iam.PolicyStatement({actions: ['s3:GetObject', 's3:PutObject'],resources: [bucket.arnForObjects('*')],principals: [// new aws_iam.CanonicalUserPrincipal(// cloudfrontOAI.cloudFrontOriginAccessIdentityS3CanonicalUserId// ),// "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E2DWROBFVR8YDR"new aws_iam.ArnPrincipal(`arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${oai.attrId}`)// new aws_iam.ServicePrincipal("cloudfront.amazonaws.com"),]// conditions: {// StringEquals: {// "AWS:SourceArn": `arn:aws:cloudfront::${this.account}:distribution/${dist.attrId}`,// },// },}))// dist.addDependency(oai);new CfnOutput(this, 'OAIIdentity', {exportName: 'OAIIdentity',value: oai.attrId})}}