Об авторизации
2019-11-23
Немного психанул, и выдал на проекте документацию того, как я хочу видеть нормальную авторизацию пользователей в случае микросервисов и «богатого» фронтенда на React или Angular. Излагаю и здесь.
Для начала нужно разделить понятия аутентификации и авторизации.
Аутентификация — это доказательство того, что пользователь является именно тем, за кого себя выдаёт. В простейшем случае пользователь предоставляет нам свой логин и пароль на нашем сервисе. В более сложных случаях мы можем попросить пользователя аутентифицироваться на каком-нибудь Google, и, если через OAuth и соответствующий access токен мы сможем получить имя и email пользователя, то мы сможем пустить его и к себе. В любом случае некий идентификатор и профиль пользователя появляются в нашей системе.
Авторизация — это проверка прав доступа. А может ли этот, уже аутентифицированный, пользователь делать то, что пытается сделать сейчас? Авторизацию нужно проводить для каждого запроса, который к нам приходит.
С точки зрения пользователей есть несколько задач, которые, на самом деле, могут решаться разными компонентами системы, разными микросервисами.
- Логин/логаут. Пользователь должен иметь возможность ввести логин/пароль, чтобы попасть в систему.
- Управление профилем. Аутентифицированный пользователь должен иметь возможность менять свой профиль: задавать email, своё имя, адрес и прочее.
- Управление пользователями. Админ (который должен быть таким же пользователем, только со специальными правами) должен иметь возможность управлять всеми пользователями системы: видеть их список и менять их профили.
- Регистрация пользователей. Новый неаутентифицированный пользователь, пришедший анонимус, должен иметь возможность зарегистрироваться в системе, получить логин/пароль и стать аутентифицированным пользователем.
- Multitenancy. В нашей системе может существовать несколько независимых «организаций», каждая со своим независимым набором пользователей. Хотя, конечно, возможны варианты, когда один и тот же пользователь может иметь (разные) права в разных организациях. Важно, что всяческие ресурсы (ради доступа к которым и существует наша система) у каждой организации свои. А ещё у организации могут быть свои админы, с доступом ко списку пользователей только организации.
Немного терминологии, касающейся авторизации.
- Субъект — пользователь или группа пользователей.
- Объект — цель приложения каких-либо действий. Например, это может быт REST ресурс, обслуживаемый каким-то микросервисом.
- Действие — что-то, что может быть сделано Субъектом с Объектом. Например, чтение REST ресурса, или создание нового объекта в REST коллекции.
- Разрешение — комбинация Действия и Объекта. Конкретное действие, применимое к конкретному объекту. Например, чтение списка пользователей текущей организации. Разрешения назначаются Субъекту и дают ему права что-либо делать в системе.
- Роль — предопределённый набор Разрешений. Вместо того, чтобы назначать Разрешения по одному, можно объединить их в Роли и назначать Роль Субъекту.
- Пользователь — Субъект, представляющий собой одного человека.
- Группа — Субъект, представляющий собой группу людей. Пользователь получает объединение Разрешений от всех Групп, в которых он состоит, плюс его собственные Разрешения.
Я не знаю, получилось ли тут мандатное, или избирательное, или ролевое управление доступом. Просто, перечисленного более чем достаточно для достаточно сложной системы.
Минимальный же набор включает в себя лишь Пользователей и Разрешения. Нам достаточно знать, кому что можно.
Выделение Действий отдельно удобно для раздачи Разрешений, если у нас есть стандартный набор Действий для каждого Объекта. Например, CRUD — create, read, update, delete. Тогда у нас появятся стандартные Разрешения: "create_resource", "read_resource", "update_resource", "delete_resource". И так для каждого ресурса.
Выделение Ролей удобно, когда у нас становится слишком много Объектов и Разрешений. Удобнее назначать с пяток ролей, чем сотни разных Разрешений. Админы того, админы сего, обычные пользователи, пользователи с правами только на чтение — это всё Роли.
Группы удобны, когда у нас становится слишком много Пользователей с однотипными Разрешениями. Проще добавить пользователя в Группу, у которой уже есть нужные Разрешения, чем назначать их каждому Пользователю.
Токены. Нам нужно два их типа.
- Access token. Это должен быть JWT токен. В нём самом содержится всё, что нужно, чтобы авторизовать пользователя. А чтобы никто другой не мог подделать эти токены, они должны быть криптографически подписаны (собственно, это и есть JWT). JWT токен не может быть отозван, поэтому у него должно быть небольшое время жизни, минуты.
- Refresh token. Это не обязательно JWT, может быть и просто случайная строка. Этот токен нужен, чтобы получить новый access токен, когда срок действия старого access токена истекает. У refresh токена может быть долгое время жизни, дни или даже недели. Это должен быть одноразовый токен, то есть его должно быть можно использовать только раз для получения нового access и refresh токенов.
Фронту, который на React или Angular, нужно следующее.
- Фронт хранит access и refresh токены в Local Storage или в Cookie.
- Если у нас нет access токена или запрос на бэкенд возвращает 401 или 403, браузер нужно редиректнуть на страницу логина. Либо, если страница логина является частью фронта, показать эту страницу.
- Если мы редиректим на страницу логина отдельного сервиса аутентификации, URL этой страницы должны быть известным и публичным.
- После логина фронт должен получить access и refresh токены и сохранить их. Либо редиректом со страницы логина. Либо явным запросом на сервис аутентификации, обменять логин/пароль на токены.
- После получения токенов запросы на другие бэкенд сервисы должны передавать access токен. Либо в заголовке Authorization типа "Bearer". Либо в Cookie.
- Если срок действия access токена подходит к концу, либо если запрос к бэкенду вернул 401 или 403, нужно воспользоваться refresh токеном, чтобы получить новый access токен (и refresh тоже).
- URL куда надо направлять запрос для получения новых токенов по refresh токену должен быть известным и публичным.
Можно долго спорить, где лучше хранить токены, в Local Storage или Cookie. В Local Storage немного удобнее, потому что сюда есть полный доступ из JavaScript, и токен можно без проблем добавить в заголовок всех запросов на бэкенд. В Cookie немного безопаснее, если использовать HttpOnly Cookie, то оно автоматически будет передаваться на бэкенд, а вот JavaScript (в том числе и зловредный, внедрённый в страницу) не сможет получить к нему доступ. Но Cookie нужно ещё установить с бэкенда.
Бэкенду, каждому из микросервисов, нужно следующее.
- Микросервис получает JWT access токен. Либо в заголовке Authorization типа Bearer. Либо в Cookie.
- JWT токен должен быть подписан с использованием RSA. Тогда подпись токена можно будет проверить публичным ключом.
- Публичный ключ должно быть возможно получить по известному публичному URL. Микросервис может скачать этот ключ при старте.
- JWT токен должен содержать идентификатор пользователя.
- Прочие сведения о пользователе, если они необходимы, должно быть возможно получить, сделав запрос к микросервису, отвечающему за профили пользователей.
- JWT токен должен содержать список Разрешений (в терминах, описанных выше) назначенных текущему пользователю. Каждое Разрешение должно быть представлено строкой, например, "read_users".
- Микросервис должен знать набор Разрешений, дающих доступ к его методам, и должен разрешать вызов метода или обращение к ресурсу, только если в токене присутствует необходимое Разрешение.
- Если Разрешений в токене недостаточно, чтобы выполнить текущий запрос, микросервис должен вернуть 403 Forbidden.
- Если у JWT токена невалидная подпись или срок его действия истёк, микросервис должен вернуть 401 Unauthorized.
- Если миросервису нужно обратиться к другому микросервису, он может использовать access токен из входящего запроса. Это эффективный способ выполнить запрос от имени и с правами пользователя.
В JWT есть два основных способа формирования подписи. В алгоритме HMAC используется общий ключ как для формирования подписи, так и для её проверки. В алгоритмах RSA и ECDSA (на эллиптических кривых) подпись формируется с помощью приватного ключа, а проверяется с помощью публичного ключа. Ассиметричные алгоритмы предпочтительнее, потому что ключ для проверки подписи нужно раздавать всем микросервисам, и он легко может быть скомпроментирован. Имея публичный ключ можно проверить подпись, но нельзя создать новую подпись. Публичный ключ на то и публичный, что его можно раздавать кому угодно. RSA несколько предпочтительнее, потому что он лучше поддерживается библиотеками.
JWT токен проверяется без обращения к каким-либо сервисам или базам данных. Каждый микросервис проверяет подпись и время жизни токена. Именно в этом смысл JWT. Именно это позволяет проверять каждый запрос каждым микросервисом распределённо и независимо.
Ценой этого является то, что JWT токен нельзя отозвать. Раз мы проверяем токен локально, никто не может нам сказать, что этого пользователя уже нет, или что у него уже отобрали права.
Поэтому время жизни JWT токена должно быть небольшим. И нужен refresh токен. Права пользователя и само его существование проверяются, когда используется refresh токен. Именно тогда мы можем сходить в БД и всё проверить, прежде чем выдать новый access токен. Получается, что мы проверяем права доступа с периодичностью, равной времени жизни access токена, но не при каждом запросе к каждому микросервису. И это — хорошо.
Есть некоторая проблема с синхронизацией Разрешений. Запросто могут появляться новые микросервисы, то есть добавляться новые Объекты и Разрешения. Эти новые Разрешения нужно назначать пользователям. Значит, компонент управления пользователями должен знать обо всех имеющихся Разрешениях. Нужно их где-то централизованно собирать.
Для управления пользователями, страницы логина, раздачи токенов и прочих этих штук можно взять готовые решения. Например, посмотрите Keycloak. А в AWS, говорят, эти задачи решает Cognito.