Spring Security — пример веб приложения с авторизацией по протоколу OAuth2 через BitBucket

В данной статье мы рассмотрим способ авторизации пользователей в веб приложениях на Spring Boot по протоколу OAuth2 с использованием внешнего сервера авторизации (на примере Bitbucket).

Что мы хотим получить


Допустим, мы разрабатываем защищенное веб приложение, имеющее доступ к ресурсам пользователя на внешнем сервере, например, систему непрерывной интеграции, а в качестве внешнего сервера выступает Bitbucket, и мы не хотели бы хранить у себя логин и пароль пользователя от внешней системы. То есть нам необходимо авторизовать пользователя через Bitbucket, чтобы получить доступ к его учетной записи и ресурсам, дополнительно проверить, что он является пользователем нашего приложения и сделать это так, чтобы пользователь не раскрывал нам свои учетные данные от Bitbucket. Интересно как это сделать, — добро пожаловать под кат.

OAuth2


OAuth2 — это протокол авторизации, который позволяет предоставить третьей стороне ограниченный доступ к защищенным ресурсам пользователя без необходимости передавать ей (третьей стороне) логин и пароль.

OAuth2 определяет 4 роли:

  • Владелец ресурса
  • Ресурсный сервер
  • Сервер авторизации
  • Клиент (приложение)

Владелец ресурса — это пользователь, который использует клиентское приложение и разрешает ему доступ к своей учетной записи, размещенной на ресурсном сервере. Доступ приложения к учетной записи ограничен выданными разрешениями.

На ресурсном сервере размещаются защищенные учетные записи пользователей.
Сервер авторизации проверяет подлинность владельца ресурса и выдает маркеры доступа. Сервер авторизации может одновременно являться и ресурсным сервером.

Клиентское приложение — это приложение, которое хочет получить доступ к учетной записи и ресурсам пользователя.

В нашем случае клиентское приложение — это приложение, которое мы разрабатываем, а Bitbucket будет как сервером авторизации так и ресурсным сервером.

OAuth2 поддерживает четыре типа авторизации: Authorization Code, Implicit, Resource Owner Password Credentials и Client Credentials. Мы не будем рассматривать их все, нас интересует тип Authorization Code. Тип Authorization Code оптимизирован для серверных приложений, в которых исходный код не является общедоступным и может сохраняться конфиденциальность кода доступа (Client Secret). Этот тип работает на основе перенаправления, то есть пользователь будет перенаправлен на сервер авторизации для того, чтобы подтвердить свою личность и разрешить приложению использовать его учетную запись.

Процесс авторизации посредством Authorization Code состоит из последовательности двух запросов:

  • Запрос авторизации (Authorization Request)
  • Запрос кода доступа (Access Token Request)

Запрос авторизации используется, чтобы подтвердить личность пользователя, а также запросить у пользователя авторизацию нашего приложения. Этот запрос представляет собой GET запрос со следующими параметрами:

  • response_type — значение должно быть равно code
  • client_id — значение, полученное при регистрации клиента в OAuth2 провайдере
  • redirect_uri — URL, куда будет перенаправлен пользователь после авторизации
  • scope — опциональный параметр, указывающий, какой уровень доступа запрашивается
  • state — случайно сгенерированная строка для верификации ответа

Пример запроса:

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

В случае, если пользователь подтвердит свою личность и разрешит приложению доступ к своим ресурсам, пользовательский агент будет перенаправлен на URL обратного вызова, заданный при регистрации клиента с дополнительным параметром code, содержащим код авторизации и параметром state со значением переданным в запросе.

Пример ответного вызова:

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

Запрос кода доступа используется, чтобы обменять полученный код авторизации на код доступа к ресурсам пользователя. Этот запрос представляет собой POST запрос со следующими параметрами:

  • grant_type — значение должно быть authorization_code
  • code — код авторизации, полученный на предыдущем шаге
  • redirect_uri — должен совпадать с URL, указанным на предыдущем шаге
  • client_id — значение, полученное при регистрации клиента в OAuth2 провайдере
  • client_secret — значение, полученное при регистрации клиента в OAuth2 провайдере

