Что мы хотим получить
Допустим, мы разрабатываем защищенное веб приложение, имеющее доступ к ресурсам пользователя на внешнем сервере, например, систему непрерывной интеграции, а в качестве внешнего сервера выступает 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 своей реализацией.
@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. Он и ляжет в основу нашей реализации.
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 будет состоять всего из одного запроса, который будет приветствовать текущего пользователя.
@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.
Barsik68
Не очень подходящий кейс: "… мы разрабатываем защищенное веб приложение, имеющее доступ к ресурсам пользователя на внешнем сервере, ..., а в качестве внешнего сервера выступает Bitbucket, и мы не хотели бы хранить у себя логин и пароль пользователя от внешней системы."
Получается Вы нарушаете безопасность своего защищенного приложения подключением к неконтролируемому с Вашей стороны сервису Bitbucket ради того, чтобы только он знал о логин-паролях к Вашему приложению. Бред какой-то…
nbondarchuk Автор
По сути это обычная авторизация по протоколу OAuth2, где Bitbucket является сервером авторизации. Логин происходит с помощью учетной записи Bitbucket, других учетных данных нет, так что все вполне безопасно.