Spring: Roles dinâmicas no Spring Security

Boa parte das pessoas que estudam Spring Security aprendem a deixar fixo no código a associação entre url’s e roles.

http.authorizeRequests()
.antMatchers("/produtos/form").hasRole("ADMIN")
.antMatchers("/shopping/**").permitAll()
.antMatchers(HttpMethod.POST,"/produtos").hasRole("ADMIN")
.antMatchers("/produtos/**").permitAll()
.antMatchers(HttpMethod.GET, "/admin/users").permitAll()
.anyRequest().authenticated()

Porém, essa talvez não seja a melhor forma de escrever e/ou expor no código suas url’s e roles, fora que se a lógica de negócio mudasse teria que abrir o código e mudar as url’s ou roles associadas. Bem ruim essa abordagem, né?!

Há pouco tempo atrás, na empresa onde trabalho, em um projeto novo, precisávamos de uma abordagem bem parecida com essa de relacionamento entre as roles e url’s, porém um pouco mais complexa, pois envolvia também o token, utilizado no cabeçalho da chamada HTTP. A partir daí percebi que precisávamos verificar dinamicamente esta associação entre as roles e as url’s direto do Banco de Dados.

Mas como fazer isso no Spring Security? Pesquisei em vários locais a respeito do assunto, mas não encontrei nada que eu pudesse dizer “é isso!!“. Esse tipo de mapeamento dinâmico, de certa forma, é até citado pela própria documentação do Spring que diz que esse tipo de associação é uma parte importante do seu sistema e, portanto, deve ser testada para garantir que tudo esteja configurado da maneira correta, caso você opte pelo processo ser feito de forma dinâmica.

Depois de muito fuçar a documentação de filters do Spring, cheguei na classe FilterSecurityInterceptor. É justamente ela a responsável por checar as roles liberadas para cada url. O melhor jeito de ter acesso a este objeto é pedindo para o Spring Security nos notificar quando ele tiver sido criado, pois dessa forma basta que façamos algumas pequenas alterações.

http.authorizeRequests()
                .anyRequest()
                .authenticated()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        //Implementação aqui
                        return o;
                    }
                })

Existe um método no builder da classe HttpSecurity que devemos utilizar para processar algum objeto criado pela configuração. Esse método é o withObjectPostProcessor. Perfeito para o que queremos.

O objeto do tipo FilterSecurityInterceptor possui um método chamado setSecurityMetadataSource,  que recebe como argumento algum objeto que implementa a interface FilterInvocationSecurityMetadataSource. Precisamos criar uma implementação dessa interface para poder realizar nossa lógica de pegar os dados do Banco de Dados, nesse caso as url’s e roles.

private final DynamicSecurityMetadataSource dynamicSecurityMetadataSource;

    public SpringSecurityConfig(DynamicSecurityMetadataSource dynamicSecurityMetadataSource) {
        this.dynamicSecurityMetadataSource = dynamicSecurityMetadataSource;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                .anyRequest()
                .authenticated()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        o.setSecurityMetadataSource(dynamicSecurityMetadataSource);
                        return o;
                    }
                })
                .and()
                .logout().logoutRequestMatcher(new AntPathRequestMatcher("/logout"));
        http.csrf().disable();
    }

Esse código acima é um trecho da minha classe de configuração do Spring Security. Injetei então uma outra classe, que dei o nome de DynamicSecurityMetadataSource, que implementa a interface FilterInvocationSecurityMetadataSource e passei ela como argumento do método setSecurityMetadataSource.

/**
 * @author João Faro on 30/06/20
 * @version 1.0.0
 */
