Перейти к основному содержимому

OAuth2 для Spring REST API — обработка токена обновления в AngularJS (устаревший стек OAuth)

· 6 мин. чтения

1. Обзор

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

Примечание . В этой статье используется устаревший проект Spring OAuth . Для версии этой статьи, использующей новый стек Spring Security 5, ознакомьтесь с нашей статьей OAuth2 для Spring REST API — обработка токена обновления в Angular .

2. Срок действия токена доступа

Во-первых, помните, что клиент получал токен доступа, когда пользователь входил в приложение:

function obtainAccessToken(params) {
var req = {
method: 'POST',
url: "oauth/token",
headers: {"Content-type": "application/x-www-form-urlencoded; charset=utf-8"},
data: $httpParamSerializer(params)
}
$http(req).then(
function(data) {
$http.defaults.headers.common.Authorization= 'Bearer ' + data.data.access_token;
var expireDate = new Date (new Date().getTime() + (1000 * data.data.expires_in));
$cookies.put("access_token", data.data.access_token, {'expires': expireDate});
window.location.href="index";
},function() {
console.log("error");
window.location.href = "login";
});
}

Обратите внимание, как наш токен доступа хранится в файле cookie, срок действия которого истекает в зависимости от того, когда истечет срок действия самого токена.

Важно понимать, что сам файл cookie используется только для хранения и не управляет ничем другим в потоке OAuth. Например, браузер никогда не будет автоматически отправлять cookie на сервер с запросами.

Также обратите внимание, как мы на самом деле вызываем эту функциюgetAccessToken() :

$scope.loginData = {
grant_type:"password",
username: "",
password: "",
client_id: "fooClientIdPassword"
};

$scope.login = function() {
obtainAccessToken($scope.loginData);
}

3. Прокси

Теперь у нас будет прокси-сервер Zuul, работающий во внешнем приложении и в основном расположенный между внешним клиентом и сервером авторизации.

Настроим маршруты прокси:

