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, другие не проходят через метод запуска
.
5. Поместите код в файл cookie с помощью почтового фильтра Zuul .
Здесь мы планируем сохранить код в виде файла 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 была строго запрещена.
6. Получите и используйте код из файла 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";
}
}
Это даст нам токен доступа от сервера авторизации в ответе. Далее мы увидим, как мы преобразуем ответ.
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
, в процесс копирования.
На втором этапе нам нужно запустить наш класс SpringBootApplication
UiApplication
. Наше клиентское приложение будет работать на порту 8089, как указано в application.yml
.
11. Заключение
В этом руководстве по OAuth2 мы узнали, как сохранить токен обновления в клиентском приложении Angular, как обновить токен доступа с истекшим сроком действия и как использовать для всего этого прокси-сервер Zuul.
Полную реализацию этого руководства можно найти на GitHub .