Spring Security - An OAuth2 Authorization Web Application Example Through BitBucket

In this article, we will consider a way to authorize users in web applications on Spring Boot using the OAuth2 protocol using an external authorization server (using the example of Bitbucket).

What do we want to get


Suppose we are developing a secure web application that has access to user resources on an external server, for example, a continuous integration system, and Bitbucket acts as an external server, and we would not want to store the username and password of the user from the external system. That is, we need to authorize the user through Bitbucket in order to access his account and resources, additionally check that he is a user of our application and make it so that the user does not disclose to us his Bitbucket credentials. I wonder how to do this - welcome to cat.

OAuth2


OAuth2 is an authorization protocol that allows you to provide a third party with limited access to a user's protected resources without having to give her (a third party) a username and password.

OAuth2 defines 4 roles:

  • Resource owner
  • Resource server
  • Authorization server
  • Client (application)

A resource owner is a user who uses a client application and allows him access to his account hosted on a resource server. Application access to the account is limited by granted permissions.

The resource server hosts secure user accounts.
The authorization server authenticates the owner of the resource and issues access tokens. The authorization server can simultaneously be a resource server.

A client application is an application that wants to access a user’s account and resources.

In our case, the client application is the application that we are developing, and Bitbucket will be both an authorization server and a resource server.

OAuth2 supports four types of authorization: Authorization Code, Implicit, Resource Owner Password Credentials, and Client Credentials. We will not consider them all, we are interested in the type of Authorization Code. The Authorization Code type is optimized for server applications in which the source code is not publicly available and the Client Secret code can be kept confidential. This type works on the basis of redirection, that is, the user will be redirected to the authorization server in order to confirm his identity and allow the application to use his account.

The authorization process through the Authorization Code consists of a sequence of two requests:

  • Authorization Request
  • Access Token Request

An authorization request is used to confirm the identity of the user, as well as request authorization of our application from the user. This request is a GET request with the following parameters:

  • response_type - the value must be equal to code
  • client_id - value obtained when registering a client with OAuth2 provider
  • redirect_uri - URL where the user will be redirected after authorization
  • scope - an optional parameter indicating which access level is requested
  • state - randomly generated string to verify the response

Request example:

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

If the user confirms his identity and allows the application access to his resources, the user agent will be redirected to the callback URL specified during registration of the client with the additional parameter code containing the authorization code and state parameter with the value passed in the request.

Callback example:

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

An access code request is used to exchange the received authorization code for an access code to user resources. This request is a POST request with the following parameters:

  • grant_type - value must be authorization_code
  • code - authorization code obtained in the previous step
  • redirect_uri - must match the URL specified in the previous step
  • client_id - value obtained when registering a client with OAuth2 provider
  • client_secret - value obtained when registering a client with OAuth2 provider

Request example:

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

The server response contains the access code and its lifetime:

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

This whole process is already automated in Spring Security and we do not need to worry about its implementation.

Customer registration


First of all, we will register our application as a client in Bitbucket to get a key (Key) and an access code (Client Secret).



Enter the client name and callback URL. Then we note what will be available to this client.



The obtained Key and ClientSecret values ​​are stored in application.properties:

client_id=ZJsdANfWkJ7jcktw2x
client_secret=28uUrJ9m43svbkcnXVNj8qeBjFtd8jaD

Configure Spring Security


Next, let's set up Spring Security. For OAuth2 to work, you must create a ClientRegistration object. ClientRegistration stores information about the client registered with the OAuth2 provider. Here we need the client_id and client_secret obtained in the previous step. Since there can be several such ClientRegistration objects in general, Spring Security uses the ClientRegistrationRepository object to store and access them. Let's create it too. We also indicate that only an authorized user can call any request and redefine UserService with its implementation.

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


Customization UserService


Spring Security not only fully implements the authorization process, but also provides the ability to customize it. For example, the possibility of customizing an authorization request , requesting an access code , as well as the possibility of custom post processing of a response to an access code request . After successful authorization, Spring Security uses UserInfo Endpoint to retrieve user attributes from the authorization server. In particular, the OAuth2UserService interface implementation is used for this.

We are going to create our own implementation of this service, so that after user authorization on the authorization server, we additionally check whether he is a user of our application, or register it if registration is open to everyone. By default, Spring Security uses the implementation of DefaultOAuth2UserService. He will form the basis of our implementation.

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


Test endpoint


It's time to create an endpoint to test the health of what we just did. Our endpoint will consist of just one request, which will welcome the current user.

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


Health Check


We launch the application and go to the address http: // localhost: 8080 and see that we were redirected to the Bitbucket website to confirm our account. Enter username and password.



Now we need to allow our application access to our account and resources.



The user's greeting contains both attributes from the authorization server and the user ID in our application.



Source


The full source code for this application is on Github .

References



All Articles