@Component
@Transactional
@Slf4j
public class DynamicSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    private final RequestScopeBean requestScopeBean;
    private final RoleActionRepository actionRepository;
    private final TokenHistoryRepository tokenHistoryRepository;

    public DynamicSecurityMetadataSource(RoleActionRepository actionRepository, TokenHistoryRepository tokenHistoryRepository, RequestScopeBean requestScopeBean) {
        this.actionRepository = actionRepository;
        this.tokenHistoryRepository = tokenHistoryRepository;
        this.requestScopeBean = requestScopeBean;
    }

    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        final HttpServletRequest request = ((FilterInvocation) o).getRequest();
        String methodAction = request.getMethod();
        String urlWithoutContextPath = StringUtils.substringAfter(request.getRequestURI(), "anyPath/");

        log.info("getting token and clientId claim...");
        String token = JwtUtil.getToken(request);
        String clientId = JwtUtil.getClaim(token, "clientId");
        String username = JwtUtil.getClaim(token, "preferred_username");
        log.info("clientId is: "+ clientId);
        log.info("username is: "+ username);

        log.info("building token history...");
        PGTokenHist tokenHist = TokenHistoryBuilder
                .newTokenHist()
                .withClientId(clientId)
                .withUsername(username)
                .withToken(token)
                .build();
        log.info(tokenHist.getClass().getSimpleName() + ": "+ tokenHist);

        Optional<PGAction> pgAction = actionRepository.findPGActionByMethodAndName(MethodAction.getEnumByCode(methodAction),
                urlWithoutContextPath);
        pgAction.ifPresent(tokenHist::setAction);

        log.info("saving token access history...");
        tokenHistoryRepository.save(tokenHist);
        log.info("token saved successfully");

        log.info("putting into the object scope bean...");
        requestScopeBean.setScopeTokenId(tokenHist.getId());
        log.info(requestScopeBean.getClass().getSimpleName() +": "+ requestScopeBean);

        return pgAction.<Collection<ConfigAttribute>>map(action -> action.getRoles().stream()
                .map(this::configAttribute)
                .collect(Collectors.toList()))
                .orElse(null);
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return FilterInvocation.class.isAssignableFrom(aClass);
    }

    private ConfigAttribute configAttribute(PGRole PGRole) {
        return new ConfigAttribute() {
            @Override
            public String getAttribute() {
                return PGRole.getAuthority();
            }
        };
    }
}

A classe que escrevi é um pouco extensa, possui uma lógica de negócio que envolve pegar alguns dados do token no momento da requisição (Token gerado pelo Keycloak, falo a respeito dele em um artigo anterior), portanto vou explicar o que de fato importa para nossa abordagem.

A interface ConfigAttribute é a abstração de uma permissão. Por exemplo, quando usamos o modo default usamos a implementação WebExpressionConfigAttribute. No nosso caso, simplesmente usamos uma classe anônima que implementa a interface, mas também poderíamos ter criado uma classe chamada, por exemplo, de RoleConfigAttribute. Nesse caso minha entity PGRole precisou implementar a interface GrantedAuthority.

return pgAction.<Collection<ConfigAttribute>>map(action -> action.getRoles().stream()
                .map(this::configAttribute)
                .collect(Collectors.toList()))
                .orElse(null);

Este trecho acima é onde consigo carregar a minha lista de Roles, graças ao stream introduzido no Java 8. Fiz com que os métodos da minha interface de JpaRepository retornassem Optional<> para melhor trabalhar com a stream (Aconselho fortemente trabalhar com Optionals). Vou escrever um artigo mais pra frente sobre algumas técnicas que utilizam essas features que ajudam na escrita de um código mais coeso.

Bom, voltando ao que interessa, um último detalhe muito importante. Quando uma nova requisição é feita, para o Spring Security decidir se o usuário pode acessar o recurso ou não, é usada uma implementação da interface AccessDecisionManager.

Quando usamos as configurações estáticas, o único voter utilizado é o WebExpressionVoter. Só que, no meu caso, precisei de uma implementação baseada simplesmente em roles . Para isso que existe a classe RoleVoter. Apenas vamos ensinar ao Spring Security que queremos usar outro AcessDecisionManager.

@Override
    protected void configure(HttpSecurity http) throws Exception {
        AffirmativeBased affirmativeBased = new AffirmativeBased(Arrays.asList(new RoleVoter(), new WebExpressionVoter()));
        http.authorizeRequests().accessDecisionManager(affirmativeBased)
                .anyRequest()
                .authenticated()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        o.setSecurityMetadataSource(dynamicSecurityMetadataSource);
                        return o;
                    }
                })
                .and()
                .logout().logoutRequestMatcher(new AntPathRequestMatcher("/logout"));
        http.csrf().disable();
    }

Pronto! Desse jeito eu consigo buscar as roles e as url’s dinamicamente do Banco de Dados.

Espero que tenham curtido esse artigo, qualquer coisa é só comentar ou, se preferir, entre em contato. Até o próximo artigo!