Пример запроса:

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

Ответ сервера содержит код доступа и время его жизни:

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

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

Регистрация клиента


Первым делом зарегистрируем наше приложение в качестве клиента в Bitbucket, чтобы получить ключ (Key) и код доступа (Client Secret).



Вводим название клиента и URL обратного вызова. Затем отмечаем, что будет доступно данному клиенту.



Полученные значения Key и ClientSecret сохраняем в application.properties:

client_id=ZJsdANfWkJ7jcktw2x
client_secret=28uUrJ9m43svbkcnXVNj8qeBjFtd8jaD

Настройка Spring Security


Далее приступаем к настройке Spring Security. Для работы OAuth2 необходимо создать объект ClientRegistration. ClientRegistration хранит информацию о клиенте, зарегистрированном в OAuth2 провайдере. Тут нам понадобятся, полученные на предыдущем шаге client_id и client_secret. Поскольку в общем случае таких объектов ClientRegistration может быть несколько, для хранения и доступа к ним Spring Security использует объект ClientRegistrationRepository. Создадим его тоже. Также указываем, что вызывать любой запрос может только авторизованный пользователь и переопределяем UserService своей реализацией.

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


Кастомизация UserService


Spring Security не только полностью реализует процесс авторизации, но также предоставляет возможности его кастомизации. Например, возможность кастомизации запроса авторизации, запроса кода доступа, а также возможность собственной пост обработки ответа на запрос кода доступа. После успешной авторизации Spring Security использует UserInfo Endpoint, чтобы получить атрибуты пользователя с сервера авторизации. В частности, для этого используется реализация интерфейса OAuth2UserService.

Мы собираемся создать собственную реализацию этого сервиса, чтобы после авторизации пользователя на сервере авторизации дополнительно проверить является ли он пользователем нашего приложения, либо зарегистрировать его, если регистрация открыта всем. По умолчанию Spring Security использует реализацию DefaultOAuth2UserService. Он и ляжет в основу нашей реализации.

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


Тестовый endpoint


Самое время создать endpoint для проверки работоспособности того, что мы только что сделали. Наш endpoint будет состоять всего из одного запроса, который будет приветствовать текущего пользователя.

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


Проверка работоспособности


Запускаем приложение и переходим по адресу http://localhost:8080 и видим, что мы были переправлены на сайт Bitbucket  для подтверждения своей учетной записи. Вводим логин и пароль.



Теперь нам надо разрешить нашему приложению доступ к своей учетной записи и ресурсам.



Приветствие пользователя содержит как атрибуты с сервера авторизации, так и ID пользователя в нашем приложении.



Исходный код


Полный исходный код рассмотренного приложения находится на Github.

Ссылки


