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

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

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

1. Обзор

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

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

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

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

Наш токен доступа хранится в файле 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/foreach/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/foreach/protocol/openid-connect/auth
auth/token:
path: /auth/token/**
sensitiveHeaders:
url: http://localhost:8083/auth/realms/foreach/protocol/openid-connect/token
auth/refresh:
path: /auth/refresh/**
sensitiveHeaders:
url: http://localhost:8083/auth/realms/foreach/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 — получить код авторизации и сохранить его в куки
  • auth/redirect — обрабатывать перенаправление на страницу входа на сервер авторизации.
  • auth/resources — сопоставление с соответствующим путем сервера авторизации для его ресурсов страницы входа ( css и js )
  • auth/token — получить токен доступа, удалить refresh_token из полезной нагрузки и сохранить его в куки
  • 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<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";
}
}

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

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

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

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

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

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

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

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

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

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

Теперь, когда у нас есть токен обновления в файле 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 , в процесс копирования.

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

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

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

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