Содержание
Создаем кастомную реализацию интерфейса AuthenticationProvider
Объединяем аннотацию PreAuthorize и кастомный сервис проверки ролей
Когда речь заходит об авторизации, роли вступают в игру. Если модель плоская, то все очевидно. Пользователь обладает определенным набором привелегий и при запросе достаточно лишь проверить, что нужное право доступа присутствует в коллекции. Но как быть, если у пользователя могут быть разные наборы ролей для разных сущностей? Например, я обладаю ролью EDITOR
в посте в социальной сети, но имею только VIEWER
в другом. Также могут быть определены правила наследования. Если администратор выдает мне роль EDITOR
, то я автоматически приобретаю привилегию VIEWER
. При этом, если я EDITOR
, роль ADMIN
у меня не появляется.
Как увязать все эти детали в коде и при этом не превратить продукт в большую кучу грязи? В рамках этой статьи я расскажу вам:
1. Как реализовать наследование ролей в Java?
2. Как протестировать полученную иерархию?
3. Как применить решение в рамках Spring Security?
Вы можете найти примеры кода и весь нижеописанный проект в этом GitHub-репозитории.
Бизнес-требования и доменная модель
Предположим, что мы разрабатываем социальную сеть. Посмотрите на диаграмму ниже. Здесь я обозначил бизнес-сущности, с которыми мы будет работать в рамках этой статьи.
Речь пойдет о трех ключевых сущностях:
User
— это тот, кто читает существующие посты и создает новыеCommunity
— это лента, куда можно публиковать новые постыPost
— это отдельная единица медиаинформации. Некоторые пользователи могут просматривать пост, редактировать его и удалять.
Хочу отметить, что наша ролевая модель немного сложнее, чем обычное присваивание плоских ролей. Например, пользователь может обладать ролью EDITOR
для определенного поста, но иметь только VIEWER
для другого. Или человек может иметь ADMIN
для сообщества ‘Cats and dogs’, но только MODERATOR
для ‘Motorcycles and Heavy metal’.
Роли предоставляют следующие привилегии:
CommunityRole.ADMIN
дает полный доступ к сообществу и всем постам внутри него.CommunityRole.MODERATOR
предоставляет возможность добавлять новые посты и удалять уже существующие в рамках определенного сообщества.PostRole.EDITOR
дает право менять содержимое определенного поста.PostRole.REPORTER
предоставляет доступ отправлять жалобы на неподобающее поведение в комментариях определенного поста.PostRole.VIEWER
дает возможность просматривать конкретный пост.
Тем не менее, бизнес также желает видеть наследование ролей. Например, если я обладаю корневой ролью, то также имею право выполнять действия, которые открывают доступ children nodes
. Посмотрите на схему ниже, чтобы понять принцип, о котором я говорю.
Предположим, что я MODERATOR
в каком-то сообществе. Это значит, я что я также EDITOR
, REPORTER
и VIEWER
для всех постов в рамках данного сообщества. С другой стороны, если кто-то дал мне роль REPORTER
для конкретного поста, это не значит, что я могу его редактировать (для этого нужна роль EDITOR
), или добавлять новые посты (роль MODERATOR
предоставляет такое право).
Такой подход удобен, потому что отсутствует необходимость проверять множество ролей для каждой операции. Достаточно лишь узнать присутствие самой нижней привилегии, которая дает право на выполнения действия. Если я ADMIN
в сообществе, то я и VIEWER
для каждого поста в нем. Так что благодаря наследованию проверить нужно только VIEWER
, а не все роли выше по иерархии.
Как бы то ни было, способ выглядит не очень простым для реализации в коде. К тому же, у VIEWER
есть два родителя: REPORTER
и EDITOR
. В Java нет множественного наследования, так что нам понадобится более интересный подход.
Роли, enums и наследование
Роли — идеальный кандидат для enum. Посмотрите на пример кода ниже.
public enum CommunityRoleType {
ADMIN, MODERATOR
}
public enum PostRoleType {
VIEWER, EDITOR, REPORTER
}
Как построить наследование, используя обычные enum? В первую очередь давайте объявим интерфейс, чтобы объединить роли одним типом. Посмотрите на его декларацию ниже.
public interface Role {
boolean includes(Role role);
}
Интерфейс Role
будет базовым для любого значения CommunityRoleType
и PostRoleType
. Метод includes
проверяет, равна ли та роль, что мы передали в методе, текущей, или же содержится она в children nodes
, или нет.
Посмотрите на измененный код PostRoleType
ниже.
public enum PostRoleType implements Role {
VIEWER, EDITOR, REPORTER;
private final Set<Role> children = new HashSet<>();
static {
REPORTER.children.add(VIEWER);
EDITOR.children.add(VIEWER);
}
@Override
public boolean includes(Role role) {
return this.equals(role) || children.stream().anyMatch(r -> r.includes(role));
}
}
Мы сохраняем children
конкретной node
в обычной Java-коллекции HashSet
в поле private final
. Интересный момент в том, как связи между nodes
образуются. По умолчанию коллекция children
пустая для каждого значения enum. Но в игру в вступает static initializer block. Вы можете воспринимать его как двухфазный конструктор. В этом блоке нам нужно установить правильные связи между parent
и child
. Сам же метод includes
довольно примитивен:
Если переданная
role
равнаthis
, возвращаемtrue
.Иначе вызываем функцию рекурсивно для каждого
child
.
Реализация для CommunityRoleType
очень похожа. Посмотрите на блок кода ниже.
public enum CommunityRoleType implements Role {
ADMIN, MODERATOR;
private final Set<Role> children = new HashSet<>();
static {
ADMIN.children.add(MODERATOR);
MODERATOR.children.addAll(List.of(PostRoleType.EDITOR, PostRoleType.REPORTER));
}
@Override
public boolean includes(Role role) {
return this.equals(role) || children.stream().anyMatch(r -> r.includes(role));
}
}
Как видите, у MODERATOR
два объекта children
: PostRoleType.EDITOR
и PostRoleType.REPORTER
. Из-за того, что и CommunityRoleType
, и PostRoleType
расширяют один и тот же интерфейс, значения из обоих enum-ов могут быть частью общей иерархии наследования.
Осталась небольшая деталь. Нам нужно знать корень всей иерархии. Самый простой способ — добавление static
метода, который вернет нужную node
. Посмотрите на исправленный код интерфейса Role
ниже.
public interface Role {
boolean includes(Role role);
static Set<Role> roots() {
return Set.of(CommunityRoleType.ADMIN);
}
}
Я возвращаю
Set<Role>
вместоRole
, потому что теоретически у иерархии может быть несколько корней. В такой ситуации нет смысла ограничивать количествоroots
до одного на уровне сигнатуры метода.Кто-то может спросить: «Почему ты не используешь Spring Security Role Hierarchy? Это ведь готовое решение». Данный компонент удобен для плоских ролей, но у нас ситуация иного рода. Я сделаю ссылку на
RoleHierarchy
дальше по ходу повествования.
Unit-тестирование иерархии ролей с наследованием
Давайте протестируем построенную иерархию ролей. Сначала проверим, что в графе нет циклов, которые могут привести к StackOverflowError. Посмотрите на тест ниже.
@Test
void shouldNotThrowStackOverflowException() {
final var roots = Role.roots();
final var existingRoles = Stream.concat(
stream(PostRoleType.values()),
stream(CommunityRoleType.values())
).toList();
assertDoesNotThrow(
() -> {
for (Role root : roots) {
for (var roleToCheck : existingRoles) {
root.includes(roleToCheck);
}
}
}
);
}
Идея заключается в проверке комбинаций всех roots
и всех существующих ролей на вызов includes
. Ни один из них не должен бросить исключения.
Следующий шаг — валидация наследования. Проверим кейсы:
Роль
CommunityRoleType.ADMIN
должна включать любую другую роль и в том числе себя.Роль
CommunityRoleType.MODERATOR
должна включать роли:PostRoleType.EDITOR
,PostRoleType.REPORTER
,PostRoleType.VIEWER
иCommunityRoleType.MODERATOR
.Роль
PostRoleType.VIEWER
не должна включать рольPostRoleType.EDITOR
.Роль
CommunityRoleType.MODERATOR
не должна включатьCommunityRoleType.ADMIN
.
Посмотрите ниже на тест, который валидирует обозначенные кейсы.
@ParameterizedTest
@MethodSource("provideArgs")
void shouldIncludeOrNotTheGivenRoles(Role root, Set<Role> rolesToCheck, boolean shouldInclude) {
for (Role role : rolesToCheck) {
assertEquals(
shouldInclude,
root.includes(role)
);
}
}
private static Stream<Arguments> provideArgs() {
return Stream.of(
arguments(
CommunityRoleType.ADMIN,
Stream.concat(
stream(PostRoleType.values()),
stream(CommunityRoleType.values())
).collect(Collectors.toSet()),
true
),
arguments(
CommunityRoleType.MODERATOR,
Set.of(PostRoleType.EDITOR, PostRoleType.VIEWER, PostRoleType.REPORTER, CommunityRoleType.MODERATOR),
true
),
arguments(
PostRoleType.VIEWER,
Set.of(PostRoleType.REPORTER),
false
),
arguments(
CommunityRoleType.MODERATOR,
Set.of(CommunityRoleType.ADMIN),
false
)
);
}
Существуют намного больше кейсов, которые следует покрыть тестами. Но для простоты я их не указываю.
Определяем JPA-сущности
Вы можете найти декларацию всех JPA-сущностей проекта здесь, а соответствующие Flyway-миграции — здесь. Тем не менее, я покажу вам главные entity в системе. Посмотрите ниже на декларацию PostRole
и CommunityRole
.
@Entity
@Table(name = "community_role")
public class CommunityRole {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "community_id")
private Community community;
@Enumerated(STRING)
private CommunityRoleType type;
}
@Entity
@Table(name = "post_role")
public class PostRole {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "post_id")
private Post post;
@Enumerated(STRING)
private PostRoleType type;
}
Как я уже упомянал, CommunityRole
привязывается к User
и Community
. А PostRole
— к User
и Post
. Следовательно, структура ролей не плоская. Это привносит определенные сложности при интеграции со Spring Security, но не беспокойтесь. Я покажу вам, как их преодолеть.
Как раз из-за вертикальной ролевой модели, Spring Security Role Hierarchy не сработает. Нам нужен более глубокий подход. Так что двигаемся дальше.
Посмотрите на SQL для создания таблиц post_role
и community_role
(я использую PostgreSQL).
CREATE TABLE community_role
(
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users (id) NOT NULL,
community_id BIGINT REFERENCES community (id) NOT NULL,
type VARCHAR(50) NOT NULL,
UNIQUE (user_id, community_id, type)
);
CREATE TABLE post_role
(
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users (id) NOT NULL,
post_id BIGINT REFERENCES post (id) NOT NULL,
type VARCHAR(50) NOT NULL,
UNIQUE (user_id, post_id, type)
);
Создаем кастомную реализацию интерфейса Authentication
Прежде всего нужно создать реализацию интерфейса Authentication
, с которой мы будем работать. Посмотрите на пример кода ниже.
@RequiredArgsConstructor
public class PlainAuthentication implements Authentication {
private final Long userId;
private volatile boolean authenticated = true;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return emptySet();
}
@Override
public Long getPrincipal() {
return userId;
}
@Override
public String getName() {
return "";
}
@Override
public boolean isAuthenticated() {
return authenticated;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
authenticated = isAuthenticated;
}
@Override
public Object getCredentials() {
return "";
}
@Override
public Object getDetails() {
return null;
}
}
Почему метод getAuthorities() возвращает пустой set?
Первое, что бросается в глаза, — метод getAuthorities()
всегда возвращает пустую коллекцию. Почему? Если вы хотите в рамках getAuthorities()
возвращать список привелегий, которые есть у пользователя для постов и сообществ, это может выглядеть примерно так:
CommunityRole_ADMIN_234
PostRole_VIEWER_896
Роль разделяется на три части:
Тип роли
Значение роли
ID сообщества или поста, на который эта роль ссылается
При такой настройке проверка ролей станет неудобной. Посмотрите на пример использования @PreAuthorize
ниже.
@PreAuthorize("hasAuthority('PostRole_VIEWER_' + #postId)")
@GetMapping("/api/post/{postId}")
public PostResponse getPost(@PathVariable Long postId) { ... }
На мой взгляд, этот код ужасен попахивает. Во-первых, применение string typings может привести к трудноуловимым багам (например, можно случайно неправильно составить название роли). Во-вторых, мы теряем идею наследования. Помните, что роль CommunityRoleType.ADMIN
также включает PostRoleType.VIEWER
? Но здесь мы проверяем лишь конкретную authority
(Spring Security просто вызывает метод Collection.contains
). Это значит, что метод Authentication.getAuthorities()
должен возвращать все children
роли в том числе. Следовательно, придется провернуть такие шаги:
Выбираем роли всех сообществ и постов, которые назначены пользователю
Проходимся по каждой вплоть до последнего
child node
и складываем результаты в отдельную коллекциюУдаляем дубликаты (или просто используем
HashSet
)Строим название ролей по паттерну выше и возвращаем в качестве
authorities
Помимо того, что код становится сложнее и запутанее, добавляются и проблемы с производительностью. Каждый запрос от клиента будет сопровождаться выбором всех ролей, которые есть у пользователя из БД. Но что если пользователь уже админ в каком-то сообществе? Нет смысла запрашивать роли на уровне поста, потому что у пользователя уже и так гарантировано есть доступ на выполнение операции. Но это придется сделать, чтобы аннотация @PreAuthorize
работала ожидаемым образом во всех случаях.
Думаю, теперь понятно, почему я возвращаю пустую коллекцию в качестве вызова метода getAuthorities()
. Позже мы рассмотрим, как правильно с этим обращаться.
UserId и флаг volatile на поле authenticated
Взгляните еще раз на объявление PlainAuthentication ниже. Я оставил только методы getPrincipal
и isAuthenticated/setAuthenticated
для обсуждения.
@RequiredArgsConstructor
public class PlainAuthentication implements Authentication {
private final Long userId;
private volatile boolean authenticated = true;
@Override
public Long getPrincipal() {
return userId;
}
@Override
public boolean isAuthenticated() {
return authenticated;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
authenticated = isAuthenticated;
}
...
}
Значение userId
указывает на строку в БД, где хранится информация о пользователе. Позже мы будем использовать этот ID, чтобы получить роли. Методы isAuthenticated/setAuthenticated
являются частью контракта Spring Security. Так что нужно реализовать их правильно. Я добавил маркер volatile
, потому что класс PlainAuthentication
мутабелен и теоретически его экземпляр может использоваться разными потоками. Так что лучше перестраховаться, чтобы не иметь дело с неожиданными багами.
Метод
getPrincipal
объявлен в интерфейсеAuthentication
и его возвращаемое значение —Object
. Но Java позволяет возвращать наследников в методах, которые реализуют интерфейс, или наследуют суперкласс (в данном случае, мы возвращаемLong
). Это позволяет сделать код более типизированным и безопасным, если мы будем работать с реализацией напрямую.Интерфейс Authentication также обязуют реализовать еще три метода, о которых я не говорил:
getName
,getCredentials
иgetDetails
. Ни один из них мы не будем использовать далее по коду, так что я просто возвращаю значения по умолчанию.
Создаем кастомную реализацию интерфейса AuthenticationProvider
Чтобы корректно привязывать объект PlainAuthentication
к текущему Security Context, нам понадобится своя реализация AuthenticationProvider
. Посмотрите на блок кода ниже.
@Component
@RequiredArgsConstructor
class DbAuthenticationProvider implements AuthenticationProvider {
private final UserRepository userRepository;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
final var password = authentication.getCredentials().toString();
if (!"password".equals(password)) {
throw new AuthenticationServiceException("Invalid username or password");
}
return userRepository.findByName(authentication.getName())
.map(user -> new PlainAuthentication(user.getId()))
.orElseThrow(() -> new AuthenticationServiceException("Invalid username or password"));
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.equals(authentication);
}
}
Конечно, это не production-ready решение. Для простоты у всех пользователей одинаковый пароль — password
. Если мы нашли пользователя в БД по указанному имени, возвращаем его ID в виде обертки PlainAuthentication
. Иначе — бросаем AuthenticationServiceException
, которое будет преобразовано в код ошибки 401
.
Создаем конфиг Spring Security
Пришло время добавить конфиг Spring Security. В данном примере я использую basic access authentication. Тем не менее, паттерны проверки ролей останутся такими же для любого иного механизма аутентификации. Посмотрите на пример кода ниже.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
@SneakyThrows
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
return http
.csrf().disable()
.cors().disable()
.authorizeHttpRequests(customizer -> customizer.anyRequest().authenticated())
.httpBasic()
.authenticationEntryPoint((request, response, authException) -> response.sendError(401))
.and()
.build();
}
}
Определяем методы REST API
Доменная модель дает много вариантов для возможных операций. Но я приведу всего 4 из них. Этого будет достаточно, чтобы понять идею наследования и проверки ролей, которую я хочу до вас донести. У нас будут следующие endpoints:
Создать новое сообщество
Создать пост в указанном сообществе
Поменять название поста по id
Получить пост по id
Посмотрите на пример кода ниже.
@RestController
@RequestMapping("/api")
public class Controller {
@PostMapping("/community")
@PreAuthorize("isAuthenticated()")
public CommunityResponse createCommunity(@RequestParam String name) { ... }
@PostMapping("/community/{communityId}/post")
// Must have CommunityRoleType.MODERATOR role
public PostResponse createPost(@PathVariable Long communityId, @RequestParam String name) { ... }
@PutMapping("/post/{postId}")
// Must have PostRoleType.EDITOR
public void updatePost(@PathVariable Long postId, @RequestParam String name) { ... }
@GetMapping("/post/{postId}")
// Must have PostRoleType.VIEWER role
public PostResponse getPost(@PathVariable Long postId) { ... }
}
Как видите, любой пользователь может создать новое сообщество (в нем он автоматически становится админом). Однако я еще не реализовал проверки ролей на остальных endpoints, а только оставил комментарии. Этим мы сейчас и займемся.
Создаем кастомный сервис проверки ролей
Посмотрите на шаблон сервиса проверки ролей ниже.
@Service("RoleService")
public class RoleService {
public boolean hasAnyRoleByCommunityId(Long communityId, Role... roles) { ... }
public boolean hasAnyRoleByPostId(Long postId, Role... roles) { ... }
}
Я вручную задал название bean в аннотации
@Service
. Позже я объясню, зачем это нужно.
Здесь всего два метода. Первый проверяет наличие одной из переданных ролей по communityId
, а второй — по postId
.
Посмотрите на реализациюhasAnyRoleByCommunityId
ниже. Второй метод написан практически так же и вы можете посмотреть код всего класса по этой ссылке.
@Service("RoleService")
@RequiredArgsConstructor
public class RoleService {
private final CommunityRoleRepository communityRoleRepository;
private final PostRoleRepository postRoleRepository;
@Transactional
public boolean hasAnyRoleByCommunityId(Long communityId, Role... roles) {
final Long userId = ((PlainAuthentication) SecurityContextHolder.getContext().getAuthentication()).getPrincipal();
final Set<CommunityRoleType> communityRoleTypes =
communityRoleRepository.findRoleTypesByUserIdAndCommunityId(userId, communityId);
for (Role role : roles) {
if (communityRoleTypes.stream().anyMatch(communityRoleType -> communityRoleType.includes(role))) {
return true;
}
}
final Set<PostRoleType> postRoleTypes =
postRoleRepository.findRoleTypesByUserIdAndCommunityId(userId, communityId);
for (Role role : roles) {
if (postRoleTypes.stream().anyMatch(postRoleType -> postRoleType.includes(role))) {
return true;
}
}
return false;
}
...
}
Алгоритм такой:
Получаем
Authentication
пользователя путем вызоваSecurityContextHolder.getContext().getAuthentication()
и «кастуем» результат кPlainAuthentication
, потому что это единственный тип, с которым работает наше приложение.Находим все роли на уровне сообщества по
userId
иcommunityId
. Если среди переданных ролей есть хотя бы одна в полученном списке из БД (согласно вышеописанной модели наследования), возвращаемtrue
. Иначе — переходим к следующему шагу.Находим все роли на уровне поста по
userId
иcommunityId
. Если среди переданных ролей есть хотя бы одна в полученном списке из БД (согласно вышеописанной модели наследования), возвращаемtrue
. Иначе — возвращаемfalse
.
Я также хочу обратить ваше внимание на преимущества в производительности при использовании подхода, который я вам предлагаю. В классическом решении в выборке сразу всех существующих ролей и последующим вызовом Authentication.getAuthorities
нам придется вытащить все CommunityRole
и все PostRole
, которые есть у пользователя. А ведь человек может состоять в десятке сообществ и иметь отдельные права на сотни постов. Но в рамках этой статьи мы выполняем нужные действия более эффективно:
Вытаскиваем
CommunityRole
только для переданной комбинации(userId, communityId)
.Вытаскиваем
PostRole
только для переданной комбинации(userId, communityId)
.
Если первый шаг сработал, возвращаем true
и не выполняем лишних запросов в БД.
Объединяем аннотацию PreAuthorize и кастомный сервис проверки ролей
Наконец-то мы почти готовы к применению механизма проверки ролей на уровне endpoints. Почему почти? Посмотрите на пример кода ниже и обратите внимание на то, что в нем можно улучшить.
@RestController
@RequestMapping("/api")
public class Controller {
@PostMapping("/community")
@PreAuthorize("isAuthenticated()")
public CommunityResponse createCommunity(@RequestParam String name) { ... }
@PostMapping("/community/{communityId}/post")
@PreAuthorize("@RoleService.hasAnyRoleByCommunityId(#communityId, T(com.example.demo.domain.CommunityRoleType).MODERATOR)")
public PostResponse createPost(@PathVariable Long communityId, @RequestParam String name) { ... }
@PutMapping("/post/{postId}")
@PreAuthorize("@RoleService.hasAnyRoleByPostId(#postId, T(com.example.demo.domain.PostRoleType).EDITOR)")
public void updatePost(@PathVariable Long postId, @RequestParam String name) { ... }
@GetMapping("/post/{postId}")
@PreAuthorize("@RoleService.hasAnyRoleByPostId(#postId, T(com.example.demo.domain.PostRoleType).VIEWER)")
public PostResponse getPost(@PathVariable Long postId) { ... }
}
@RoleService
ссылается на соответствующий Spring bean RoleService
по его названию. Затем я вызываю необходимый метод для проверки доступа. Параметры postId
и communityId
являются аргументами методов в классе Controller
, так что при их использовании в SpEL нужно добавлять префикс с решеткой. Последние параметры — это varargs от ролей, который требуется проверить. Так как Role
является базовым интерфейсом для всех enum-реализаций, мы можем подставлять конкретное значение через fully qualified name
.
Как вы уже могли догадаться, такое выражение T(com.example.demo.domain.CommunityRoleType).MODERATOR
имеет несколько проблем:
Код становится сложнее читать, а также возникает провокация на copy-paste development.
Если поменять название
package
, где расположен enum с ролью, также придется редактировать все API методы, где он использовался.
Короткие и элегантные ссылки на enum-ы в SpEL-выражениях
Благо есть решение лучше. Посмотрите на исправленный вариант CommunityRoleType
ниже.
public enum CommunityRoleType implements Role {
ADMIN, MODERATOR;
...
@Component("CommunityRole")
@Getter
static class SpringComponent {
private final CommunityRoleType ADMIN = CommunityRoleType.ADMIN;
private final CommunityRoleType MODERATOR = CommunityRoleType.MODERATOR;
}
}
Нужно всего лишь добавить Spring bean, который будет инкапсулировать все значения enum в качестве полей. Теперь мы можем обращаться к ним так же, как и к RoleService
.
Изменения
PostRoleType
будут схожи. Вы можете посмотреть код по этой ссылке.
Давайте немного отрефакторим декларацию API методов. Посмотрите на финальный вариант Controller
ниже.
@RestController
@RequestMapping("/api")
public class Controller {
@PostMapping("/community")
@PreAuthorize("isAuthenticated()")
public CommunityResponse createCommunity(@RequestParam String name) { ... }
@PostMapping("/community/{communityId}/post")
@PreAuthorize("@RoleService.hasAnyRoleByCommunityId(#communityId, @CommunityRole.ADMIN)")
public PostResponse createPost(@PathVariable Long communityId, @RequestParam String name) { ... }
@PutMapping("/post/{postId}")
@PreAuthorize("@RoleService.hasAnyRoleByPostId(#postId, @PostRole.EDITOR)")
public void updatePost(@PathVariable Long postId, @RequestParam String name) { ... }
@GetMapping("/post/{postId}")
@PreAuthorize("@RoleService.hasAnyRoleByPostId(#postId, @PostRole.VIEWER)")
public PostResponse getPost(@PathVariable Long postId) { ... }
}
Намного более красивое решение, как думаете? Проверка ролей декларативна и даже не технари смогут понять логику, которая здесь реализуется (возможно вы захотите сгененировать документацию по правилам разграничения доступа на endpoints).
Интеграционное тестирование и проверка security
Если у вас нет тестов, то нельзя быть уверенным, что код хоть как-то работает. Так что давайте напишем несколько. Я буду проверять следующие кейсы:
Если пользователь не аутентифицирован, запрос на создание нового сообщества возврщает
401
.Если пользователь аутентифицирован, он должен успешно создать новое сообщество и пост внутри него.
Если пользователь аутентифицирован, но у него нет прав на просмотр поста, запрос должен возвращать
403
.
Я использую Testcontainers, чтобы запустить PostgreSQL во время тестов. Объяснение настройки окружения выходит за рамки статьи. Но как бы то ни было, вы можете посмотреть весь тестовый класс целиком по этой ссылке.
Посмотрите на пример теста с проверкой запроса от неаутентифицированного пользователя.
@Test
void shouldReturn401IfUnauthorizedUserTryingToCreateCommunity() {
userRepository.save(User.newUser("john"));
final var communityCreatedResponse =
rest.postForEntity(
"/api/community?name={name}",
null,
CommunityResponse.class,
Map.of("name", "community_name")
);
assertEquals(UNAUTHORIZED, communityCreatedResponse.getStatusCode());
}
Результат теста ожидаемый.
Здесь ничего сложного. Давайте пойдем дальше и проверим успешного создание сообщества и поста внутри него. Посмотрите на пример кода ниже.
@Test
void shouldCreateCommunityAndPostSuccessfully() {
userRepository.save(User.newUser("john"));
final var communityCreatedResponse =
rest.withBasicAuth("john", "password")
.postForEntity(
"/api/community?name={name}",
null,
CommunityResponse.class,
Map.of("name", "community_name")
);
assertTrue(communityCreatedResponse.getStatusCode().is2xxSuccessful());
final var communityId = communityCreatedResponse.getBody().id();
final var postCreatedResponse =
rest.withBasicAuth("john", "password")
.postForEntity(
"/api/community/{communityId}/post?name={name}",
null,
PostResponse.class,
Map.of("communityId", communityId, "name", "post_name")
);
assertTrue(postCreatedResponse.getStatusCode().is2xxSuccessful());
}
Пользователь john
создает новое сообщество, а затем добавляет пост внутри него. Опять же, тест проходит успешно.
Перейдем к последнему кейсу. Если у пользователя нет роли PostRoleType.VIEWER
(или любого родителя) для определенного поста, то запрос на его получение должен возвращать 403
. Посмотрите на пример кода ниже.
@Test
void shouldReturn403IfUserHasNoAccessToViewThePost() {
userRepository.save(User.newUser("john"));
userRepository.save(User.newUser("bob"));
// john creates new community and post inside it
...
final var postViewResponse =
rest.withBasicAuth("bob", "password")
.getForEntity(
"/api/post/{postId}",
PostResponse.class,
Map.of("postId", postId)
);
assertEquals(FORBIDDEN, postViewResponse.getStatusCode());
}
Создание нового сообщества и поста такое же, как и в предыдущем тесте. Так что я пропускаю эту часть, чтобы сконцентрировать внимание на важных деталях
Есть два пользователя: john
и bob
. John
создаем сообщество и пост для него, а bob
пытается получить этот пост по id. Так как у bob
нет требуемых привилегий, сервер должен вернуть 403
. Посмотрите на результат выполнения ниже.
Осталась последняя проверка. Запустим все тесты разом, чтобы убедиться, что они детерминированны и не зависят друг от друга.
Все работает как часы. Превосходно!
Заключение
Spring Security прекрасно ладит со сложными иерархиями ролевой модели и правилами наследования. Чуть-чуть паттернов и ваш код сияет.
Это все, что я хотел рассказать о применении ролевой модели в рамках Spring Security. Если у вас есть вопросы, предложения, то пожалуйста оставляйте комментарии ниже. Если материал статьи был для вас полезен, нажмите лайк и поделитесь ссылкой с друзьями и коллегами.
Спасибо вам большое, что прочитали этот длинный материал до конца!
Ссылки
Комментарии (5)
dididididi
00.00.0000 00:00+2Что по мне, вы уже затащили бизнес логику в магическую спринговую секюрность. Прекрасно, что смогли, но делать так наверно не стоит, потому что, никто кроме вас это починить не сможет.
dimkus
00.00.0000 00:00Поддерживаю.
У автора видимо подразумевается небольшое приложение с зашитыми в код возможными ролями.
Но сама изначальная идея про роли подразумевает назначение любых ролей на отдельные сегменты приложения, для доступа к которым пользователь должен содержать указанные роли исходя от бизнес задачи, т.е. должна быть разделена бизнес логика и техническая реализация, а в статье оно смешано.
OlegZH
00.00.0000 00:00Многое не понятно. Не хватает некоторых разъяснений и связок между фрагментами текста для новичка, желающего разобраться в теме. Немного даже обидно, потому что подозреваю глубокую работу, он оценить в полном объёме статью пока не могу.
Придётся задавать много дополнительных вопросов. Не возражаете?
maiorBoltach
Спасибо за статью.
Сам буквально месяца полтора назад прошёл путь имплементации полновесного ABAC по примерно такому же пути, только через AuthorizationManagerBeforeMethodInterceptor.
1. Есть ли какие-то проверки "на износ" под нагрузкой? В какой момент имеет смысл приделать кэширование или достаточно просто просто ходить на каждый запрос в БД?
2. С практической зрения какое решение будет более эффективное: кастомное через PreAuthorize или Spring ACL?
kirekov Автор
Спасибо за фидбек.
Целесообразность кэширования нужно определять по результатам load testing. Если система и так справляется, то не вижу в этом смысла, так как кэширование неизбежно усложняет код и может добавить баги в плане консистентности данных. А последнее для ролей крайне важно. Например, вы забрали роль у человека, а в кэше она осталась. То есть он продолжает получать доступ, как и раньше. Вообще, как я вижу, гораздо лучше разбираться в причинах, почему запрос на выборку ролей тормозит. А их может быть много: проблема n + 1, отсутствие нужных индексов, лишние запросы, которые можно устранить, и так далее.
Не применял Spring ACL, так что здесь не могу что-то посоветовать. Но исходя из инфы, которую нашел на Baeldung, как я понимаю, Spring ACL также следует концепции плоской модели (если я ошибаюсь, поправьте меня). А в таком случае проблемы будут все те же самые, как и при классическом вызове метода
Authentication.getAuthorities()
.