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

Настраиваемое выражение безопасности с помощью Spring Security

1. обзор

В этом руководстве мы сосредоточимся наcreating a custom security expression with Spring Security.

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

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

2. Пользовательский объект

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

Давайте посмотрим на нашу сущностьUser, у которой естьPrivileges иOrganization:

@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 privileges;

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

    // standard getters and setters
}

А вот и наш простойPrivilege:

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

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

    // standard getters and setters
}

И нашOrganization:

@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 getAuthorities() {
        List authorities = new ArrayList();
        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();
    }
}

Вот наши методыinit:

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(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(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)) {
            if (grantedAuth.getAuthority().contains(permission)) {
                return true;
            }
        }
    }
    return false;
}

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

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

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

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

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

or

@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')")
    @RequestMapping(method = RequestMethod.GET, value = "/foos/{id}")
    @ResponseBody
    public Foo findById(@PathVariable long id) {
        return new Foo("Sample");
    }

    @PreAuthorize("hasPermission(#foo, 'write')")
    @RequestMapping(method = RequestMethod.POST, value = "/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:8081/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:8081/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:8081/foos");
    assertEquals(201, response.getStatusCode());
    assertTrue(response.asString().contains("id"));
}

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

private RequestSpecification givenAuth(String username, String password) {
    FormAuthConfig formAuthConfig =
      new FormAuthConfig("http://localhost:8081/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() используется для проверки, является ли текущий пользователь участником данногоOrganization.

Также обратите внимание, как мы расширили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)")
@RequestMapping(method = RequestMethod.GET, value = "/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:8081/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:8081/organizations/2");
    assertEquals(403, response.getStatusCode());
}

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

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

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

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

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')")
@RequestMapping(method = RequestMethod.GET, value = "/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:8081/foos?name=sample");
    assertEquals(500, response.getStatusCode());
    assertTrue(response.asString().contains("method hasAuthority() not allowed"));
}

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

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

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