Перейти к основному содержимому

Пользовательское выражение безопасности с Spring Security

· 8 мин. чтения

1. Обзор

В этом руководстве мы сосредоточимся на создании пользовательского выражения безопасности с помощью Spring Security .

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

Сначала мы обсудим, как создать настраиваемый PermissionEvaluator , затем полностью настраиваемое выражение и, наконец, как переопределить одно из встроенных выражений безопасности.

2. Пользовательская сущность

Во-первых, давайте подготовим основу для создания новых выражений безопасности.

Давайте посмотрим на нашу сущность User , которая имеет привилегии и организацию :

@Entity
public class User{
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

@Column(nullable = false, unique = true)
private String username;

private String password;

@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_privileges",
joinColumns =
@JoinColumn(name = "user_id", referencedColumnName = "id"),
inverseJoinColumns =
@JoinColumn(name = "privilege_id", referencedColumnName = "id"))
private Set<Privilege> privileges;

@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "organization_id", referencedColumnName = "id")
private Organization organization;

// standard getters and setters
}

А вот и наша простая Привилегия :

@Entity
public class Privilege {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

@Column(nullable = false, unique = true)
private String name;

// standard getters and setters
}

И наша Организация :

@Entity
public class Organization {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

@Column(nullable = false, unique = true)
private String name;

// standard setters and getters
}

Наконец, мы будем использовать более простой пользовательский Principal :

public class MyUserPrincipal implements UserDetails {

private User user;

public MyUserPrincipal(User user) {
this.user = user;
}

@Override
public String getUsername() {
return user.getUsername();
}

@Override
public String getPassword() {
return user.getPassword();
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
for (Privilege privilege : user.getPrivileges()) {
authorities.add(new SimpleGrantedAuthority(privilege.getName()));
}
return authorities;
}

...
}

Когда все эти классы готовы, мы собираемся использовать наш пользовательский Principal в базовой реализации UserDetailsService :

@Service
public class MyUserDetailsService implements UserDetailsService {

@Autowired
private UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException(username);
}
return new MyUserPrincipal(user);
}
}

Как видите, в этих отношениях нет ничего сложного — у пользователя есть одна или несколько привилегий, и каждый пользователь принадлежит к одной организации.

3. Настройка данных

Далее — давайте инициализируем нашу базу данных простыми тестовыми данными:

@Component
public class SetupData {
@Autowired
private UserRepository userRepository;

@Autowired
private PrivilegeRepository privilegeRepository;

@Autowired
private OrganizationRepository organizationRepository;

@PostConstruct
public void init() {
initPrivileges();
initOrganizations();
initUsers();
}
}

Вот наши методы инициализации :

private void initPrivileges() {
Privilege privilege1 = new Privilege("FOO_READ_PRIVILEGE");
privilegeRepository.save(privilege1);

Privilege privilege2 = new Privilege("FOO_WRITE_PRIVILEGE");
privilegeRepository.save(privilege2);
}
private void initOrganizations() {
Organization org1 = new Organization("FirstOrg");
organizationRepository.save(org1);

Organization org2 = new Organization("SecondOrg");
organizationRepository.save(org2);
}
private void initUsers() {
Privilege privilege1 = privilegeRepository.findByName("FOO_READ_PRIVILEGE");
Privilege privilege2 = privilegeRepository.findByName("FOO_WRITE_PRIVILEGE");

User user1 = new User();
user1.setUsername("john");
user1.setPassword("123");
user1.setPrivileges(new HashSet<Privilege>(Arrays.asList(privilege1)));
user1.setOrganization(organizationRepository.findByName("FirstOrg"));
userRepository.save(user1);

User user2 = new User();
user2.setUsername("tom");
user2.setPassword("111");
user2.setPrivileges(new HashSet<Privilege>(Arrays.asList(privilege1, privilege2)));
user2.setOrganization(organizationRepository.findByName("SecondOrg"));
userRepository.save(user2);
}

Обратите внимание, что:

  • Пользователь «john» имеет только FOO_READ_PRIVILEGE
  • Пользователь «tom» имеет как FOO_READ_PRIVILEGE , так и FOO_WRITE_PRIVILEGE.

4. Пользовательский оценщик разрешений

На данный момент мы готовы приступить к реализации нашего нового выражения — через новый настраиваемый оценщик разрешений.

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

Давайте начнем.

4.1. PermissionEvaluator

