Spring Security: un ejemplo de aplicación web de autorización OAuth2 a través de BitBucket

En este artículo, consideraremos una forma de autorizar a los usuarios en aplicaciones web en Spring Boot usando el protocolo OAuth2 usando un servidor de autorización externo (usando el ejemplo de Bitbucket).

Que queremos conseguir


Supongamos que estamos desarrollando una aplicación web segura que tiene acceso a los recursos del usuario en un servidor externo, por ejemplo, un sistema de integración continua, y Bitbucket actúa como un servidor externo, y no queremos almacenar el nombre de usuario y la contraseña del usuario desde el sistema externo. Es decir, debemos autorizar al usuario a través de Bitbucket para obtener acceso a su cuenta y recursos, además de verificar que es un usuario de nuestra aplicación y hacer que el usuario no nos revele sus credenciales de Bitbucket. Me pregunto cómo hacer esto: bienvenido a cat.

OAuth2


OAuth2 es un protocolo de autorización que le permite proporcionar a un tercero acceso limitado a los recursos protegidos de un usuario sin tener que darle a él (un tercero) un nombre de usuario y contraseña.

OAuth2 define 4 roles:

  • Propietario del recurso
  • Servidor de recursos
  • Servidor de autorizaciones
  • Cliente (aplicación)

El propietario de un recurso es un usuario que usa una aplicación cliente y le permite acceder a su cuenta alojada en un servidor de recursos. El acceso de la aplicación a la cuenta está limitado por los permisos otorgados.

El servidor de recursos aloja cuentas de usuario seguras.
El servidor de autorización autentica al propietario del recurso y emite tokens de acceso. El servidor de autorización puede ser simultáneamente un servidor de recursos.

Una aplicación cliente es una aplicación que quiere acceder a la cuenta y los recursos de un usuario.

En nuestro caso, la aplicación cliente es la aplicación que estamos desarrollando, y Bitbucket será tanto un servidor de autorización como un servidor de recursos.

OAuth2 admite cuatro tipos de autorización: Código de autorización, Implícito, Credenciales de contraseña del propietario del recurso y Credenciales del cliente. No los consideraremos a todos, estamos interesados ​​en el tipo de Código de Autorización. El tipo de código de autorización está optimizado para aplicaciones de servidor en las que el código fuente no está disponible públicamente y el código secreto del cliente puede mantenerse confidencial. Este tipo funciona sobre la base de la redirección, es decir, el usuario será redirigido al servidor de autorización para confirmar su identidad y permitir que la aplicación use su cuenta.

El proceso de autorización a través del Código de autorización consiste en una secuencia de dos solicitudes:

  • Solicitud de autorización
  • Solicitud de token de acceso

Se utiliza una solicitud de autorización para confirmar la identidad del usuario, así como solicitar la autorización de nuestra aplicación al usuario. Esta solicitud es una solicitud GET con los siguientes parámetros:

  • response_type: el valor debe ser igual al código
  • client_id: valor obtenido al registrar un cliente con el proveedor OAuth2
  • redirect_uri - URL donde el usuario será redirigido después de la autorización
  • alcance - un parámetro opcional que indica qué nivel de acceso se solicita
  • estado: cadena generada aleatoriamente para verificar la respuesta

Solicitar ejemplo:

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

Si el usuario confirma su identidad y permite que la aplicación acceda a sus recursos, el agente de usuario será redirigido a la URL de devolución de llamada especificada durante el registro del cliente con el código de parámetro adicional que contiene el código de autorización y el parámetro de estado con el valor pasado en la solicitud.

Ejemplo de devolución de llamada:

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

Una solicitud de código de acceso se utiliza para intercambiar el código de autorización recibido por un código de acceso a los recursos del usuario. Esta solicitud es una solicitud POST con los siguientes parámetros:

  • tipo de concesión: el valor debe ser código de autorización
  • código: código de autorización obtenido en el paso anterior
  • redirect_uri: debe coincidir con la URL especificada en el paso anterior
  • client_id: valor obtenido al registrar un cliente con el proveedor OAuth2
  • client_secret: valor obtenido al registrar un cliente con el proveedor OAuth2

Solicitar ejemplo:

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

La respuesta del servidor contiene el código de acceso y su duración:

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

Todo este proceso ya está automatizado en Spring Security y no debemos preocuparnos por su implementación.

Registro de cliente


En primer lugar, registraremos nuestra aplicación como cliente en Bitbucket para obtener una clave (Clave) y un código de acceso (Secreto del cliente).



Ingrese el nombre del cliente y la URL de devolución de llamada. Luego observamos lo que estará disponible para este cliente.



Los valores Key y ClientSecret obtenidos se almacenan en application.properties:

client_id=ZJsdANfWkJ7jcktw2x
client_secret=28uUrJ9m43svbkcnXVNj8qeBjFtd8jaD

Configurar Spring Security


A continuación, configuremos Spring Security. Para que OAuth2 funcione, debe crear un objeto ClientRegistration. ClientRegistration almacena información sobre el cliente registrado con el proveedor OAuth2. Aquí necesitamos el client_id y client_secret obtenidos en el paso anterior. Dado que puede haber varios objetos de ClientRegistration en general, Spring Security utiliza el objeto ClientRegistrationRepository para almacenarlos y acceder a ellos. Vamos a crearlo también. También indicamos que solo un usuario autorizado puede llamar a cualquier solicitud y redefinir UserService con su implementación.

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))
                    );
        }
    }
}


Servicio de usuario de personalización


Spring Security no solo implementa completamente el proceso de autorización, sino que también brinda la posibilidad de personalizarlo. Por ejemplo, la posibilidad de personalizar una solicitud de autorización , solicitar un código de acceso , así como la posibilidad de procesamiento posterior personalizado de una respuesta a una solicitud de código de acceso . Después de una autorización exitosa, Spring Security usa UserInfo Endpoint para recuperar los atributos del usuario del servidor de autorizaciones. En particular, la implementación de la interfaz OAuth2UserService se utiliza para esto.

Vamos a crear nuestra propia implementación de este servicio, de modo que después de la autorización del usuario en el servidor de autorización, verifiquemos adicionalmente si es un usuario de nuestra aplicación, o la registraremos si el registro está abierto para todos. Por defecto, Spring Security utiliza la implementación de DefaultOAuth2UserService. Él formará la base de nuestra implementación.

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;
    }
}


Punto final de prueba


Es hora de crear un punto final para probar la salud de lo que acabamos de hacer. Nuestro punto final consistirá en una sola solicitud, que dará la bienvenida al usuario actual.

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();
    }
}


Chequeo de salud


Iniciamos la aplicación y vamos a la dirección http: // localhost: 8080 y vemos que fuimos redirigidos al sitio web de Bitbucket para confirmar nuestra cuenta. Ingrese nombre de usuario y contraseña.



Ahora debemos permitir que nuestra aplicación acceda a nuestra cuenta y recursos.



El saludo del usuario contiene ambos atributos del servidor de autorización y la ID de usuario en nuestra aplicación.



Fuente


El código fuente completo para esta aplicación está en Github .

Referencias



All Articles