«1. Обзор

В этом руководстве мы продолжим изучение потока кода авторизации OAuth2, который мы начали собирать в нашей предыдущей статье, и сосредоточимся на том, как обрабатывать токен обновления в приложении Angular. Мы также будем использовать прокси-сервер Zuul.

Мы будем использовать стек OAuth в Spring Security 5. Если вы хотите использовать устаревший стек Spring Security OAuth, ознакомьтесь с этой предыдущей статьей: OAuth2 для Spring REST API — обработка токена обновления в AngularJS (устаревший стек OAuth)

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

Во-первых, помните, что клиент получал токен доступа, используя тип предоставления кода авторизации, в два этапа. На первом этапе получаем код авторизации. И на втором этапе мы фактически получаем токен доступа.

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

var expireDate = new Date().getTime() + (1000 * token.expires_in);
Cookie.set("access_token", token.access_token, expireDate);

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

Но обратите внимание, как мы на самом деле определяем эту функцию retrieveToken() для получения токена доступа:

retrieveToken(code) {
  let params = new URLSearchParams();
  params.append('grant_type','authorization_code');
  params.append('client_id', this.clientId);
  params.append('client_secret', 'newClientSecret');
  params.append('redirect_uri', this.redirectUri);
  params.append('code',code);

  let headers =
    new HttpHeaders({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});

  this._http.post('http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token',
    params.toString(), { headers: headers })
    .subscribe(
      data => this.saveToken(data),
      err => alert('Invalid Credentials'));
}

Мы отправляем секрет клиента в параметрах, что на самом деле не является безопасным способом обработки этого. Давайте посмотрим, как мы можем избежать этого.

3. Прокси

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

Интерфейсный клиент теперь будет размещаться как загрузочное приложение, чтобы мы могли беспрепятственно подключаться к нашему встроенному прокси-серверу Zuul с помощью пускового устройства Spring Cloud Zuul.

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

Теперь давайте настроим маршруты прокси:

zuul:
  routes:
    auth/code:
      path: /auth/code/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/auth
    auth/token:
      path: /auth/token/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token
    auth/refresh:
      path: /auth/refresh/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token
    auth/redirect:
      path: /auth/redirect/**
      sensitiveHeaders:
      url: http://localhost:8089/
    auth/resources:
      path: /auth/resources/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/resources/

Мы настроили маршруты для обработки следующего:

    auth/code — получить код авторизации и сохранить его в файле cookie auth/redirect — обрабатывать перенаправление на страницу авторизации auth/resources — сопоставлять с соответствующим путем сервера авторизации для его ресурсов страницы входа (css и js) auth/token — получать токен доступа, удалять refresh_token из payload и сохраните его в файле cookie. auth/refresh — получите токен обновления, удалите его из полезной нагрузки и сохраните в файле cookie. еще. Нам действительно нужно, чтобы прокси-сервер приходил только тогда, когда клиент получает новые токены.

Далее, давайте рассмотрим все это один за другим.

4. Получение кода с помощью предварительного фильтра Zuul

Первое использование прокси-сервера простое — мы настраиваем запрос на получение кода авторизации:

Мы используем тип фильтра of pre для обработки запроса перед его передачей.

@Component
public class CustomPreZuulFilter extends ZuulFilter {
    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest req = ctx.getRequest();
        String requestURI = req.getRequestURI();
        if (requestURI.contains("auth/code")) {
            Map<String, List> params = ctx.getRequestQueryParams();
            if (params == null) {
	        params = Maps.newHashMap();
	    }
            params.put("response_type", Lists.newArrayList(new String[] { "code" }));
            params.put("scope", Lists.newArrayList(new String[] { "read" }));
            params.put("client_id", Lists.newArrayList(new String[] { CLIENT_ID }));
            params.put("redirect_uri", Lists.newArrayList(new String[] { REDIRECT_URL }));
            ctx.setRequestQueryParams(params);
        }
        return null;
    }

    @Override
    public boolean shouldFilter() {
        boolean shouldfilter = false;
        RequestContext ctx = RequestContext.getCurrentContext();
        String URI = ctx.getRequest().getRequestURI();

        if (URI.contains("auth/code") || URI.contains("auth/token") || 
          URI.contains("auth/refresh")) {		
            shouldfilter = true;
	}
        return shouldfilter;
    }

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

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

В методе run() фильтра мы добавляем параметры запроса для response_type, scope, client_id и redirect_uri — все, что нужно нашему серверу авторизации, чтобы перейти на страницу входа и отправить обратно код.

Также обратите внимание на метод shouldFilter(). Мы фильтруем только запросы с 3 упомянутыми URI, другие не проходят через метод запуска.

5. Поместите код в файл cookie с помощью почтового фильтра Zuul

Здесь мы планируем сохранить код в виде файла cookie, чтобы мы могли отправить его на сервер авторизации для получения токена доступа. . Код присутствует в качестве параметра запроса в URL-адресе запроса, на который сервер авторизации перенаправляет нас после входа в систему.

Мы настроим постфильтр Zuul, чтобы извлечь этот код и установить его в файле cookie. Это не просто обычный файл cookie, а защищенный файл cookie только для HTTP с очень ограниченным путем (/auth/token):

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

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

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        try {
            Map<String, List> params = ctx.getRequestQueryParams();

            if (requestURI.contains("auth/redirect")) {
                Cookie cookie = new Cookie("code", params.get("code").get(0));
                cookie.setHttpOnly(true);
                cookie.setPath(ctx.getRequest().getContextPath() + "/auth/token");
                ctx.getResponse().addCookie(cookie);
            }
        } catch (Exception e) {
            logger.error("Error occured in zuul post filter", e);
        }
        return null;
    }

    @Override
    public boolean shouldFilter() {
        boolean shouldfilter = false;
        RequestContext ctx = RequestContext.getCurrentContext();
        String URI = ctx.getRequest().getRequestURI();

        if (URI.contains("auth/redirect") || URI.contains("auth/token") || URI.contains("auth/refresh")) {
            shouldfilter = true;
        }
        return shouldfilter;
    }

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

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

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

«

@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, когда внешнее приложение Angular попытается инициировать запрос токена, оно отправит запрос по адресу /auth/ токен, поэтому браузер, конечно же, отправит этот файл cookie.

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

public Object run() {
    RequestContext ctx = RequestContext.getCurrentContext();
    ...
    else if (requestURI.contains("auth/token"))) {
        try {
            String code = extractCookie(req, "code");
            String formParams = String.format(
              "grant_type=%s&client_id=%s&client_secret=%s&redirect_uri=%s&code=%s",
              "authorization_code", CLIENT_ID, CLIENT_SECRET, REDIRECT_URL, code);

            byte[] bytes = formParams.getBytes("UTF-8");
            ctx.setRequest(new CustomHttpServletRequest(req, bytes));
        } catch (IOException e) {
            e.printStackTrace();
        }
    } 
    ...
}

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

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

public class CustomHttpServletRequest extends HttpServletRequestWrapper {

    private byte[] bytes;

    public CustomHttpServletRequest(HttpServletRequest request, byte[] bytes) {
        super(request);
        this.bytes = bytes;
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        return new ServletInputStreamWrapper(bytes);
    }

    @Override
    public int getContentLength() {
        return bytes.length;
    }

    @Override
    public long getContentLengthLong() {
        return bytes.length;
    }
	
    @Override
    public String getMethod() {
        return "POST";
    }
}

Это даст нам токен доступа от сервера авторизации в ответе. Далее мы увидим, как мы преобразуем ответ.

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

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

Мы планируем сделать так, чтобы клиент получал Refresh Token в виде файла cookie.

Мы добавим в наш пост-фильтр Zuul извлечение маркера обновления из тела ответа JSON и установку его в файле cookie. Это снова защищенный файл cookie только для HTTP с очень ограниченным путем (/auth/refresh):

public Object run() {
...
    else if (requestURI.contains("auth/token") || requestURI.contains("auth/refresh")) {
        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.setPath(ctx.getRequest().getContextPath() + "/auth/refresh");
            cookie.setMaxAge(2592000); // 30 days
            ctx.getResponse().addCookie(cookie);
        }
        ctx.setResponseBody(responseBody);
    }
    ...
}

Как мы видим, здесь мы добавили условие в наш пост-фильтр Zuul для чтения ответа и его извлечения. Refresh Token для маршрутов auth/token и auth/refresh. Мы делаем одно и то же для обоих, потому что сервер авторизации по существу отправляет одну и ту же полезную нагрузку при получении токена доступа и токена обновления.

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

Еще один момент, на который следует обратить внимание, это то, что мы установили максимальный срок действия файла cookie на 30 дней, поскольку это соответствует сроку действия токена.

8. Получите и используйте токен обновления из файла cookie

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

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

public Object run() {
    RequestContext ctx = RequestContext.getCurrentContext();
    ...
    else if (requestURI.contains("auth/refresh"))) {
        try {
            String token = extractCookie(req, "token");                       
            String formParams = String.format(
              "grant_type=%s&client_id=%s&client_secret=%s&refresh_token=%s", 
              "refresh_token", CLIENT_ID, CLIENT_SECRET, token);
 
            byte[] bytes = formParams.getBytes("UTF-8");
            ctx.setRequest(new CustomHttpServletRequest(req, bytes));
        } catch (IOException e) {
            e.printStackTrace();
        }
    } 
    ...
}

~~ ~ Это похоже на то, что мы сделали, когда впервые получили токен доступа. Но обратите внимание, что тело формы отличается. Теперь мы отправляем Grant_type Refresh_Token вместо Authorization_code вместе с токеном, который мы ранее сохранили в файле cookie.

После получения ответа он снова проходит ту же трансформацию в предварительном фильтре, что мы видели ранее в разделе 7.

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

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

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

refreshAccessToken() {
  let headers = new HttpHeaders({
    'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});
  this._http.post('auth/refresh', {}, {headers: headers })
    .subscribe(
      data => this.saveToken(data),
      err => alert('Invalid Credentials')
    );
}

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

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

10. Запуск внешнего интерфейса

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

Первый шаг такой же. Нам нужно собрать приложение:

mvn clean install

Это запустит внешний модуль-maven-plugin, определенный в нашем pom.xml, для создания кода Angular и копирования артефактов пользовательского интерфейса в папку target/classes/static. Этот процесс перезаписывает все, что у нас есть в каталоге src/main/resources. Поэтому нам нужно убедиться и включить все необходимые ресурсы из этой папки, такие как application.yml, в процесс копирования.

На втором этапе нам нужно запустить класс UiApplication нашего SpringBootApplication. Наше клиентское приложение будет работать на порту 8089, как указано в application.yml.

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

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

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