zuul:
routes:
oauth:
path: /oauth/**
url: http://localhost:8081/spring-security-oauth-server/oauth

Что здесь интересно, так это то, что мы проксируем трафик только на сервер авторизации и ни на что другое. Нам действительно нужно, чтобы прокси-сервер приходил только тогда, когда клиент получает новые токены.

Если вы хотите пройтись по основам Zuul, бегло прочитайте основную статью Zuul .

4. Фильтр Zuul, выполняющий базовую аутентификацию

Первое использование прокси-сервера простое — вместо того, чтобы раскрывать « секрет клиента » нашего приложения в javascript, мы будем использовать предварительный фильтр Zuul, чтобы добавить заголовок авторизации для запросов токена доступа:

@Component
public class CustomPreZuulFilter extends ZuulFilter {
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
if (ctx.getRequest().getRequestURI().contains("oauth/token")) {
byte[] encoded;
try {
encoded = Base64.encode("fooClientIdPassword:secret".getBytes("UTF-8"));
ctx.addZuulRequestHeader("Authorization", "Basic " + new String(encoded));
} catch (UnsupportedEncodingException e) {
logger.error("Error occured in pre filter", e);
}
}
return null;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public int filterOrder() {
return -2;
}

@Override
public String filterType() {
return "pre";
}
}

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

С точки зрения реализации особенно стоит обратить внимание на тип фильтра. Мы используем тип фильтра «pre» для обработки запроса перед его передачей.

К забавным вещам.

Мы планируем сделать так, чтобы клиент получал Refresh Token в виде файла cookie. Не просто обычный файл cookie, а защищенный файл cookie только для HTTP с очень ограниченным путем ( /oauth/token ).

Мы настроим пост-фильтр Zuul для извлечения токена обновления из тела ответа JSON и установим его в файле cookie:

@Component
public class CustomPostZuulFilter extends ZuulFilter {
private ObjectMapper mapper = new ObjectMapper();

@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
try {
InputStream is = ctx.getResponseDataStream();
String responseBody = IOUtils.toString(is, "UTF-8");
if (responseBody.contains("refresh_token")) {
Map<String, Object> responseMap = mapper.readValue(
responseBody, new TypeReference<Map<String, Object>>() {});
String refreshToken = responseMap.get("refresh_token").toString();
responseMap.remove("refresh_token");
responseBody = mapper.writeValueAsString(responseMap);

Cookie cookie = new Cookie("refreshToken", refreshToken);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath(ctx.getRequest().getContextPath() + "/oauth/token");
cookie.setMaxAge(2592000); // 30 days
ctx.getResponse().addCookie(cookie);
}
ctx.setResponseBody(responseBody);
} catch (IOException e) {
logger.error("Error occured in zuul post filter", e);
}
return null;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public int filterOrder() {
return 10;
}

@Override
public String filterType() {
return "post";
}
}

Несколько интересных вещей, которые нужно понять здесь:

  • Мы использовали постфильтр Zuul для чтения ответа и извлечения токена обновления.
  • Мы удалили значение refresh_token из ответа JSON, чтобы убедиться, что оно никогда не будет доступно для внешнего интерфейса за пределами файла cookie.
  • Мы устанавливаем максимальный срок действия файла cookie на 30 дней , поскольку он соответствует сроку действия токена.

Чтобы добавить дополнительный уровень защиты от CSRF-атак, мы добавим заголовок файла cookie Same-Site ко всем нашим файлам cookie .

Для этого мы создадим класс конфигурации:

@Configuration
public class SameSiteConfig implements WebMvcConfigurer {
@Bean
public TomcatContextCustomizer sameSiteCookiesConfig() {
return context -> {
final Rfc6265CookieProcessor cookieProcessor = new Rfc6265CookieProcessor();
cookieProcessor.setSameSiteCookies(SameSiteCookies.STRICT.getValue());
context.setCookieProcessor(cookieProcessor);
};
}
}

Здесь мы устанавливаем для атрибута значение strict , чтобы любая межсайтовая передача файлов cookie была строго запрещена.

Теперь, когда у нас есть токен обновления в файле cookie, когда внешнее приложение AngularJS пытается инициировать обновление токена, оно отправит запрос в /oauth/token , и поэтому браузер, конечно же, отправит этот файл cookie.

Итак, теперь у нас будет еще один фильтр в прокси, который будет извлекать токен обновления из файла cookie и отправлять его вперед в качестве параметра HTTP, чтобы запрос был действительным:

public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
...
HttpServletRequest req = ctx.getRequest();
String refreshToken = extractRefreshToken(req);
if (refreshToken != null) {
Map<String, String[]> param = new HashMap<String, String[]>();
param.put("refresh_token", new String[] { refreshToken });
param.put("grant_type", new String[] { "refresh_token" });
ctx.setRequest(new CustomHttpServletRequest(req, param));
}
...
}

private String extractRefreshToken(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if (cookies != null) {
for (int i = 0; i < cookies.length; i++) {
if (cookies[i].getName().equalsIgnoreCase("refreshToken")) {
return cookies[i].getValue();
}
}
}
return null;
}

А вот наш CustomHttpServletRequest , используемый для ввода параметров токена обновления :

public class CustomHttpServletRequest extends HttpServletRequestWrapper {
private Map<String, String[]> additionalParams;
private HttpServletRequest request;

public CustomHttpServletRequest(
HttpServletRequest request, Map<String, String[]> additionalParams) {
super(request);
this.request = request;
this.additionalParams = additionalParams;
}

@Override
public Map<String, String[]> getParameterMap() {
Map<String, String[]> map = request.getParameterMap();
Map<String, String[]> param = new HashMap<String, String[]>();
param.putAll(map);
param.putAll(additionalParams);
return param;
}
}

Опять же, здесь много важных замечаний по реализации:

  • Прокси извлекает токен обновления из файла cookie.
  • Затем он устанавливает его в параметр refresh_token
  • Он также устанавливает для параметра grant_type значение refresh_token.
  • Если файл cookie refreshToken отсутствует (либо с истекшим сроком действия, либо с первым входом в систему), запрос токена доступа будет перенаправлен без изменений.

7. Обновление токена доступа из AngularJS

Наконец, давайте изменим наше простое внешнее приложение и воспользуемся обновлением токена:

Вот наша функция refreshAccessToken() :

$scope.refreshAccessToken = function() {
obtainAccessToken($scope.refreshData);
}

А вот и наш $scope.refreshData :

$scope.refreshData = {grant_type:"refresh_token"};

Обратите внимание, что мы просто используем существующую функцию getAccessToken и просто передаем ей разные входные данные.

Также обратите внимание, что мы не добавляем Refresh_token сами, так как об этом позаботится фильтр Zuul.

8. Заключение

В этом руководстве по OAuth мы узнали, как сохранить токен обновления в клиентском приложении AngularJS, как обновить токен доступа с истекшим сроком действия и как использовать для всего этого прокси-сервер Zuul.

Полную реализацию этого туториала можно найти в проекте github .