├── .gitattributes ├── docs ├── multi-region.png └── single-region.png ├── src ├── utils │ └── response.js └── lambdas │ ├── decode │ └── handler.js │ ├── login │ └── handler.js │ └── register │ └── handler.js ├── .github └── workflows │ └── main.yml ├── LICENSE ├── package.json ├── .gitignore ├── CONTRIBUTING.md ├── serverless.yml └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /docs/multi-region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GagePielsticker/AWS-Auth-Template/HEAD/docs/multi-region.png -------------------------------------------------------------------------------- /docs/single-region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GagePielsticker/AWS-Auth-Template/HEAD/docs/single-region.png -------------------------------------------------------------------------------- /src/utils/response.js: -------------------------------------------------------------------------------- 1 | 2 | function formatResponse (data, stat) { 3 | let statusCode 4 | 5 | if (!stat) statusCode = 200 6 | else statusCode = stat 7 | 8 | return { 9 | statusCode, 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | 'Access-Control-Allow-Origin':'*' 13 | }, 14 | body: JSON.stringify( 15 | { 16 | region: process.env.AWS_REGION, 17 | data 18 | } 19 | ) 20 | } 21 | } 22 | 23 | module.exports = { 24 | formatResponse 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Deployer 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # Change this to your desired branch name 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: '16' # Change this to your desired Node.js version 20 | 21 | - name: Configure AWS Credentials 22 | uses: aws-actions/configure-aws-credentials@v2 23 | with: 24 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 25 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 26 | aws-region: us-east-1 27 | 28 | - name: Install dependencies with pnpm 29 | run: npm i 30 | 31 | - name: Run npm deploy 32 | run: npm run deploy -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 James Gage Pielsticker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lambdas/decode/handler.js: -------------------------------------------------------------------------------- 1 | /* Coldstart dependency loading */ 2 | const { formatResponse } = require('../../utils/response') 3 | const { string, object } = require('yup') 4 | const jwt = require('jsonwebtoken') 5 | 6 | /* Invoke handler */ 7 | exports.handler = async (event, context, callback) => { 8 | const params = event?.queryStringParameters 9 | const body = JSON.parse(event.body) 10 | console.log(`Lambda Invoked with params:\n${params ? JSON.stringify(params, null, 4) : 'NONE'}`) 11 | 12 | // Input Validation 13 | const inputSchema = object({ 14 | jwt: string() 15 | }) 16 | 17 | try { 18 | await inputSchema.validate(body) 19 | } catch (error) { 20 | console.log(`Error validating input :: ${error}.`) 21 | return callback(null, formatResponse({ error: `Invalid Input. ${error}` }, 409)) 22 | } 23 | 24 | // Decode our JWT 25 | try { 26 | var decodedToken = jwt.verify(body.jwt, process.env.JWT_KEY) 27 | } catch (error) { 28 | console.log(`Invalid JWT :: ${error}`) 29 | return callback(null, formatResponse({ error: 'Could not validate JWT token.' }, 403)) 30 | } 31 | 32 | // Return data 33 | const data = { 34 | status: 'Successfully validated JWT.', 35 | decodedJWT: decodedToken 36 | } 37 | 38 | return callback(null, formatResponse(data)) 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-auth-template", 3 | "version": "1.0.0", 4 | "description": "A serverless template created as an example of user signup & JWT authentication in AWS using DynamoDB, Lambdas, WAFv2, and API-Gateway", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "serverless package", 8 | "deploy": "serverless deploy --stage prod --region us-east-1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/GagePielsticker/AWS-Auth-Template.git" 13 | }, 14 | "keywords": [ 15 | "aws", 16 | "serverless", 17 | "api", 18 | "service", 19 | "template", 20 | "boilerplate", 21 | "jwt", 22 | "jwt-tokens" 23 | ], 24 | "author": "James Gage Pielsticker", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/GagePielsticker/AWS-Auth-Template/issues" 28 | }, 29 | "homepage": "https://github.com/GagePielsticker/AWS-Auth-Template#readme", 30 | "devDependencies": { 31 | "serverless": "^3.30.0", 32 | "serverless-associate-waf": "^1.2.1", 33 | "serverless-deployment-bucket": "^1.6.0", 34 | "serverless-offline": "^12.0.4", 35 | "standard": "^17.0.0" 36 | }, 37 | "dependencies": { 38 | "aws-sdk": "^2.1428.0", 39 | "bcryptjs": "^2.4.3", 40 | "jsonwebtoken": "^9.0.1", 41 | "uuid": "^9.0.0", 42 | "yup": "^1.2.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Serverless directories 108 | .serverless/ 109 | 110 | # FuseBox cache 111 | .fusebox/ 112 | 113 | # DynamoDB Local files 114 | .dynamodb/ 115 | 116 | # TernJS port file 117 | .tern-port 118 | 119 | # Stores VSCode versions used for testing VSCode extensions 120 | .vscode-test 121 | 122 | # yarn v2 123 | .yarn/cache 124 | .yarn/unplugged 125 | .yarn/build-state.yml 126 | .yarn/install-state.gz 127 | .pnp.* 128 | -------------------------------------------------------------------------------- /src/lambdas/login/handler.js: -------------------------------------------------------------------------------- 1 | /* Coldstart dependency loading */ 2 | const { formatResponse } = require('../../utils/response') 3 | 4 | const { DynamoDBClient, QueryCommand } = require('@aws-sdk/client-dynamodb') 5 | const { marshall, unmarshall } = require('@aws-sdk/util-dynamodb') 6 | const dbClient = new DynamoDBClient({ region: process.env.AWS_REGION }) 7 | 8 | const { string, object } = require('yup') 9 | 10 | const bcrypt = require('bcryptjs') 11 | const jwt = require('jsonwebtoken') 12 | 13 | /* Invoke handler */ 14 | exports.handler = async (event, context, callback) => { 15 | const params = event?.queryStringParameters 16 | const body = JSON.parse(event.body) 17 | console.log(`Lambda Invoked with params:\n${params ? JSON.stringify(params, null, 4) : 'NONE'}`) 18 | 19 | // Input Validation 20 | const inputSchema = object({ 21 | email: string().email(), 22 | password: string() 23 | }) 24 | 25 | try { 26 | await inputSchema.validate(body) 27 | } catch (error) { 28 | console.log(`Error validating input :: ${error}.`) 29 | return callback(null, formatResponse({ error: `Invalid Input. ${error}` }, 409)) 30 | } 31 | 32 | body.email = body.email.toLowerCase() // Change our email to lowercase since we check against it for pre-existing users 33 | 34 | // Get user from database 35 | const dbQuery = { 36 | TableName: process.env.DYNAMO_TABLE_NAME, 37 | IndexName: process.env.USER_INDEX, 38 | KeyConditionExpression: 'email = :email', 39 | ExpressionAttributeValues: marshall({ 40 | ':email': body.email 41 | }) 42 | } 43 | 44 | try { 45 | const queryCommand = new QueryCommand(dbQuery) 46 | const queryResponse = await dbClient.send(queryCommand) 47 | 48 | if (queryResponse.Items && queryResponse.Items.length > 0) { 49 | var userObject = unmarshall(queryResponse.Items[0]) 50 | console.log('Found email in database.') 51 | } else { 52 | return callback(null, formatResponse({ error: 'No user with that email/password combination exist.' }, 403)) 53 | } 54 | } catch (error) { 55 | console.log(`Error checking user in database:: ${error}.`) 56 | return callback(null, formatResponse({ error: 'Internal Service Error.' }, 500)) 57 | } 58 | 59 | // Validate Password Is Correct 60 | try { 61 | const bcryptResult = bcrypt.compareSync(body.password, userObject.password) 62 | if (!bcryptResult) return callback(null, formatResponse({ error: 'No user with that email/password combination exist.' }, 403)) 63 | console.log('validated Password.') 64 | } catch (error) { 65 | console.log(`Error checking password:: ${error}.`) 66 | return callback(null, formatResponse({ error: 'Internal Service Error' }, 500)) 67 | } 68 | 69 | // Create JWT 70 | try { 71 | var userToken = jwt.sign( 72 | { 73 | userid: userObject.userid, 74 | email: userObject.email 75 | }, 76 | process.env.JWT_KEY, // Random string our app will encrypt/decrypt jwt with. Technically sensitive so should be in secrets manager and auto-rotate. 77 | { 78 | expiresIn: process.env.JWT_EXPIRY 79 | } 80 | ) 81 | console.log('Successfully generated JWT.') 82 | } catch (error) { 83 | console.log(`Error when generating JWT :: ${error}.`) 84 | return callback(null, formatResponse({ error: 'Internal Service Error.' }, 500)) 85 | } 86 | 87 | // Return data 88 | const data = { 89 | status: 'Successfully logged in.', 90 | jwt: `${userToken}` 91 | } 92 | 93 | return callback(null, formatResponse(data)) 94 | } 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure that you are following [StandardJS](https://standardjs.com/) principles. This can be quickly achieved running `standard --fix` 11 | 2. Increase the version numbers in any examples files and the README.md to the new version that this 12 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 13 | 3. All Pull Request will be merged by myself. 14 | 15 | ## Code of Conduct 16 | 17 | ### Our Pledge 18 | 19 | In the interest of fostering an open and welcoming environment, we as 20 | contributors and maintainers pledge to making participation in our project and 21 | our community a harassment-free experience for everyone, regardless of age, body 22 | size, disability, ethnicity, gender identity and expression, level of experience, 23 | nationality, personal appearance, race, religion, or sexual identity and 24 | orientation. 25 | 26 | ### Our Standards 27 | 28 | Examples of behavior that contributes to creating a positive environment 29 | include: 30 | 31 | * Using welcoming and inclusive language 32 | * Being respectful of differing viewpoints and experiences 33 | * Gracefully accepting constructive criticism 34 | * Focusing on what is best for the community 35 | * Showing empathy towards other community members 36 | 37 | Examples of unacceptable behavior by participants include: 38 | 39 | * The use of sexualized language or imagery and unwelcome sexual attention or 40 | advances 41 | * Trolling, insulting/derogatory comments, and personal or political attacks 42 | * Public or private harassment 43 | * Publishing others' private information, such as a physical or electronic 44 | address, without explicit permission 45 | * Other conduct which could reasonably be considered inappropriate in a 46 | professional setting 47 | 48 | ### Our Responsibilities 49 | 50 | Project maintainers are responsible for clarifying the standards of acceptable 51 | behavior and are expected to take appropriate and fair corrective action in 52 | response to any instances of unacceptable behavior. 53 | 54 | Project maintainers have the right and responsibility to remove, edit, or 55 | reject comments, commits, code, wiki edits, issues, and other contributions 56 | that are not aligned to this Code of Conduct, or to ban temporarily or 57 | permanently any contributor for other behaviors that they deem inappropriate, 58 | threatening, offensive, or harmful. 59 | 60 | ### Scope 61 | 62 | This Code of Conduct applies both within project spaces and in public spaces 63 | when an individual is representing the project or its community. Examples of 64 | representing a project or community include using an official project e-mail 65 | address, posting via an official social media account, or acting as an appointed 66 | representative at an online or offline event. Representation of a project may be 67 | further defined and clarified by project maintainers. 68 | 69 | ### Enforcement 70 | 71 | All 72 | complaints will be reviewed and investigated and will result in a response that 73 | is deemed necessary and appropriate to the circumstances. The project team is 74 | obligated to maintain confidentiality with regard to the reporter of an incident. 75 | Further details of specific enforcement policies may be posted separately. 76 | 77 | Project maintainers who do not follow or enforce the Code of Conduct in good 78 | faith may face temporary or permanent repercussions as determined by other 79 | members of the project's leadership. 80 | 81 | ### Attribution 82 | 83 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 84 | available at [http://contributor-covenant.org/version/1/4][version] 85 | 86 | [homepage]: http://contributor-covenant.org 87 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /src/lambdas/register/handler.js: -------------------------------------------------------------------------------- 1 | /* Coldstart dependency loading */ 2 | const { formatResponse } = require('../../utils/response') 3 | 4 | const { DynamoDBClient, PutItemCommand, QueryCommand } = require('@aws-sdk/client-dynamodb') 5 | const { marshall } = require('@aws-sdk/util-dynamodb') 6 | const dbClient = new DynamoDBClient({ region: process.env.AWS_REGION }) 7 | 8 | const { v4: uuidv4 } = require('uuid') 9 | const bcrypt = require('bcryptjs') 10 | const { string, object } = require('yup') 11 | 12 | const jwt = require('jsonwebtoken') 13 | 14 | /* Invoke handler */ 15 | exports.handler = async (event, context, callback) => { 16 | const params = event?.queryStringParameters 17 | const body = JSON.parse(event.body) 18 | console.log(`Lambda Invoked with params:\n${params ? JSON.stringify(params, null, 4) : 'NONE'}`) 19 | 20 | // Input Validation 21 | const usernameMaxLength = 20 22 | 23 | const inputSchema = object({ 24 | email: string().email(), 25 | username: string().max(20), 26 | password: string() 27 | }) 28 | 29 | try { 30 | await inputSchema.validate(body) 31 | } catch (error) { 32 | console.log(`Error validating input :: ${error}.`) 33 | return callback(null, formatResponse({ error: `Invalid Input. ${error}` }, 409)) 34 | } 35 | 36 | body.email = body.email.toLowerCase() // Change our email to lowercase since we check against it for pre-existing users 37 | 38 | // Validate email doesnt already exist in database 39 | const dbQuery = { 40 | TableName: process.env.DYNAMO_TABLE_NAME, 41 | IndexName: process.env.USER_INDEX, 42 | KeyConditionExpression: 'email = :email', 43 | ExpressionAttributeValues: marshall({ 44 | ':email': body.email 45 | }) 46 | } 47 | 48 | try { 49 | const queryCommand = new QueryCommand(dbQuery) 50 | const queryResponse = await dbClient.send(queryCommand) 51 | 52 | if (queryResponse.Items && queryResponse.Items.length > 0) { 53 | return callback(null, formatResponse({ error: 'User with this email already exist.' }, 409)) 54 | } 55 | } catch (error) { 56 | console.log(`Error checking user in database:: ${error}.`) 57 | return callback(null, formatResponse({ error: 'Internal Service Error.' }, 500)) 58 | } 59 | 60 | // Hash our password 61 | try { 62 | var passwordHash = await bcrypt.hash(body.password, 10) 63 | console.log('Password Successfully Hashed.') 64 | } catch (error) { 65 | console.log(`Error hashing password :: ${error}.`) 66 | return callback(null, formatResponse({ error: 'Internal Service Error.' }, 500)) 67 | } 68 | 69 | // Create DynamoDB Object 70 | const dbInput = { 71 | TableName: process.env.DYNAMO_TABLE_NAME, 72 | Item: { 73 | userid: { S: uuidv4() }, // primary key (unchangeable) 74 | email: { S: body.email }, // secondary index key 75 | username: { S: body.username }, 76 | password: { S: passwordHash }, 77 | createdOn: { N: `${+new Date()}` } // You must send numbers to Dynamo as strings, however dynamo will treat it as a number for maths 78 | } 79 | } 80 | 81 | // Insert into DynamoDB 82 | try { 83 | const putCommand = new PutItemCommand(dbInput) 84 | await dbClient.send(putCommand) 85 | console.log(`Successfully added user ${body.username} to database.`) 86 | } catch (error) { 87 | console.log(`Error when adding user to database :: ${error}.`) 88 | return callback(null, formatResponse({ error: 'Internal Service Error.' }, 500)) 89 | } 90 | 91 | // Generate our JWT to send to the user 92 | try { 93 | var userToken = jwt.sign( 94 | { 95 | userid: dbInput.Item.userid.S, 96 | email: dbInput.Item.email.S 97 | }, 98 | process.env.JWT_KEY, // Random string our app will encrypt/decrypt jwt with. Technically sensitive so should be in secrets manager and auto-rotate. 99 | { 100 | expiresIn: process.env.JWT_EXPIRY 101 | } 102 | ) 103 | console.log('Successfully generated JWT.') 104 | } catch (error) { 105 | console.log(`Error when generating JWT :: ${error}.`) 106 | return callback(null, formatResponse({ error: 'Internal Service Error.' }, 500)) 107 | } 108 | 109 | // Form Response 110 | const data = { 111 | status: 'Successfully created user!', 112 | jwt: `${userToken}` 113 | } 114 | 115 | // Send response 116 | return callback(null, formatResponse(data)) 117 | } 118 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: auth-service 2 | 3 | ################### 4 | # General Configuration 5 | ################### 6 | 7 | plugins: 8 | - serverless-offline 9 | - serverless-deployment-bucket 10 | - serverless-associate-waf 11 | 12 | provider: 13 | 14 | ### General Deployment Settings 15 | name: aws 16 | stage: ${opt:stage, 'dev'} 17 | region: ${opt:region, 'us-east-1'} 18 | 19 | stackTags: 20 | project: ${self:service} 21 | tags: 22 | project: ${self:service} 23 | 24 | deploymentBucket: 25 | name: ${self:service}-${self:provider.stage}-deployment-641 ##MUST BE UNIQUE IN EACH REGION 26 | 27 | ### Lambda Settings 28 | 29 | ### Global environment Vars for lambdas 30 | environment: 31 | DYNAMO_TABLE_NAME: 'userTable' 32 | USER_INDEX: 'EmailIndex' #Secondary Index we can search users by. (We want to be able to pull up user by their email) 33 | JWT_KEY: 'q35ZQKcD5HXK8ZAIv1wVR91EEhFeQnPs' #Random string to generate JWT with 34 | JWT_EXPIRY: '1d' 35 | 36 | runtime: nodejs18.x #node version 37 | memorySize: 512 # optional, in MB, default is 1024 38 | timeout: 20 # optional, in seconds, default is 6 39 | architecture: arm64 #architecture to run the lambda (graviton2) 40 | logRetentionInDays: 7 #time in days to keep lambda logs in cloudwatch 41 | tracing: # X-Ray tracing on lambda & apigateway 42 | lambda: true 43 | apiGateway: true 44 | 45 | ### Give Lambda permission to dynamodb 46 | iamRoleStatements: 47 | - Effect: Allow 48 | Action: 49 | - dynamodb:DescribeTable 50 | - dynamodb:Query 51 | - dynamodb:Scan 52 | - dynamodb:GetItem 53 | - dynamodb:PutItem 54 | - dynamodb:UpdateItem 55 | - dynamodb:DeleteItem 56 | Resource: 57 | - !Sub arn:aws:dynamodb:*:${AWS::AccountId}:table/${self:provider.environment.DYNAMO_TABLE_NAME} 58 | - !Sub arn:aws:dynamodb:*:${AWS::AccountId}:table/${self:provider.environment.DYNAMO_TABLE_NAME}/index/${self:provider.environment.USER_INDEX} 59 | 60 | ### API Gateway creation/Settings & usage plan 61 | endpointType: REGIONAL 62 | apiGateway: 63 | description: Api Gateway for ${self:service}-${self:provider.stage} 64 | 65 | # attach waf to api gateway 66 | custom: 67 | associateWaf: 68 | name: ${self:service}-${self:provider.stage}-WAF 69 | version: V2 #(optional) Regional | V2 70 | deploymentBucket: 71 | blockPublicAccess: true 72 | tags: 73 | - Key: project 74 | Value: ${self:service} 75 | 76 | 77 | ################### 78 | # Lambda Functions to provision 79 | ################### 80 | # Lambda Packaging Options 81 | package: 82 | individually: true 83 | patterns: 84 | - '!**/*' 85 | - node_modules/** 86 | - src/utils/** #Global lambda utils 87 | - package.json 88 | 89 | # Actual lambda functions 90 | functions: 91 | register: 92 | handler: src/lambdas/register/handler.handler #fileName.exportName in code 93 | maximumRetryAttempts: 1 #Retry attempts by lambda on failure 94 | package: # Only package code relevant to this specific lambda 95 | patterns: 96 | - src/lambdas/register/** 97 | events: #API Gateway integration etc 98 | - http: 99 | method: post 100 | path: user/register 101 | private: true #require api key 102 | cors: #cors settings 103 | origin: '*' 104 | 105 | login: 106 | handler: src/lambdas/login/handler.handler 107 | maximumRetryAttempts: 1 108 | package: 109 | patterns: 110 | - src/lambdas/login/** 111 | events: 112 | - http: 113 | method: post 114 | path: user/login 115 | private: true 116 | cors: 117 | origin: '*' 118 | 119 | decode: 120 | handler: src/lambdas/decode/handler.handler 121 | maximumRetryAttempts: 1 122 | package: 123 | patterns: 124 | - src/lambdas/decode/** 125 | events: 126 | - http: 127 | method: post 128 | path: user/decode 129 | private: true 130 | cors: 131 | origin: '*' 132 | 133 | ################### 134 | # Additional Resource Provisioning 135 | ################### 136 | resources: 137 | Resources: 138 | 139 | # DynamoDB for user data 140 | # Check out https://github.com/sbstjn/serverless-dynamodb-autoscaling for autoscaling the capacity units 141 | # Check out https://www.serverless.com/plugins/serverless-create-global-dynamodb-table/ for multi-region 142 | # We keep put our email as a secondary index so that we can preserve the ability to change it if the user wishes. 143 | usersTable: 144 | Type: AWS::DynamoDB::Table 145 | Properties: 146 | TableName: ${self:provider.environment.DYNAMO_TABLE_NAME} 147 | AttributeDefinitions: 148 | - AttributeName: userid 149 | AttributeType: S 150 | - AttributeName: email 151 | AttributeType: S # Define 'email' attribute with the desired type (S: String, N: Number, etc.) 152 | KeySchema: 153 | - AttributeName: userid 154 | KeyType: HASH 155 | ProvisionedThroughput: 156 | ReadCapacityUnits: 1 157 | WriteCapacityUnits: 1 158 | GlobalSecondaryIndexes: # Define the secondary index 159 | - IndexName: ${self:provider.environment.USER_INDEX} 160 | KeySchema: 161 | - AttributeName: email 162 | KeyType: HASH 163 | Projection: 164 | ProjectionType: ALL # Change this to specify what attributes to include in the index 165 | ProvisionedThroughput: 166 | ReadCapacityUnits: 1 167 | WriteCapacityUnits: 1 168 | 169 | # WAF for our api gateway 170 | wafv2: 171 | Type: AWS::WAFv2::WebACL 172 | Properties: 173 | DefaultAction: 174 | Allow: {} 175 | Name: ${self:service}-${self:provider.stage}-WAF 176 | Scope: REGIONAL 177 | VisibilityConfig: 178 | CloudWatchMetricsEnabled: False 179 | MetricName: ${self:service}-${self:provider.stage} 180 | SampledRequestsEnabled: False 181 | Tags: 182 | - Key: project 183 | Value: ${self:service} 184 | Rules: 185 | - Name: Core-Rule-Set 186 | OverrideAction: 187 | None: {} 188 | Priority: 0 189 | VisibilityConfig: 190 | CloudWatchMetricsEnabled: False 191 | MetricName: ${self:service}-${self:provider.stage}-WAF 192 | SampledRequestsEnabled: False 193 | Statement: 194 | ManagedRuleGroupStatement: 195 | VendorName: AWS 196 | Name: AWSManagedRulesCommonRuleSet 197 | - Name: Known-Bad-Input 198 | OverrideAction: 199 | None: {} 200 | Priority: 1 201 | VisibilityConfig: 202 | CloudWatchMetricsEnabled: False 203 | MetricName: ${self:service}-${self:provider.stage}-WAF 204 | SampledRequestsEnabled: False 205 | Statement: 206 | ManagedRuleGroupStatement: 207 | VendorName: AWS 208 | Name: AWSManagedRulesKnownBadInputsRuleSet 209 | - Name: Anonymous-IP 210 | OverrideAction: 211 | None: {} 212 | Priority: 2 213 | VisibilityConfig: 214 | CloudWatchMetricsEnabled: False 215 | MetricName: ${self:service}-${self:provider.stage}-WAF 216 | SampledRequestsEnabled: False 217 | Statement: 218 | ManagedRuleGroupStatement: 219 | VendorName: AWS 220 | Name: AWSManagedRulesAnonymousIpList 221 | - Name: IP-Reputation 222 | OverrideAction: 223 | None: {} 224 | Priority: 3 225 | VisibilityConfig: 226 | CloudWatchMetricsEnabled: False 227 | MetricName: ${self:service}-${self:provider.stage}-WAF 228 | SampledRequestsEnabled: False 229 | Statement: 230 | ManagedRuleGroupStatement: 231 | VendorName: AWS 232 | Name: AWSManagedRulesAmazonIpReputationList -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Serverless JWT Template 2 | ![Known Vulnerabilities](https://snyk.io/test/github/gagepielsticker/AWS-Auth-Template/badge.svg) 3 | 4 | > A template for starting an AWS based serverless api which integrates JWT authorization and user registration. This utilizes Lambda, api-gateway, WAFv2, and DynamoDB to build a scalable and highly-available rest api. Deployment of all infrastructure fully automated via serverless framework :) 5 | 6 | - [AWS Serverless JWT Template](#aws-serverless-jwt-template) 7 | - [Architecture](#architecture) 8 | - [REST Documentation](#rest-documentation) 9 | - [`POST /user/register`](#post-userregister) 10 | - [`POST /user/login`](#post-userlogin) 11 | - [`POST /user/decode`](#post-userdecode) 12 | - [Adding new lambdas](#adding-new-lambdas) 13 | - [Adding Environment Variables](#adding-environment-variables) 14 | - [Caching](#caching) 15 | - [Configuring API Gateway \& Limits](#configuring-api-gateway--limits) 16 | - [Deployment](#deployment) 17 | - [Dev Dependencies](#dev-dependencies) 18 | - [App Dependencies](#app-dependencies) 19 | - [Contributing](#contributing) 20 | 21 | ## Architecture 22 | Below is a single region deployment example. All resources here are automatically provisioned and configured via serverless framework and deployed to the configured region. 23 |
24 | 25 |
26 | Below is a multi-region deployment for high availability in the unlikely event of an AWS regional failure. This also reduces geographic latency to each region deployed. To achieve this active-active behavior we must manually configure a `route53 zone` & `api gateway custom domains`. You then apply 2 `latency based` routing policies to point at each api gateaway via `A record`. Then attach health-checks to each policy to ensure that the services are healthy in that region. You will also need to configure DynamoDB to use a [global table](https://aws.amazon.com/dynamodb/global-tables/) 27 |
28 | 29 | 30 | Alternatively you could setup a cloudfront distribution with an origin group that contains both api-gateways that fails over on 4xx-5xx. This active-passive pattern would also allow multi-region failover without the need of route53 or a custom domain. 31 | 32 | ## REST Documentation 33 | ### `POST /user/register` 34 | 35 | Input: `body (json)` 36 | 37 | ```json 38 | { 39 | "email": "String (must be valid format)", 40 | "username": "String (under 20 characters)", 41 | "password": "String" 42 | } 43 | ``` 44 | Example Output 45 | ```json 46 | { 47 | "status": "Successfully created user!", 48 | "jwt": "xxxxxxxxxxxxxxxxx" 49 | } 50 | ``` 51 | ### `POST /user/login` 52 | 53 | Input: `body (json)` 54 | 55 | ```json 56 | { 57 | "email": "String (must be valid format)", 58 | "password": "String" 59 | } 60 | ``` 61 | Example Output 62 | ```json 63 | { 64 | "status": "Successfully logged in.", 65 | "jwt": "xxxxxxxxxxxxxxxxx" 66 | } 67 | ``` 68 | 69 | ### `POST /user/decode` 70 | 71 | Input: `body (json)` 72 | 73 | ```json 74 | { 75 | "jwt": "String (must be valid jwt)" 76 | } 77 | ``` 78 | Example Output 79 | ```json 80 | { 81 | "status": "Successfully validated JWT.", 82 | "decodedJWT": { 83 | "userid": "xxxxx-xxxx-xxx-x", 84 | "email": "test@test.com", 85 | "iat": 1691089483, 86 | "exp": 1691175883 87 | } 88 | } 89 | ``` 90 | 91 | ## Adding new lambdas 92 | Adding lambdas is fairly straight forward. Simply copy one of the lambda folders inside of `/src/lambdas/` and modify the handler code as needed. Note that the handler must return the callback and the data should be in a `json` format. Once that is complete open `serverless.yml` and navigate to the `functions:` section. This is where we specify the file to use in that lambda as well as how it connects to api-gateway. 93 | 94 | Example config: 95 | ```yaml 96 | functions: 97 | hello: 98 | handler: src/lambdas/hello/handler.handler #fileName.exportName in code 99 | maximumRetryAttempts: 1 #Retry attempts by lambda on failure 100 | package: # Only package code relevant to this specific lambda 101 | patterns: 102 | - src/lambdas/hello/** 103 | events: #API Gateway integration etc 104 | - http: 105 | method: get 106 | path: hello 107 | private: true #require an api key (look below to usage-plans) 108 | cors: #cors settings 109 | origin: '*' 110 | ``` 111 | 112 | For more documentation on how its configured, visit [HERE](https://www.serverless.com/framework/docs/providers/aws/guide/functions). 113 | 114 | ## Adding Environment Variables 115 | Environment variables can be added to our lambdas via `serverless.yml` under the `provider:`. An example is as follow: 116 | ```yaml 117 | provider: 118 | environment: 119 | MY_SECRET: hi there :) 120 | ``` 121 | This will be accessible in the lambda via `process.env.MY_SECRET`. There are defaultly some environment variables accessible from AWS such as `process.env.AWS_REGION` for the current region 122 | 123 | **Note**: Sensitive secrets should be stored via [Secret Manager](https://aws.amazon.com/secrets-manager/) which should be manually configured by you. To request secrets and data from the service you can utilize the [AWS SDK for JS](https://www.npmjs.com/package/aws-sdk) and follow this [example](https://www.internetkatta.com/how-to-use-secrets-manager-in-aws-lambda-node-js). 124 | 125 | ## Caching 126 | There are a few methods to cache request. Api-Gateway has a built in caching mechanism & you can alternatively use something like elasticache for an app cache. For database caching there is also [DAX](https://aws.amazon.com/dynamodb/dax/) for a fairly high price. 127 | 128 | ## Configuring API Gateway & Limits 129 | By default, our api-gateway service is public and is throttled through default burst and limits attached to the stage. However, you can alternatively create a `usage plan` for finer control and optionally attach it to specific api keys. A usage plan is a set of limits such as a `rate limit`, `burst limit`, and `quota`. You can modify the limits of this usage plan via the `serverless.yml` under the `apiGateway:` section. 130 | 131 | Example config: 132 | ```yaml 133 | apiKeys: 134 | - private_key: # Used to tie key to usage plan 135 | - my amazing api key 136 | usagePlan: 137 | - private_key: # Used to tie key to usage plan. Key will abide by the following 138 | quota: 139 | limit: 5000 #maximum 5000 request per month, refreshes on 2nd day 140 | offset: 2 141 | period: MONTH 142 | throttle: 143 | burstLimit: 200 #maximum number of concurrent requests that API gateway will serve at any given point 144 | rateLimit: 100 #request per second limit 145 | ``` 146 | More info on configuration options found [HERE](https://www.serverless.com/framework/docs/providers/aws/events/apigateway). 147 | 148 | ## Deployment 149 | To deploy the application to AWS you will first need to install the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html). Then proceed with the following steps. 150 | 151 | 1. Authenticate with your AWS CLI. Whether that be entering access keys in the [credential file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) or alternatively entering [environment variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html). 152 | 2. Ensure all your unit test are passing with `npm test` 153 | 3. Run `npm deploy` which will use the default of stage `dev` and region `us-east-1`. If you wish to manually configure the stage and region, you can do so by modifying the `package.json` script or alternatively running `serverless deploy --stage dev --region us-east-1` manually with your respective changes to that commands parameters. 154 | 155 | **IMPORTANT** : Due to the universal nature of s3, the deployment may fail with a "bucket already exist" error. This is because s3 buckets must have unique names _per region_. To resolve this ensure your service name is unique to your project or modify the deployment bucket name in the `serverless.yml` 156 | 157 | **IMPORTANT** : Upon first deployment, the script may not be able to attach the WAF to the API-Gateway. Simply rerun the deployment script and it should attach now that its created. 158 | 159 | ## Dev Dependencies 160 | - serverless 161 | - serverless-associate-waf 162 | - serverless-deployment-bucket 163 | - serverless-offline 164 | - standard 165 | 166 | ## App Dependencies 167 | - aws-sdk 168 | - bcryptjs 169 | - jsonwebtoken 170 | - uuid 171 | - yup 172 | 173 | ## Contributing 174 | Find the contribution document [HERE](/CONTRIBUTING.md) if you wish to work on the repo! --------------------------------------------------------------------------------