Spring Security - um exemplo de aplicativo da Web de autorização OAuth2 por meio do BitBucket

Neste artigo, consideraremos uma maneira de autorizar usuários em aplicativos Web no Spring Boot usando o protocolo OAuth2 usando um servidor de autorização externo (usando o exemplo de Bitbucket).

O que queremos obter


Suponha que estamos desenvolvendo um aplicativo Web seguro que tenha acesso aos recursos do usuário em um servidor externo, por exemplo, um sistema de integração contínua, e o Bitbucket atue como um servidor externo, e não desejaríamos armazenar o nome de usuário e a senha do usuário no sistema externo. Ou seja, precisamos autorizar o usuário através do Bitbucket para obter acesso à sua conta e recursos, além disso, verifique se ele é usuário de nosso aplicativo e faça com que ele não nos revele suas credenciais do Bitbucket. Gostaria de saber como fazer isso - bem-vindo ao gato.

OAuth2


OAuth2 é um protocolo de autorização que permite fornecer a terceiros acesso limitado aos recursos protegidos de um usuário sem precisar fornecer a ela (terceiros) um nome de usuário e senha.

OAuth2 define quatro funções:

  • Proprietário do recurso
  • Servidor de recursos
  • Servidor de autorização
  • Cliente (aplicativo)

Um proprietário de recurso é um usuário que usa um aplicativo cliente e permite que ele acesse sua conta hospedada em um servidor de recursos. O acesso de aplicativos à conta é limitado pelas permissões concedidas.

O servidor de recursos hospeda contas de usuário seguras.
O servidor de autorização autentica o proprietário do recurso e emite tokens de acesso. O servidor de autorização pode ser simultaneamente um servidor de recursos.

Um aplicativo cliente é um aplicativo que deseja acessar a conta e os recursos de um usuário.

No nosso caso, o aplicativo cliente é o aplicativo que estamos desenvolvendo e o Bitbucket será um servidor de autorização e um servidor de recursos.

O OAuth2 suporta quatro tipos de autorização: código de autorização, implícito, credenciais de senha do proprietário do recurso e credenciais do cliente. Não consideraremos todos eles, estamos interessados ​​no tipo de código de autorização. O tipo de código de autorização é otimizado para aplicativos de servidor nos quais o código fonte não está disponível ao público e o código secreto do cliente pode ser mantido em sigilo. Esse tipo funciona com base no redirecionamento, ou seja, o usuário será redirecionado ao servidor de autorização para confirmar sua identidade e permitir que o aplicativo use sua conta.

O processo de autorização por meio do código de autorização consiste em uma sequência de duas solicitações:

  • Pedido de Autorização
  • Solicitação de token de acesso

Uma solicitação de autorização é usada para confirmar a identidade do usuário, bem como solicitar autorização do nosso aplicativo ao usuário. Esta solicitação é uma solicitação GET com os seguintes parâmetros:

  • response_type - o valor deve ser igual ao código
  • client_id - valor obtido ao registrar um cliente no provedor OAuth2
  • redirect_uri - URL para o qual o usuário será redirecionado após a autorização
  • escopo - um parâmetro opcional que indica qual nível de acesso é solicitado
  • state - sequência gerada aleatoriamente para verificar a resposta

Exemplo de solicitação:

GET https://server.example.com/authorize?response_type=code&client_id=CLIENT_ID&state=xyz&redirect_uri=REDIRECT_URI

Se o usuário confirmar sua identidade e permitir que o aplicativo acesse seus recursos, o agente do usuário será redirecionado para a URL de retorno de chamada especificada durante o registro do cliente com o código de parâmetro adicional que contém o código de autorização e o parâmetro state com o valor passado na solicitação.

Exemplo de retorno de chamada:

GET https://client.example.com/cb?code=AUTH_CODE_HERE&state=xyz

Uma solicitação de código de acesso é usada para trocar o código de autorização recebido por um código de acesso aos recursos do usuário. Esta solicitação é uma solicitação POST com os seguintes parâmetros:

  • grant_type - o valor deve ser permission_code
  • code - código de autorização obtido na etapa anterior
  • redirect_uri - deve corresponder ao URL especificado na etapa anterior
  • client_id - valor obtido ao registrar um cliente no provedor OAuth2
  • client_secret - valor obtido ao registrar um cliente com o provedor OAuth2