Чтобы создать собственный оценщик разрешений, нам нужно реализовать интерфейс PermissionEvaluator :

public class CustomPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(
Authentication auth, Object targetDomainObject, Object permission) {
if ((auth == null) || (targetDomainObject == null) || !(permission instanceof String)){
return false;
}
String targetType = targetDomainObject.getClass().getSimpleName().toUpperCase();

return hasPrivilege(auth, targetType, permission.toString().toUpperCase());
}

@Override
public boolean hasPermission(
Authentication auth, Serializable targetId, String targetType, Object permission) {
if ((auth == null) || (targetType == null) || !(permission instanceof String)) {
return false;
}
return hasPrivilege(auth, targetType.toUpperCase(),
permission.toString().toUpperCase());
}
}

Вот наш метод hasPrivilege() :

private boolean hasPrivilege(Authentication auth, String targetType, String permission) {
for (GrantedAuthority grantedAuth : auth.getAuthorities()) {
if (grantedAuth.getAuthority().startsWith(targetType) &&
grantedAuth.getAuthority().contains(permission)) {
return true;
}
}
return false;
}

Теперь у нас есть новое выражение безопасности, доступное и готовое к использованию: hasPermission .

Итак, вместо использования более жестко закодированной версии:

@PostAuthorize("hasAuthority('FOO_READ_PRIVILEGE')")

Мы можем использовать использование:

@PostAuthorize("hasPermission(returnObject, 'read')")

или же

@PreAuthorize("hasPermission(#id, 'Foo', 'read')")

Примечание: #id относится к параметру метода, а ' Foo ' относится к типу целевого объекта.

4.2. Конфигурация безопасности метода

Недостаточно определить CustomPermissionEvaluator — нам также нужно использовать его в нашей конфигурации безопасности метода:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {

@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler =
new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(new CustomPermissionEvaluator());
return expressionHandler;
}
}

4.3. Пример на практике

Давайте теперь начнем использовать новое выражение — в нескольких простых методах контроллера:

@Controller
public class MainController {

@PostAuthorize("hasPermission(returnObject, 'read')")
@GetMapping("/foos/{id}")
@ResponseBody
public Foo findById(@PathVariable long id) {
return new Foo("Sample");
}

@PreAuthorize("hasPermission(#foo, 'write')")
@PostMapping("/foos")
@ResponseStatus(HttpStatus.CREATED)
@ResponseBody
public Foo create(@RequestBody Foo foo) {
return foo;
}
}

Вот и все — все готово, и мы используем новое выражение на практике. ``

4.4. Живой тест

Теперь давайте напишем простые живые тесты — запустим API и убедимся, что все в рабочем состоянии:

@Test
public void givenUserWithReadPrivilegeAndHasPermission_whenGetFooById_thenOK() {
Response response = givenAuth("john", "123").get("http://localhost:8082/foos/1");
assertEquals(200, response.getStatusCode());
assertTrue(response.asString().contains("id"));
}

@Test
public void givenUserWithNoWritePrivilegeAndHasPermission_whenPostFoo_thenForbidden() {
Response response = givenAuth("john", "123").contentType(MediaType.APPLICATION_JSON_VALUE)
.body(new Foo("sample"))
.post("http://localhost:8082/foos");
assertEquals(403, response.getStatusCode());
}

@Test
public void givenUserWithWritePrivilegeAndHasPermission_whenPostFoo_thenOk() {
Response response = givenAuth("tom", "111").contentType(MediaType.APPLICATION_JSON_VALUE)
.body(new Foo("sample"))
.post("http://localhost:8082/foos");
assertEquals(201, response.getStatusCode());
assertTrue(response.asString().contains("id"));
}

А вот и наш метод GivenAuth() :

private RequestSpecification givenAuth(String username, String password) {
FormAuthConfig formAuthConfig =
new FormAuthConfig("http://localhost:8082/login", "username", "password");

return RestAssured.given().auth().form(username, password, formAuthConfig);
}

5. Новое выражение безопасности

В предыдущем решении мы смогли определить и использовать выражение hasPermission , что может быть весьма полезным.

Однако здесь мы все еще несколько ограничены именем и семантикой самого выражения.

Итак, в этом разделе мы собираемся перейти к полной настройке — и мы собираемся реализовать выражение безопасности, называемое isMember() — проверяя, является ли принципал членом Организации. ``

5.1. Пользовательское выражение безопасности метода

