Рубрики
Без рубрики

OAuth2 для API Spring REST – Обработайте токен обновления в Angular

Посмотрите, как обновить токен, используя стек OAuth Spring Security 5 и используя прокси-сервер Zuul.

Автор оригинала: Sampada Wagde.

1. Обзор

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

Мы будем использовать стек OAuth в Spring Security 5. Если вы хотите использовать устаревший стек OAuth Spring Security, ознакомьтесь с этой предыдущей статьей: OAuth2 для API Spring REST – Обработайте токен обновления в 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'));
}

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

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 из полезной нагрузки и сохраните его в файле cookie
  • auth/refresh – получите токен обновления, удалите его из полезной нагрузки и сохраните в файле cookie

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

Далее давайте рассмотрим все это по очереди.

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

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

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

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

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

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

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

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

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

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

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        try {
            Map 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";
    }
}

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

Таким образом, теперь у нас будет еще одно условие в нашем pre фильтре в прокси-сервере, который извлечет код из файла 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

Перейдем к забавным вещам.

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

Мы добавим в наш постфильтр Zuul, чтобы извлечь токен обновления из тела ответа JSON и установить его в файле cookie. Это снова защищенный файл cookie HttpOnly с очень ограниченным путем ( /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 responseMap = mapper.readValue(responseBody, 
              new TypeReference>() {});
            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, чтобы прочитать ответ и извлечь токен обновления для маршрутов auth/token и auth/refresh . Мы делаем то же самое для этих двух, потому что сервер авторизации по существу отправляет одну и ту же полезную нагрузку при получении маркера доступа и маркера обновления.

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

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

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

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

Таким образом, теперь у нас будет еще одно условие в нашем pre фильтре в прокси – сервере, которое извлечет токен обновления из файла 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 .

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

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

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

Вот наша функция обновить маркер доступа() :

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. Запустите передний конец

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

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

mvn clean install

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

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

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

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

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