Exemplo de solicitação:

POST https://server.example.com/token
    grant_type=authorization_code&
    code=AUTH_CODE&
    redirect_uri=REDIRECT_URI&
    client_id=CLIENT_ID&
    client_secret=CLIENT_SECRET

A resposta do servidor contém o código de acesso e sua vida útil:

{
    "access_token": "2YotnFZFEjr1zCsicMWpAA",
    "expires_in": 3600,
    "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA"
}

Todo esse processo já está automatizado no Spring Security e não precisamos nos preocupar com sua implementação.

Cadastro de cliente


Primeiro, registraremos nosso aplicativo como cliente no Bitbucket para obter uma chave (Chave) e um código de acesso (Segredo do Cliente).



Digite o nome do cliente e o URL de retorno de chamada. Depois, observamos o que estará disponível para esse cliente.



Os valores Key e ClientSecret obtidos são armazenados em application.properties:

client_id=ZJsdANfWkJ7jcktw2x
client_secret=28uUrJ9m43svbkcnXVNj8qeBjFtd8jaD

Configurar Spring Security


Em seguida, vamos configurar o Spring Security. Para o OAuth2 funcionar, você deve criar um objeto ClientRegistration. ClientRegistration armazena informações sobre o cliente registrado com o provedor OAuth2. Aqui precisamos do client_id e client_secret obtidos na etapa anterior. Como pode haver vários objetos ClientRegistration em geral, o Spring Security usa o objeto ClientRegistrationRepository para armazená-los e acessá-los. Vamos criar também. Também indicamos que apenas um usuário autorizado pode chamar qualquer solicitação e redefinir o UserService com sua implementação.

SecurityConfig.java
@Configuration
public class SecurityConfig {

    @Value("${client_id}")
    private String clientId;

    @Value("${client_secret}")
    private String clientSecret;

    @Bean
    public ClientRegistration clientRegistration() {
        return ClientRegistration
                .withRegistrationId("bitbucket")
                .clientId(clientId)
                .clientSecret(clientSecret)
                .userNameAttributeName("username")
                .clientAuthenticationMethod(BASIC)
                .authorizationGrantType(AUTHORIZATION_CODE)
                .userInfoUri("https://api.bitbucket.org/2.0/user")
                .tokenUri("https://bitbucket.org/site/oauth2/access_token")
                .authorizationUri("https://bitbucket.org/site/oauth2/authorize")
                .redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
                .build();
    }

    @Bean
    @Autowired
    public MyOAuth2UserService oAuth2userService(UserService userService) {
        return new MyOAuth2UserService(userService);
    }

    @Bean
    @Autowired
    public ClientRegistrationRepository clientRegistrationRepository(List<ClientRegistration> registrations) {
        return new InMemoryClientRegistrationRepository(registrations);
    }

    @Configuration
    @EnableWebSecurity
    public static class AuthConfig extends WebSecurityConfigurerAdapter {

        private final MyOAuth2UserService userService;

        @Autowired
        public AuthConfig(MyOAuth2UserService userService) {
            this.userService = userService;
        }

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .authorizeRequests(authorizeRequests -> authorizeRequests
                            .anyRequest().authenticated()
                    )
                    .oauth2Login(oauth2Login -> oauth2Login
                            .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(userService))
                    );
        }
    }
}


Customização UserService


O Spring Security não apenas implementa completamente o processo de autorização, mas também oferece a capacidade de personalizá-lo. Por exemplo, a possibilidade de personalizar uma solicitação de autorização , solicitar um código de acesso , bem como a possibilidade de pós-processamento personalizado de uma resposta a uma solicitação de código de acesso . Após a autorização bem-sucedida, o Spring Security usa o UserInfo Endpoint para recuperar atributos do usuário do servidor de autorização. Em particular, a implementação da interface OAuth2UserService é usada para isso.

Vamos criar nossa própria implementação deste serviço, para que, após a autorização do usuário no servidor de autorização, verifiquemos adicionalmente se ele é usuário do nosso aplicativo ou registre-o se o registro estiver aberto a todos. Por padrão, o Spring Security usa a implementação de DefaultOAuth2UserService. Ele formará a base da nossa implementação.

