«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» для обработки запроса перед его передачей.

5. Поместите жетон обновления в файл cookie

Перейдем к веселью.

Мы планируем сделать так, чтобы клиент получал 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 между сайтами была строго запрещена.

6. Получите и используйте токен обновления из файла 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. без изменений

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.