API Gateway & AWS Lambda for Authentication
When Foreach initially stepped on the microservices wagon, we didn’t really build microservices. We thought we did, but there was always some logic present in all of our services. Of course, every service should really only focus on its own tasks and not on things that belong to another microservice. The most obvious thorn in our side was authentication and authorization logic.
At a certain point, we had a couple of “micro”-services that verified the Authorization headers of incoming requests against our AuthenticationService
(and in earlier days, even against a shared database). This caused more load on our AuthenticationService
than we would like (the same token was verified multiple times), but it also caused some important code to be present in all those services. And as any developer knows, the road to hell is paved with shared code. The microservices became bigger than their actual purpose, which made them harder to develop and harder to maintain.
In our quest for salvation we quickly identified a couple of solutions that could help us.
JSON Web Tokens
The first thing we considered was to start using JSON Web Tokens (JWT). JWT is an open standard that defines a self-contained way to securely transfer information between parties. Self-contained means that the token itself can contain all the information we need – for example an identifier of the user, or his name. Secure means that it is impossible for other parties to interfere with these tokens. The token contains an encrypted part, and to decrypt it you need a secret key that only you know. In other words, if the token has been tampered with you will know.
JWT is a very interesting lead, because with minimal adjustments on our side we could theoretically even eliminate some of the additional workload in our microservices (that they shouldn’t be doing anyway). The verification of the token would be a minimal process, very well integrated into the Spring framework, so we wouldn’t need that much code for it. The tokens would also contain all the information we need, so we would no longer need to request this from another webservice.
The problem with JWT, however, was that there were already some other applications, developed by other parties, that were integrated with the API. And it turned out that not all of those applications were as happy when we started handing out JWT tokens. Since changing those applications wasn’t an option in short-term, we buried this idea – for now.
API Gateway
Another idea we had was to introduce an API Gateway. This can be seen as a wrapper around our API’s, meant to abstract our API for our end users. It could alter responses to another format. It could combine multiple HTTP requests into a single request. Or it could provide additional monitoring features (such as ‘who is spamming a certain endpoint?’). But above all, it should abstract everything related to authentication.
In our case, the idea is that the API gateway verifies the incoming Authorization header, even before the request gets proxied to our application. It should cache the result, so that if the same user requests five endpoints, we still only verify the token once per hour, and it should pass on the authentication info to our APIs, so that we know who is requesting the resource.
Our solution: AWS API Gateway
There are a lot of products on the market that fit this description, but after some consideration, we decided to give AWS API Gateway a shot. We implemented a custom “authorizer”. This is a Lambda function that receives the Authorization token the client supplied as input and returns whether the client has access to the requested resource. If the authentication is denied, API Gateway will return a 403 HTTP code to the client. Otherwise, the request will be proxied to our services. The result of the authorizer Lambda is preserved in cache for an hour. We also want to pass on the identity of the user to our underlying services using HTTP Headers. That way, we know who is executing the request in our application.
The authorizer
Our custom Lambda function is written in Python. It gets the Authorization header from the incoming requests and launches a HTTP request to our AuthenticationService
– the only place where we can verify whether the incoming information is valid, and who the token applies to. This HTTP request will tell us who the end user is.
The code of this Lambda function – largely based on sample code provided by AWS – looks like this:
from __future__ import print_function import re import urllib2 import base64 import json import os def lambda_handler(event, context): print("Client token (provided): " + event['authorizationToken']) clientAuthorizationToken = re.sub('^%s' % 'Bearer', '', re.sub('^%s' % 'bearer', '', event['authorizationToken'])).strip() print("Client token (parsed): " + clientAuthorizationToken) print("Method ARN: " + event['methodArn']) url = os.environ['CHECK_TOKEN_ENDPOINT'] + "?token=" + clientAuthorizationToken print("Check token URL: " + url) authorizationHeader = 'Basic %s' % base64.b64encode(os.environ['CHECK_TOKEN_ENDPOINT_CLIENT_ID'] + ':' + os.environ['CHECK_TOKEN_ENDPOINT_CLIENT_SECRET']) print("Our authorization header: " + authorizationHeader) tmp = event['methodArn'].split(':') apiGatewayArnTmp = tmp[5].split('/') awsAccountId = tmp[4] policy = AuthPolicy('urn:user:unknown', awsAccountId) policy.restApiId = apiGatewayArnTmp[0] policy.region = tmp[3] policy.stage = apiGatewayArnTmp[1] request = urllib2.Request(url, headers={"Authorization": authorizationHeader}) try: result = urllib2.urlopen(request) data = json.load(result) print("HTTP Response data: " + str(data)) context = { 'userUrn': data['user_urn'] if data.has_key('user_urn') else None, 'clientId': data['client_id'] } policy.principalId = data['user_urn'] if data.has_key('user_urn') else 'urn:client:%s' % data['client_id'] policy.allowMethod('*', '*') print('Allowing resource %s. Client: %s, User: %s, Principal: %s' % (policy.allowMethods[0]['resourceArn'], context['clientId'], context['userUrn'], policy.principalId)) except urllib2.HTTPError, e: print("Error during the HTTP call: %s" % e) policy.denyAllMethods() context = {} authResponse = policy.build() authResponse['context'] = context return authResponse class HttpVerb: GET = 'GET' POST = 'POST' PUT = 'PUT' PATCH = 'PATCH' HEAD = 'HEAD' DELETE = 'DELETE' OPTIONS = 'OPTIONS' ALL = '*' class AuthPolicy(object): awsAccountId = '' principalId = '' version = '2012-10-17' pathRegex = '^[/.a-zA-Z0-9-\*]+$' allowMethods = [] denyMethods = [] restApiId = '*' region = '*' stage = '*' def __init__(self, principal, awsAccountId): self.awsAccountId = awsAccountId self.principalId = principal self.allowMethods = [] self.denyMethods = [] def _addMethod(self, effect, verb, resource, conditions): if verb != '*' and not hasattr(HttpVerb, verb): raise NameError('Invalid HTTP verb ' + verb + '. Allowed verbs in HttpVerb class') resourcePattern = re.compile(self.pathRegex) if not resourcePattern.match(resource): raise NameError('Invalid resource path: ' + resource + '. Path should match ' + self.pathRegex) if resource[:1] == '/': resource = resource[1:] resourceArn = 'arn:aws:execute-api:{}:{}:{}/{}/{}/{}'.format(self.region, self.awsAccountId, self.restApiId, self.stage, verb, resource) if effect.lower() == 'allow': self.allowMethods.append({ 'resourceArn': resourceArn, 'conditions': conditions }) elif effect.lower() == 'deny': self.denyMethods.append({ 'resourceArn': resourceArn, 'conditions': conditions }) def _getEmptyStatement(self, effect): statement = { 'Action': 'execute-api:Invoke', 'Effect': effect[:1].upper() + effect[1:].lower(), 'Resource': [] } return statement def _getStatementForEffect(self, effect, methods): statements = [] if len(methods) > 0: statement = self._getEmptyStatement(effect) for curMethod in methods: if curMethod['conditions'] is None or len(curMethod['conditions']) == 0: statement['Resource'].append(curMethod['resourceArn']) else: conditionalStatement = self._getEmptyStatement(effect) conditionalStatement['Resource'].append(curMethod['resourceArn']) conditionalStatement['Condition'] = curMethod['conditions'] statements.append(conditionalStatement) if statement['Resource']: statements.append(statement) return statements def allowAllMethods(self): self._addMethod('Allow', HttpVerb.ALL, '*', []) def denyAllMethods(self): self._addMethod('Deny', HttpVerb.ALL, '*', []) def allowMethod(self, verb, resource): self._addMethod('Allow', verb, resource, []) def denyMethod(self, verb, resource): self._addMethod('Deny', verb, resource, []) def allowMethodWithConditions(self, verb, resource, conditions): self._addMethod('Allow', verb, resource, conditions) def denyMethodWithConditions(self, verb, resource, conditions): self._addMethod('Deny', verb, resource, conditions) def build(self): if ((self.allowMethods is None or len(self.allowMethods) == 0) and (self.denyMethods is None or len(self.denyMethods) == 0)): raise NameError('No statements defined for the policy') policy = { 'principalId': self.principalId, 'policyDocument': { 'Version': self.version, 'Statement': [] } } policy['policyDocument']['Statement'].extend(self._getStatementForEffect('Allow', self.allowMethods)) policy['policyDocument']['Statement'].extend(self._getStatementForEffect('Deny', self.denyMethods)) return policy
Gateway configuration
After creating the Lambda function, it’s time to configure the Gateway. You can do this in the AWS console, or using a CloudFormation template. We are not going to explain in detail how to configure API Gateway, since it’s a well-documented task on the site of AWS. I will, however, explain some specifics to configure the authorizer.
Authorizer
When you are in the API Gateway configuration section, on the left you see the option “Authorizers”. There you can opt to create a new authorizer. When you click the button, you will see the following form:
Important here:
- Lambda function: select the authorizer Lambda you created before
- Lamba event payload: Token
- Token source: Authorization (in case your client is sending the token using the Authorization header)
- Authorization caching: Enabled
Resource
Next, we go to the method you want to protect. Click on resources on the left and select a method in the list. You should see a screen similar to the one below:
Click on “Method Request”. At the top you can then configure to use the Authorizer you added before.
Go back to the previous screen and click on “Integration request”. At the bottom, we will configure some headers we want to send to our API. These contain information about the user, that we will use in the API to know who is making the request. Note: we do not have to be scared about a malicious user sending these headers in the request. They will be overwritten with the result of our custom authorizer.
Future
While our current implementation is working well in production, we are always on the lookout for ideas on how to improve our product, and thereby, the service we provide to our customers. One of the things we will keep looking at, is to one day start using JWT tokens, quite possible in combination with API Gateway. That would make the setup a lot easier, but will require changes in some applications, something we can’t do at the moment.
Furthermore, we do have some ideas on how we can get more out of API Gateway. We are very interested in per-application and per-user rate limiting. We want to be able to configure the mobile app in such a way, for example, that it is only allowed to execute one hundred requests per hour, or that a certain end user is only allowed a small number of requests.
Using API Gateway in combination with AWS Lambda is a relatively straightforward way to add a solid authentication method to your application, without bogging down your other services.
Published on Java Code Geeks with permission by Wouter Van Hecke, partner at our JCG program. See the original article here: API Gateway & AWS Lambda for Authentication Opinions expressed by Java Code Geeks contributors are their own. |