Чтобы создать это новое пользовательское выражение, нам нужно начать с реализации корневой заметки, с которой начинается оценка всех выражений безопасности:

public class CustomMethodSecurityExpressionRoot 
extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {

public CustomMethodSecurityExpressionRoot(Authentication authentication) {
super(authentication);
}

public boolean isMember(Long OrganizationId) {
User user = ((MyUserPrincipal) this.getPrincipal()).getUser();
return user.getOrganization().getId().longValue() == OrganizationId.longValue();
}

...
}

Теперь, как мы предоставили эту новую операцию прямо в корневой заметке здесь; isMember() используется для проверки того, является ли текущий пользователь членом данной организации .

Также обратите внимание, как мы расширили SecurityExpressionRoot , включив в него встроенные выражения.

5.2. Пользовательский обработчик выражений

Затем нам нужно внедрить наш CustomMethodSecurityExpressionRoot в наш обработчик выражений:

public class CustomMethodSecurityExpressionHandler 
extends DefaultMethodSecurityExpressionHandler {
private AuthenticationTrustResolver trustResolver =
new AuthenticationTrustResolverImpl();

@Override
protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
Authentication authentication, MethodInvocation invocation) {
CustomMethodSecurityExpressionRoot root =
new CustomMethodSecurityExpressionRoot(authentication);
root.setPermissionEvaluator(getPermissionEvaluator());
root.setTrustResolver(this.trustResolver);
root.setRoleHierarchy(getRoleHierarchy());
return root;
}
}

5.3. Конфигурация безопасности метода

Теперь нам нужно использовать наш CustomMethodSecurityExpressionHandler в конфигурации безопасности метода:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
CustomMethodSecurityExpressionHandler expressionHandler =
new CustomMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(new CustomPermissionEvaluator());
return expressionHandler;
}
}

5.4. Использование нового выражения

Вот простой пример защиты нашего метода контроллера с помощью isMember() :

@PreAuthorize("isMember(#id)")
@GetMapping("/organizations/{id}")
@ResponseBody
public Organization findOrgById(@PathVariable long id) {
return organizationRepository.findOne(id);
}

5.5. Живой тест

Наконец, вот простой живой тест для пользователя « john »:

@Test
public void givenUserMemberInOrganization_whenGetOrganization_thenOK() {
Response response = givenAuth("john", "123").get("http://localhost:8082/organizations/1");
assertEquals(200, response.getStatusCode());
assertTrue(response.asString().contains("id"));
}

@Test
public void givenUserMemberNotInOrganization_whenGetOrganization_thenForbidden() {
Response response = givenAuth("john", "123").get("http://localhost:8082/organizations/2");
assertEquals(403, response.getStatusCode());
}

6. Отключить встроенное выражение безопасности

Наконец, давайте посмотрим, как переопределить встроенное выражение безопасности — мы обсудим отключение hasAuthority() .

6.1. Пользовательский корень выражения безопасности

Мы начнем аналогичным образом с написания нашего собственного SecurityExpressionRoot — в основном потому, что встроенные методы являются окончательными , и поэтому мы не можем их переопределить:

public class MySecurityExpressionRoot implements MethodSecurityExpressionOperations {
public MySecurityExpressionRoot(Authentication authentication) {
if (authentication == null) {
throw new IllegalArgumentException("Authentication object cannot be null");
}
this.authentication = authentication;
}

@Override
public final boolean hasAuthority(String authority) {
throw new RuntimeException("method hasAuthority() not allowed");
}
...
}

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

6.2. Пример — использование выражения

Теперь, если мы хотим использовать hasAuthority() для защиты методов, как показано ниже, будет выброшено исключение RuntimeException , когда мы попытаемся получить доступ к методу:

@PreAuthorize("hasAuthority('FOO_READ_PRIVILEGE')")
@GetMapping("/foos")
@ResponseBody
public Foo findFooByName(@RequestParam String name) {
return new Foo(name);
}

6.3. Живой тест

Наконец, вот наш простой тест:

@Test
public void givenDisabledSecurityExpression_whenGetFooByName_thenError() {
Response response = givenAuth("john", "123").get("http://localhost:8082/foos?name=sample");
assertEquals(500, response.getStatusCode());
assertTrue(response.asString().contains("method hasAuthority() not allowed"));
}

7. Заключение

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

И, как всегда, полный исходный код можно найти на GitHub .