Part 0: Intro could be found here.
Introduction
We start our dive deep
into the architecture from Auth
section.
To remind you from the previous part it has the next design:
It has 3 separate lambda functions:
- Registration - which has the functionality to register new account.
- Authentication - which generates JWT token for the valid credentials.
- Authorization - which validates JWT token and returns policy.
For me it doesn’t matter what language to use to implement those functions, but to get some experience I selected Golang. It works great for lambda functions as binary file is pretty small and the cold start problem is very minor. For deploying to AWS we will use CloudFormation and serverless framework which is really awesome.
We have the same serverless.yaml
file for all 3 lambda functions which starts with:
service: AuthLambdas
provider:
name: aws
runtime: go1.x
stage: dev
region: us-west-2
memorySize: 128
Note that memory is set to 128 MB
which is minimum possible.
Then custom key/value pairs section with pairs that are used more than once:
custom:
signingKey: "my signing key"
emailIndexName: "EmailIndex"
I will show usage of these pairs later.
Registration
registration
lambda logic is divided into a few steps:
- Check if account with the provided email already exists.
- Encrypt account’s password.
- Save account with provided email/name and encrypted password to DynamoDb.
We start with Accounts
table in DynamoDb:
AccountsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: Accounts
AttributeDefinitions:
- AttributeName: email
AttributeType: S
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
- AttributeName: email
KeyType: RANGE
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
GlobalSecondaryIndexes:
- IndexName: ${self:custom.emailIndexName}
KeySchema:
- AttributeName: email
KeyType: HASH
Projection:
ProjectionType: ALL
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
- It has two key attributes HASH =
id
and RANGE =email
. - It has GSI with the name
${self:custom.emailIndexName}
from the custom keys section. This index is used to check if account with the providedemail
exists. - It has RCUs and WCUs set to
1
just for development purpose.
Based on DynamoDb table we define Account
structure as following:
type Account struct {
Id string `json:"id"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
Password
and Name
are non key attributes, but we need them.
To check if account with provided email
exists we have next query:
input := &dynamodb.QueryInput{
TableName: aws.String(os.Getenv(TABLE_NAME)),
IndexName: aws.String(os.Getenv(INDEX_NAME)),
KeyConditions: map[string]*dynamodb.Condition{
"email": {
ComparisonOperator: aws.String("EQ"),
AttributeValueList: []*dynamodb.AttributeValue{
{
S: aws.String(email),
},
},
},
},
}
Table and index names are passed to lambda function through environment variables and then retrieved with os.Getenv(TABLE_NAME)
and os.Getenv(INDEX_NAME)
.
An encrypted password should be stored in the database and for that we use key from AWS Key Managment Service (KMS):
DefaultKMSKey:
Type: AWS::KMS::Key
Properties:
Description:
Fn::Sub: "Default KMS key"
KeyPolicy:
Version: "2012-10-17"
Id: "default-kms-key"
Statement:
- Sid: Allow account-level IAM policies to apply to the key
Effect: Allow
Principal:
AWS:
Fn::Join:
- ""
- - "arn:aws:iam::"
- Ref: AWS::AccountId
- ":root"
Action: "kms:*"
Resource: "*"
DefaultKMSKeyAlias:
Type: AWS::KMS::Alias
Properties:
AliasName: "alias/DefaultKMSKey"
TargetKeyId:
Ref: DefaultKMSKey
Note that KeyPolicy
could be improved with the specific account instead of root.
Also, we can see AWS::KMS::Alias
for the key as it doesn’t work without alias.
Encryption logic is simple enough using the above key:
passwordInput := &kms.EncryptInput{
KeyId: aws.String(os.Getenv(KMS_KEY)),
Plaintext: []byte(password),
}
encryptedPassword, err := kmsClient.Encrypt(passwordInput)
Where the os.Getenv(KMS_KEY)
is the KMS key arn from environment variables.
The last but not least is the query to create account in DynamoDb:
id := uuid.NewV4().String()[0:8]
input := &dynamodb.PutItemInput{
TableName: aws.String(os.Getenv(TABLE_NAME)),
Item: map[string]*dynamodb.AttributeValue{
"id": {
S: aws.String(id),
},
"email": {
S: aws.String(account.Email),
},
"password": {
B: encryptedPassword,
},
"name": {
S: aws.String(account.Name),
},
},
}
New account’s id is generated with go.uuid library.
Also, our lambda function has a role with next permissions:
DefaultLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: DefaultLambdaRole
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: MyDefaultPolicy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:PutItem
Resource:
Fn::GetAtt:
- AccountsTable
- Arn
- Effect: Allow
Action:
- dynamodb:Query
Resource:
Fn::Join:
- "/"
- - Fn::GetAtt:
- AccountsTable
- Arn
- "index"
- "*"
- Effect: Allow
Action:
- kms:Encrypt
- kms:Decrypt
Resource:
Fn::GetAtt:
- DefaultKMSKey
- Arn
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource:
Fn::Join:
- ":"
- - "arn:aws:logs"
- Ref: AWS::Region
- Ref: AWS::AccountId
- "log-group:/aws/lambda/*:*:*"
- Access to DynamoDb
Query
andPutItem
operations plus access toQuery
for GSI. - Access to
Encrypt
andDecrypt
for KMS (actually, for security reasons, it’s better to split into two different roles). - Access to CloudWatch operations to be able to see logs from our lambda function.
Our lambda function defined in serverless.yaml
as:
Registration:
handler: bin/registration
events:
- http:
path: /register
method: post
cors: true
role: DefaultLambdaRole
environment:
region: ${self:provider.region}
accountsTableName:
Ref: AccountsTable
emailIndexName: ${self:custom.emailIndexName}
kmsKey:
Fn::GetAtt:
- DefaultKMSKey
- Arn
- Triggered by HTTP
POST
method on/register
path. - Role as defined above.
- Environment variables for DynamoDb table and index, KMS key.
Authentication
authentication
lambda function logic has a few steps:
- Get account from DynamoDb by email.
- Decrypt and check if password is correct.
- Generate JWT token.
To get account from DynamoDb the same quest is used as in registration
lambda function to check if account exists.
We might think that it could be done vice-versa first encrypt the provided password and then check if it’s the same as in DynamoDb, but KMS uses envelope encryption and the encryption key changes each time request is made for a key, so it wouldn’t work.
The code is pretty straightforward and looks the same as encryption:
passwordInput := &kms.DecryptInput{
CiphertextBlob: account.Password,
}
decryptedPassword, err := kmsClient.Decrypt(passwordInput)
For JWT token jwt-go library is used:
mySigningKey := []byte(os.Getenv(SIGNING_KEY))
expiryTime := time.Now().Add(EXPIRE_TIME).UnixNano() / int64(time.Millisecond)
claims := &jwt.StandardClaims{
ExpiresAt: expiryTime,
Issuer: ISSUER,
Subject: account.Id,
}
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedJwtToken, err := jwtToken.SignedString(mySigningKey)
SIGNING_KEY
is the key from custom section ofserverless.yaml
.- Expiration time 24 hours ahead, hardcoded issuer and subject as
accountId
is stored in claims.
Definition of authentication
lambda function is similar to registration
function:
Authentication:
handler: bin/authentication
events:
- http:
path: /auth/get-token
method: post
cors: true
role: DefaultLambdaRole
environment:
region: ${self:provider.region}
accountsTableName:
Ref: AccountsTable
emailIndexName: ${self:custom.emailIndexName}
kmsKey:
Fn::GetAtt:
- DefaultKMSKey
- Arn
signingKey: ${self:custom.signingKey}
- Triggered by HTTP
POST
method on/auth/get-token
path. - Role the same as for
registration
function. - Environment variables for DynamoDb table and index, KMS key and JWT signing key.
Authorization
The last but not least lambda function is authorization
.
It is implemented as API Gateway custom authorizer
.
It validates the provided token and returns generated policy to allow or deny access to the resource, but for simplicity, we won’t have different scopes and it always either allow
or 401
.
JWT token is parsed at first with the same jwt-go library as above:
tokenStr := strings.TrimPrefix(event.AuthorizationToken, "Bearer ")
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("wrong signing method")
}
signingKey := []byte(os.Getenv(SIGNING_KEY))
return signingKey, nil
})
The same SIGNING_KEY
that is specified in custom section is used here.
Then token claims are validated and policy is generated:
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
log.Println("token claims: ", claims)
accountId := claims["sub"].(string)
err = claims.Valid()
if err == nil {
return createPolicy(accountId, ALLOW, event.MethodArn), nil
}
}
createPolicy
should generated specific policy, example in AWS docs:
func createPolicy(accountId, effect, resource string) events.APIGatewayCustomAuthorizerResponse {
return events.APIGatewayCustomAuthorizerResponse{
PrincipalID: accountId,
PolicyDocument: events.APIGatewayCustomAuthorizerPolicy{
Version: "2012-10-17",
Statement: []events.IAMPolicyStatement{
{
Action: []string{"execute-api:Invoke"},
Effect: effect,
Resource: []string{"*"},
},
},
},
}
}
Finally, the definition of authorization
lambda function:
Authorization:
handler: bin/authorization
environment:
signingKey: ${self:custom.signingKey}
- It is triggered by API Gateway, so we there is no
events
section. - JWT signing key is passed with environment variables.
Build & Deploy
To build it and deploy there is a makefile
which does everything (build, test, deploy) for all 3 lambda functions together:
.PHONY: build
build:
dep ensure -v
env GOOS=linux go build -ldflags="-s -w" -o bin/authentication authentication/main.go
env GOOS=linux go build -ldflags="-s -w" -o bin/authorization authorization/main.go
env GOOS=linux go build -ldflags="-s -w" -o bin/registration registration/main.go
.PHONY: test
test:
go test ./...
.PHONY: clean
clean:
rm -rf ./bin ./vendor Gopkg.lock
.PHONY: deploy
deploy: clean build test
sls deploy --verbose
The last step of the makefile
has sls deploy --verbose
which gives us:
- Size of the .zip file was 8.71 Mb
- Two endpoints were generated for
registration
andauthentication
lambda functions. - Outputs from the stack such as lambda functions arns and s3 bucket name created by serverless framework.
Using Postman we can verify that it works:
First we call registration
function to create a new account:
The first call with the cold start gives us up to 2s latency, but after it usually takes ~100-200ms.
The same for authentication
function:
Same here, the first call with the cold start gives us up to 1.5s latency, but after it usually takes ~100ms.
Conclusion
Using Golang for lambda functions is pretty smooth. It’s pretty easy to find some help and needed libraries on the Internet. In the beginning, it was a bit hard to get used to syntax after Java world, but after some time development is fast enough. All the logic is covered with unit tests. Everything could be found on my github repository.
What’s next?