Комментарии 5

    0
    Не очень подходящий кейс: "… мы разрабатываем защищенное веб приложение, имеющее доступ к ресурсам пользователя на внешнем сервере, ..., а в качестве внешнего сервера выступает Bitbucket, и мы не хотели бы хранить у себя логин и пароль пользователя от внешней системы."
    Получается Вы нарушаете безопасность своего защищенного приложения подключением к неконтролируемому с Вашей стороны сервису Bitbucket ради того, чтобы только он знал о логин-паролях к Вашему приложению. Бред какой-то…
      +1

      По сути это обычная авторизация по протоколу OAuth2, где Bitbucket является сервером авторизации. Логин происходит с помощью учетной записи Bitbucket, других учетных данных нет, так что все вполне безопасно.

      0
      Поделка, годная для первой половины 10-ых. Ну, или гайд для новичков для вхождения в Java-SS-OAuth2. На практике малоприменимая вещь.

      Я сам достаточно глубоко в этом, довел данную связку почти до «из коробки», поэтому могу сказать, что Spring Security последних версий может намного больше. Авторизация через внешние ресурсы — это очень спорная штука:
      а) доступность — гарантия, что завтра это будет работать? сервер вообще, линки, структура;
      б) безопасность — да, ресурс, которому верят, но ...;
      в) задержки — для безопасности expire_on ставится небольшой, значит, надо относительно часто бегать за токеном, с удаленным ресурсом с нагрузкой возможны задержки;
      г) доступность — как в предыдущем, только есть гарантия, что ресурс не имеет ограничений на запросы (с IP, в минуту и т.д.)? — риск.

      Spring Security на борту уже имеет AuthorizationServer и ResourceServer, а также OAuth2Client. Сейчас достаточно модно (и удобно) создавать мультисервисную архитектуру проекта. Так почему не сделать один модуль — авторизационный OAuth2 сервер? Нет всех ограничений, описанных выше, плюс полный контроль. Как бонус — разделение БД на доступную для сервисов-модулей с данными проекта и отдельно авторизационную с монопольным доступом из авторизационного модуля. Еще бонус — не примитивный токет, а OAuth2 token + JWT + OpenID, это значительно увеличивает удобство пользования такой авторизацией модулями.

      Кроме этого, грамотная реализация OAuth2 авторизации включает в себя не только access token, но и refresh token, для оптимизации запросов к авторизационному модулю/серверу.
      Также на практике более гибкая логика модуля, где применяются scopes, а не authorities — это позволяет очень гибко обеспечивать секьюрность методов и классов. То есть у customers назначены authorities, покрывающие определенные наборы scopes, а методы классов в большинстве своем защищены определением разрешенных scopes, типа
      @PreAuthorize("#oauth2.hasScope('mod:some_scope')")

      Кстати, с такой реализацией не только пользователи имеют авторизацию, но и подключаемые модули, у всех свои clientId и clientSecret. Плюс использование OAuth2RestTemplate, что делает еще и сами запросы к серверу секьюрными.
        0

        Спасибо за работу над статьей, особенно полезны для новичков будут детали протокола OAuth2. Но и здесь есть важный момент — сам по себе OAuth2 это не протокол аутентификации, это протокол авторизации. А вот OpenID Connect это да, аутентификация, и Spring Security его отлично поддерживает.


        Но все, что касается Spring Security в статье это, скорее bad practices, так делать не надо.


        1. Для начала, стоит указать версию SS — поддержка OAuth2 в 5-й версии внесена в ядро и сильно отличается от 4-й.
        2. Если используется Spring Boot, то руками создавать ClientRegistration не нужно, SB это сделает за вас. Более того, Boot из коробки поможет сконфигурировать стандартные OpenID провайдеры вроде Facebook, Google, Github, даже Token URI не надо указывать.
        3. Ну и самое главное — своя реализация UserService не просто не нужна, а даже вредна — например, тут не проверяются многие пункты стандарта, не надо так делать в реальных проектах. Вся конфигурация, в принципе, делается так: .oauth2Login(). Все, SS создаст стандартный UserService для работы с OpenID endpoint (/userinfo), если провайдер поддерживает, провалидирует ответ, создаст IdToken и вообще сделает все правильно и хорошо.
          0
          Спасибо, что ознакомились со статьей и представили свои замечания. Согласен, что OAuth2 — протокол авторизации, но ведь в статье так и написано. Статья и правда задумывалась как введение в OAuth2 для новичков и не претендует на пример идеального кода (возможно стоило указать это в статье сразу).
          1. В реализации используется 5-я версия и она действительно отличается от 4-й, хотя в целом конфигурирование приведенных вещей очень похоже и там и там.
          2. Согласен, в данном случае создавать его руками было не обязательно. Хотя Bitbucket не является стандартным провайдером, поэтому все тоже самое пришлось бы сделать в application.properties. Вариант с application.properties не подойдет, если нужно динамически создавать ClientRegistration в зависимости от каких-либо условий. В данном случае пример был вырезан из более крупного приложения и переносить это в application.properties руки не дошли.
          3. Насчет своей реализации UserService можно и согласиться и нет. Поддерживаю, что у этого решения есть свои минусы, но это решение использует стандартное API SS, поэтому тоже имеет право на существование.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое