Статьи

API-шлюз и AWS Lambda для аутентификации

Когда Foreach изначально наступил на универсал микросервисов, мы на самом деле не создавали микросервисы. Мы думали, что сделали, но во всех наших сервисах всегда присутствовала логика. Конечно, каждый сервис на самом деле должен фокусироваться только на своих задачах, а не на вещах, принадлежащих другому микросервису. Самым очевидным препятствием на нашей стороне была логика аутентификации и авторизации.

В определенный момент у нас было несколько «микро» -услуг, которые проверяли заголовки авторизации входящих запросов к нашему AuthenticationService (и в более ранние дни, даже к общей базе данных). Это вызвало большую нагрузку на наш AuthenticationService чем нам бы хотелось (один и тот же токен был проверен несколько раз), но также привело к тому, что во всех этих службах присутствовал некоторый важный код. И, как известно любому разработчику, дорога в ад вымощена общим кодом. Микросервисы стали больше, чем их реальное назначение, что усложнило их разработку и обслуживание.

В нашем стремлении к спасению мы быстро определили пару решений, которые могут помочь нам.

Веб-токены JSON

Первое, что мы рассмотрели, это начало использования веб-токенов JSON (JWT) . JWT — это открытый стандарт, который определяет автономный способ безопасной передачи информации между сторонами. Автономный означает, что сам токен может содержать всю необходимую нам информацию — например, идентификатор пользователя или его имя. Безопасность означает, что другие стороны не могут вмешиваться в эти токены. Маркер содержит зашифрованную часть, и для его расшифровки необходим секретный ключ, который известен только вам. Другими словами, если маркер был подделан, вы будете знать.

JWT — очень интересное руководство, потому что с минимальными корректировками на нашей стороне мы теоретически могли бы даже устранить некоторые дополнительные нагрузки в наших микросервисах (которые они не должны делать в любом случае). Проверка токена была бы минимальным процессом, очень хорошо интегрированным в среду Spring, поэтому нам не понадобилось бы столько кода для него. Токены также содержат всю необходимую нам информацию, поэтому нам больше не нужно запрашивать ее у другого веб-сервиса.

Проблема с JWT, однако, заключалась в том, что уже были некоторые другие приложения, разработанные другими сторонами, которые были интегрированы с API. И оказалось, что не все эти приложения были столь же счастливы, когда мы начали раздавать токены JWT. Поскольку изменение этих приложений не было возможным в краткосрочной перспективе, мы похоронили эту идею — пока.

API-шлюз

У нас была еще одна идея — представить API-шлюз. Это можно рассматривать как обертку вокруг наших API, предназначенную для абстрагирования нашего API для наших конечных пользователей. Это может изменить ответы в другом формате. Он может объединить несколько HTTP-запросов в один запрос. Или это может обеспечить дополнительные функции мониторинга (например, «кто спамит определенную конечную точку?»). Но, прежде всего, он должен абстрагироваться от всего, что связано с аутентификацией.

В нашем случае идея заключается в том, что шлюз API проверяет входящий заголовок авторизации даже до того, как запрос будет передан нашему приложению. Он должен кэшировать результат, так что если один и тот же пользователь запрашивает пять конечных точек, мы все равно проверяем токен только один раз в час, и он должен передавать информацию аутентификации нашим API, чтобы мы знали, кто запрашивает ресурс.

Наше решение: AWS API Gateway

Аутентификация

https://docs.aws.amazon.com/apigateway/latest/developerguide/images/custom-auth-workflow.png

На рынке есть много продуктов, которые соответствуют этому описанию, но после некоторого рассмотрения мы решили попробовать AWS API Gateway. Мы реализовали собственный «авторизатор». Это лямбда-функция, которая получает токен авторизации, предоставленный клиентом в качестве входных данных, и возвращает информацию о том, имеет ли клиент доступ к запрошенному ресурсу. Если аутентификация отклонена, API Gateway вернет клиенту код HTTP 403. В противном случае запрос будет перенаправлен на наши услуги. Результат работы авторизатора Lambda сохраняется в кеше в течение часа. Мы также хотим передать идентификацию пользователя нашим базовым сервисам, используя HTTP-заголовки. Таким образом, мы знаем, кто выполняет запрос в нашем приложении.

Автор

Наша пользовательская лямбда-функция написана на Python. Он получает заголовок авторизации от входящих запросов и запускает HTTP-запрос к нашей AuthenticationService — единственному месту, где мы можем проверить, является ли входящая информация действительной, и к кому относится токен. Этот HTTP-запрос скажет нам, кто является конечным пользователем.

Код этой лямбда-функции, в значительной степени основанный на примере кода, предоставленного AWS, выглядит следующим образом:

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
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

Конфигурация шлюза

После создания лямбда-функции пришло время настроить шлюз. Вы можете сделать это в консоли AWS или с помощью шаблона CloudFormation. Мы не будем подробно объяснять, как настроить API Gateway, поскольку это хорошо задокументированная задача на сайте AWS . Я, однако, объясню некоторые особенности для настройки авторизатора.

Authorizer

Когда вы находитесь в разделе конфигурации API-шлюза, слева вы видите опцию «Авторизаторы». Там вы можете создать новый авторизатор. При нажатии на кнопку вы увидите следующую форму:

Аутентификация

Важно здесь:

  • Лямбда-функция: выберите авторизатор лямбда, который вы создали ранее
  • Полезная нагрузка Lamba: токен
  • Источник токена: Авторизация (если ваш клиент отправляет токен с помощью заголовка Авторизация)
  • Кэширование авторизации: включено

Ресурс

Далее мы переходим к методу, который вы хотите защитить. Нажмите на ресурсы слева и выберите метод в списке. Вы должны увидеть экран, подобный показанному ниже:

Аутентификация

Нажмите «Запрос метода». В верхней части вы можете настроить использование Авторизатора, который вы добавили ранее.

Аутентификация

Вернитесь к предыдущему экрану и нажмите «Запрос на интеграцию». Внизу мы настроим некоторые заголовки, которые мы хотим отправить нашему API. Они содержат информацию о пользователе, которую мы будем использовать в API, чтобы узнать, кто делает запрос. Примечание: нам не нужно бояться, что злоумышленник отправит эти заголовки в запросе. Они будут перезаписаны с результатом нашего пользовательского авторизатора.

Аутентификация

Будущее

Несмотря на то, что наша текущая реализация хорошо работает в производстве, мы всегда ищем идеи о том, как улучшить наш продукт и, следовательно, об услугах, которые мы предоставляем нашим клиентам. Одна из вещей, на которые мы будем продолжать обращать внимание, — это однажды начать использовать токены JWT, что вполне возможно в сочетании с API Gateway. Это значительно упростит настройку, но потребует изменений в некоторых приложениях, чего мы пока не можем сделать.

Кроме того, у нас есть некоторые идеи о том, как мы можем получить больше от API Gateway. Мы очень заинтересованы в ограничении ставок для приложений и пользователей. Мы хотим иметь возможность настроить мобильное приложение таким образом, чтобы, например, ему разрешалось выполнять только сотню запросов в час или чтобы определенному конечному пользователю разрешалось только небольшое количество запросов.

Использование API Gateway в сочетании с AWS Lambda — это относительно простой способ добавить надежный метод аутентификации в ваше приложение, не перекрывая другие ваши сервисы.

Смотрите оригинальную статью здесь: API Gateway & AWS Lambda для аутентификации

Мнения, высказанные участниками Java Code Geeks, являются их собственными.