MyOAuth2UserService.java
public class MyOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private static final String MISSING_USER_INFO_URI_ERROR_CODE = "missing_user_info_uri";

    private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";

    private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";

    private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<Map<String, Object>>() {
    };

    private final UserService userService;

    private final RestOperations restOperations;

    private final Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = new OAuth2UserRequestEntityConverter();

    public MyOAuth2UserService(UserService userService) {
        this.userService = requireNonNull(userService);
        this.restOperations = createRestTemplate();
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        checkNotNull(userRequest, "userRequest cannot be null");
        if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
            OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE, "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        }

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        if (!StringUtils.hasText(userNameAttributeName)) {
            OAuth2Error oauth2Error = new OAuth2Error(
                    MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
                    "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + registrationId, null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        }

        ResponseEntity<Map<String, Object>> response;
        try {
            // OAuth2UserRequestEntityConverter cannot return null values.
            //noinspection ConstantConditions
            response = this.restOperations.exchange(requestEntityConverter.convert(userRequest), PARAMETERIZED_RESPONSE_TYPE);
        } catch (OAuth2AuthorizationException ex) {
            OAuth2Error oauth2Error = ex.getError();
            StringBuilder errorDetails = new StringBuilder();
            errorDetails.append("Error details: [");
            errorDetails.append("UserInfo Uri: ").append(
                    userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri());
            errorDetails.append(", Error Code: ").append(oauth2Error.getErrorCode());
            if (oauth2Error.getDescription() != null) {
                errorDetails.append(", Error Description: ").append(oauth2Error.getDescription());
            }
            errorDetails.append("]");
            oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
                    "An error occurred while attempting to retrieve the UserInfo Resource: " + errorDetails.toString(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
        } catch (RestClientException ex) {
            OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
                    "An error occurred while attempting to retrieve the UserInfo Resource: " + ex.getMessage(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
        }

        Map<String, Object> userAttributes = emptyIfNull(response.getBody());

        Set<GrantedAuthority> authorities = new LinkedHashSet<>();
        authorities.add(new OAuth2UserAuthority(userAttributes));

        for (String authority : userRequest.getAccessToken().getScopes()) {
            authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
        }

        //     ,   
        //          ,
        //    
        User user = findOrCreate(userAttributes);
        userAttributes.put(MyOAuth2User.ID_ATTR, user.getId());
        return new MyOAuth2User(userNameAttributeName, userAttributes, authorities);
    }

    private User findOrCreate(Map<String, Object> userAttributes) {
        String login = (String) userAttributes.get("username");
        String username = (String) userAttributes.get("display_name");

        Optional<User> userOpt = userService.findByLogin(login);
        if (!userOpt.isPresent()) {
            User user = new User();
            user.setLogin(login);
            user.setName(username);
            return userService.create(user);
        }
        return userOpt.get();
    }

    private RestTemplate createRestTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
        return restTemplate;
    }
}


Ponto final de teste


É hora de criar um ponto de extremidade para testar a integridade do que acabamos de fazer. Nosso endpoint consistirá em apenas uma solicitação, que receberá o usuário atual.

WelcomeEndpoint.java
@Path("/")
public class WelcomeEndpoint {

    @Autowired
    private UserService userService;

    @GET
    public String welcome() {
        User currentUser = getCurrentUser();
        return String.format("Welcome, %s! (user id: %s, user login: %s)",
                currentUser.getName(), currentUser.getId(), currentUser.getLogin());
    }

    public User getCurrentUser() {
        return userService.findByLogin(getAuthenticatedUser().getName()).orElseThrow(() -> new RuntimeException("No user logged in."));
    }

    private Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }

    private MyOAuth2User getAuthenticatedUser() {
        return (MyOAuth2User) getAuthentication().getPrincipal();
    }
}


Exame de saúde


Iniciamos o aplicativo e vamos para o endereço http: // localhost: 8080 e vemos que fomos redirecionados para o site Bitbucket para confirmar nossa conta. Digite o nome de usuário e a senha.



Agora, precisamos permitir que nosso aplicativo acesse nossa conta e recursos.



A saudação do usuário contém os dois atributos do servidor de autorização e o ID do usuário em nosso aplicativo.



Fonte


O código fonte completo para este aplicativo está no Github .

Referências



All Articles