Гайд по авторизации в Micronaut
Всем привет! В предыдущей статье я описал процесс аутентификации в Micronaut
. Но аутентификация без авторизации мертва! Ну или крайне мало применима. Поэтому предлагаю погрузиться в эту тему и оценить различные механизмы авторизации в данном фреймворке, а заодно узнать, как это всё тестировать.
Для начала — краткая выжимка по тому, что такое аутентификация и авторизация, чтобы больше не путаться:
Аутентификация – процесс идентификации пользователя, при котором система может однозначно определить, является ли субъект действительным и имеющим право на доступ. Субъект в данном случае необязательно живой человек, пользователем вашей системы так же могут быть другие системы, микросервисы внутри вашей архитектуры, и т.д. Если говорить проще, то аутентификация это просто подтверждение учетных данных через предоставленный механизм (Basic, OAuth2.0, etc.).
Авторизация – процесс установления прав доступа и разрешений для пользователя или системы с целью определения, какие ресурсы и функционал они могут использовать в информационной системе. это процесс проверки пользователя на выполнение тех или иных действий.
Скелет приложения с аутентификацией я собираюсь использовать из своей предыдущей статьи, сделав, однако, апгрейд до четвертой версии Micronaut
, которая вышла в июле этого года.
Тут можно найти список изменений, которые мне пришлось сделать:
Добавлен snakeyaml и micronaut-serde-processor.
Добавлен блок aot с оптимизациями.
Убрана зависимость jakarta.
Немного переписан OpenIdUserDetailsMapper.
Добавлены gradle плагины.
Если рассматривать авторизацию в Micronaut
только с технической части, то можно отказаться от базы данных, так как никто не мешает заинжектить jwtTokenGenerator
и просто вставлять нужный набор ролей (и в тестах так и будет, потому что как иначе?). Но, во-первых, само приложение без этого не будет настоящим и с ним нельзя будет поиграться на локалке. Во-вторых, это просто неинтересно. Так что некоторый код я добавлю, но не стоит воспринимать это, как руководство к действию, а скорее как некоторый минимально живой шаблон, который нужно допиливать.
Описание таблиц
Для начала стоит немного рассказать про структуру базы данных. Всего будут четыре таблицы: user
, roles
, rules
и roles_rules
. Вот их описание:
Схема таблиц
Можно сказать, что это джентльменский набор при реализации авторизации. В целом, всё достаточно прозаично, поэтому не будем надолго здесь останавливаться, лишь обозначим некоторые условности:
Придерживаемся политики: всё, что не разрешено – запрещено.
Права атомарны, каждое отвечает за конкретное действие.
Роль может иметь сколько угодно прав, но дубликатов быть не должно.
Пользователь может иметь только одну роль.
Какие методы авторизации поддерживает Micronaut?
Решение об успешном или неуспешном ответе принимает SecurityFilter
благодаря коллекции определенных заранее правил безопасности. Micronaut
изначально содержит четыре типа правил безопасности: IpPatternRule
, SecuredAnnotation
, ConfigurationInterceptUrlMapRule
, SensetiveEndpoitRule
. Если же этих правил недостаточно, то всегда можно добавить еще, имплементировав SecurityRule
.
Правила безопасности возвращают publisher
, который содержит единственный статус SecurityRuleResult
. Ниже представлена таблица с описанием этих результатов:
Результат | Описание |
---|---|
ALLOWED | Доступ должен быть предоставлен, и больше никакие правила проверяться не будут. |
REJECTED | Доступ должен быть запрещен, и больше никакие правила проверяться не будут. |
UNKNOWN | Правило не может определить результат, поэтому будут рассматриваться следующие правила. |
Важные моменты, которые стоит отметить:
Если все определенные заранее правила вернут результат UNKNOWN, то запрос будет отклонен со статусом REJECTED.
SecurityFilter
проверяет правила по очереди. Как только одно правило возвращает ALLOWED или REJECTED, проверка правил прекращается.
Правила безопасности имплементируют интерфейс Ordered
, так все существующие правила безопасности содержат статическую переменную ORDER
. Правила проверяются по восходящему порядку. Таким образом, создав свое правило, вы можете определить, в какую очередь оно будет проверяться.
В табличке ниже представлены все встроенные правила:
Правило | Порядок (Order) | Условие принятия | Условие отказа | Условие с результатом неизвестно |
---|---|---|---|---|
IpPatternsRule | -300 | Никогда. | Ни один из шаблонов IP не соответствует адресу хоста. | Адрес соответствует хотя бы одному из шаблонов, или адрес не может быть определен. |
SecuredAnnotationRule | -200 | Хотя бы одна из необходимых указанных ролей есть у пользователя. | У пользователя нет ни одной роли, указанной в аннотации. | Нет аннотаций безопасности у данного ednpoint. |
ConfigurationInterceptMapRule | -100 | Хотя бы одна из необходимых указанных ролей есть у пользователя. | У пользователя нет ни одной роли, указанной в конфигурации. | Ни один URL-паттерн не совпадает с запросом. |
SensetiveEnpoitRule | 0 | Пользователь аутентифицирован. | Пользователь не аутентифицирован. | Endpoint не является sensitive. |
Давайте поговорим про каждое немного подробнее.
Блокировка по IP
По умолчанию, когда модуль безопасности активен, ваш сервер доступен с любого IP-адреса. Однако, это можно ограничить, добавив следующие строчки в application.yml
:
micronaut:
security:
ip-patterns:
- 127.0.0.1
- 192.168.1.*
С помощью кода, приведенного выше, можно запретить доступ к серверу со всех адресов, которые не перечислены. При этом, такое правило не разрешает никаких действий, а только отклоняет все запросы, которые пришли с других IP-адресов.
URL-правила
Для регулирования доступа к вашим ресурсам можно использовать указание правил доступа через адрес.
micronaut:
security:
intercept-url-map:
-
pattern: /images/*
http-method: GET
access:
- isAnonymous()
-
pattern: /books
access:
- isAuthenticated()
-
pattern: /books/grails
http-method: POST
access:
- ROLE_GRAILS
- ROLE_GROOVY
-
pattern: /books/grails
http-method: PUT
access:
- ROLE_ADMIN
В приведенном выше фрагменте кода можно увидеть несколько правил:
Все адреса, начинающиеся с
/images/**
, доступны для аутентифицированных и анонимных пользователей с помощьюGET
-метода.Адрес
/books
доступен для всех аутентифицированных пользователей.Адрес
/books/grails
доступен для пользователей, у которых есть хотя бы одна роль из перечисленных (ROLE_GRAILS
,ROLE_GROOVY
) и происходитPOST
-запрос.Тот же адрес доступен только для пользователей с ролью
ROLE_ADMIN
, если происходитPUT
-запрос.
Таким образом, в данном листинге любой endpoint может быть определен с помощью паттерна адреса, и дополнительно может быть указан метод, по которому доступен данный endpoint.
Если запрос может подойти под несколько правил, определенных вами, будет использовано то, которое дополнительно определяет еще и метод запроса. Если же несколько правил используют один и тот же паттерн, но не определяют метод запроса, будет использовано то, которое идет первым. Например:
micronaut:
security:
intercept-url-map:
- pattern: /v1/myResource/**
httpMethod: GET
access:
- isAnonymous()
- pattern: /v1/myResource/**
access:
- isAuthenticated()
В примере выше определяется, что все GET
-запросы с паттерном /v1/myResource/**
доступны для любых пользователей. Запросы, которые используют тот же паттерн, но их метод отличен от GET
, будут доступны только для аутентифицированных пользователей.
Аннотации
Настало время поговорить про самый популярный и, в то же время самый гибкий способ – аннотации. В первую очередь, надо упомянуть про стандартную для Micronaut
аннотацию – @Secured
.
@Controller("/example")
@Secured(SecurityRule.IS_AUTHENTICATED) // Доступ к этому контроллеру по умолчанию есть только у аутентифицированных пользователей
public class ExampleController {
@Produces(MediaType.TEXT_PLAIN)
@Get("/admin")
@Secured({"ROLE_ADMIN", "ROLE_X"}) // Доступ к этому endpoint есть у пользователей с ролями ROLE_ADMIN или ROLE_X
public String withroles() {
return "You have ROLE_ADMIN or ROLE_X roles";
}
@Produces(MediaType.TEXT_PLAIN)
@Get("/anonymous")
@Secured(SecurityRule.IS_ANONYMOUS) // Доступ к этому endpoint есть у всех пользователей, даже неаутентифицированных
public String anonymous() {
return "You are anonymous";
}
@Produces(MediaType.TEXT_PLAIN)
@Get("/authenticated") // Применяется правило, объявленное в начале файла
public String authenticated(Authentication authentication) {
return authentication.getName() + " is authenticated";
}
}
Аннотации Jakarta
Также можно использовать аннотации Jakarta
:
jakarta.annotation.security.PermitAll
jakarta.annotation.security.RolesAllowed
jakarta.annotation.security.DenyAll
@Controller("/example")
public class ExampleController {
@Produces(MediaType.TEXT_PLAIN)
@Get("/admin")
@RolesAllowed({"ROLE_ADMIN", "ROLE_X"}) // Доступ к endpoint есть у пользователей с ролями ROLE_ADMIN или ROLE_X
public String withroles() {
return "You have ROLE_ADMIN or ROLE_X roles";
}
@Produces(MediaType.TEXT_PLAIN)
@Get("/anonymous")
@PermitAll // Разрешает доступ до endpoint всем пользователям
public String anonymous() {
return "You are anonymous";
}
}
Выражения в аннотациях
Начиная с четвертой версии, в Micronaut
появилась возможность использовать выражения в аннотации @Secured
. Например:
@Controller("/authenticated")
public class ExampleController {
@Secured("#{ user?.attributes?.get('email') == 'sherlock@micronaut.example' }")
@Produces(MediaType.TEXT_PLAIN)
@Get("/email")
public String authenticationByEmail(Principal principal) {
return principal.getName() + " is authenticated";
}
}
В данном примере, доступ до endpoint есть только у пользователя с адресом электронной почты sherlock@micronaut.example
.
Обзор механизма авторизации
Процесс авторизации состоит из нескольких понятных действий:
Сначала происходит чтение конфигурации и инициализация бинов.
Например,
ConfigurationInterceptUrlMapRule
, который в свою очередь распознает бины типаInterceptUrlMapPattern
благодаря свойствам, объявленным вapplication.yml
свойствомmicronaut.security.intercept-url-map
.Или
SecuredAnnotationRule
, который отвечает за обработку@Secured
аннотаций. Так же стоит отметить, что правила безопасности через аннотации по приоритету выше, чем URL-правила.
При поступлении запроса отрабатывает цепочка фильтров, которые выполнят проверку на доступ. Следующие шаги будут объяснены для URL-правила, хотя разница между ними и аннотациями не сильно большая.
HttpHeaderTokenReader
– сначала будет произведен поиск JWT в запросе.DefaultTokenResolver
– распознавание токена.JwtValidator
– валидация токена.SecurityFilter
– сопоставление токена с параметрами. Кто выпустил, когда, когда истекает, какие роли и атрибуты есть.IpPatternsRule
– проверка на совпадение запроса по IP-правилу.InterceptUrlMapRule
– поиск совпадение по URL и типу запроса.AbstractSecurityRule
– валидация правила.SecurityFilter
– если предыдущий шаг завершился успешно, то данный фильтр примет решение об авторизации данного запроса.
Как это тестировать
Итак, встает вопрос, как же протестировать, что только пользователь сможет воспользоваться нашим API при соблюдении определенных условий? К счастью, Micronaut
предоставляет весь необходимый функционал, и сам процесс достаточно прост.
@MicronautTest // (1)
public class AnnotationTest {
@Inject
JwtTokenGenerator jwtTokenGenerator; // (2)
@Inject
@Client("/annotation")
HttpClient client; // (3)
@Test
public void shouldReturn200_whenGoingToOnlyAuthenticated_givenAuthentication() {
var authentication = Authentication.build("testUser", List.of("ROLE_SOME")); // (4)
var token = jwtTokenGenerator.generateToken(authentication, 1000); // (5)
var request = HttpRequest.GET("/only-authenticated")
.accept(MediaType.TEXT_PLAIN_TYPE)
.cookie(Cookie.of("JWT", token.get())); // (6)
var response = client.toBlocking().exchange(request, String.class);
assertEquals(HttpStatus.OK, response.getStatus()); // (7)
assertEquals("Allowed only to authenticated users", response.body()); // (7)
}
}
Для начала указываем, что нужно поднимать контекст для данного теста.
Инжектим генератор JWT токенов. Нужно отметить, что для теста совсем не важно, какая фраза используется для генерации токенов.
Также потребуется подключить клиент, для того чтобы симулировать как можно более точно сам процесс запроса от пользователя.
Начинается самое интересное: создаем объект
Authentication
– наш фейковый пользователя обретает какую-то идентичность и теперь имеетuserName
и даже рольROLE_SOME
.Следующий шаг – сгенерировать токен с помощью
jwtTokenGenerator
.В зависимости от того, какая стратегия аутентификации выбрана –
cookie
илиbearer
, нужно выбрать, как именно прикрепить только что созданный токен к запросу. Мне больше нравится именноcookie
-аутентификация, но вы можете выбрать то, что больше нравится вам.Собственно, теперь самое главное – убедиться, что всё работает. В данном случае ожидаем ответ
OK
с заданной строкой в теле ответа.
Другие примеры тестирования можно найти в репозитории.
Там есть примеры тестов для авторизации на основе аннотаций и URL-правил. Тестов на основе ip-адреса нет, потому что это достаточно непросто сделать на локальной машине.
Заключение
На этом разбор работы механизма авторизации в Micronaut
можно считать завершенным. Надеюсь, было интересно и познавательно. На всякий случай еще раз оставлю здесь ссылку на мою статью об аутентификации.