Недавно передо мной встала задача отказаться от statefull аутентификации с помощью сессий, в пользу stateless аутентификации и JWT. Так как это было мое первое знакомство с JSON Web Token, в первую очередь я начал искать полезную информацию на просторах интернета, но чем больше информации я находил, тем больше вопросов у меня появлялось.
Я не буду рассказывать, что это за «волшебные» токены, как они работают и зачем они нужны. Я хочу сосредоточиться на вопросах, которые встают перед многими, но не имеют однозначного правильного ответа. Мое решение не претендует на лучшее и эта статья не является пошаговым руководством, я просто хочу поделиться своим опытом и постараться обьяснить, почему я сделал именно так и никак иначе.
Для работы с токенами я выбрал библиотеку JJWT, хотя в принципе все реализации библиотек довольно похожи по своей функциональности. Также было решено не создавать сервер авторизации, а выдавать/обновлять/проверять токены непосредственно из приложения. Кстати немного о приложении, это одностраничное приложение (AngularJS) с RESTful Web-сервисом (Spring) в back-end.
Выдавать токены на долгий срок не хотелось, так как в случае с JWT права пользователя хранятся в токене, и изменения в правах пользователя не подействуют до повторной авторизации и получения нового токена. Можно конечно хранить в токене только идентификатор пользователя, а остальные данные читать из базы данных при каждом запросе, но это повлечет слишком большое количество лишних запросов к базе данных, поэтому от этого варианта я сразу отказался. Нужно было задуматься об обновлении короткоживущих токенов.
Для обновления access токена можно использовать refresh токен с более длительным временем до истечения, который получает новые данные пользователя по его идентификатору из базы данных и создает новый access токен на их основе. Но как быть с refresh токеном, если он также выдан на не очень долгое время и истечет во время сеанса пользователя? Повторный запрос авторизационных данных во время сеанса не очень хорошее решение в плане UX, а обновление истекшего токена плохо скажется на безопастности. Нужно было найти другое решение, но к этому вопросу я вернусь немного позже. Для начала нужно определиться с временем «жизни» для токенов и я остановился на 10 минутах для access токена и 60 минутах, с возможностью продления в перспективе, для refresh токена.
С хранением и обновлением токенов на стороне пользователя, тоже все неоднозначно. Обычно для их хранения используeтся локальное хранилище или cookies. У каждого из этих вариантов есть свои плюсы и минусы, каждый из них подвержен разного вида уязвимостям, но мой выбор остановился на втором варианте.
В случае с локальным хранилищем, обычно способом передачи токенов между back-end и front-end является добавление их в header или в тело запроса/ответа. То есть, мне пришлось бы вносить изминения и во front-end, для получения токенов, сохранения их в локальное хранилище, добавления к запросам, обновления и т.д., тогда как в случае с cookies их можно добавлять/изменять/удалять непосредственно из back-end.
Думаю, самое время перейти от теории к практике и показать, как это все реализовано у меня. Не хочу загружать статью лишним кодом, выкладывая все классы полностью, поэтому постараюсь выделить только самое важное. Если вас заинтересуют какие-либо подробности, которые остались «за кадром», спрашивайте, и я с радостью отвечу вам в комментариях или дополню статью.
Естественно, взаимодействие с приложением начинается с авторизации. Пользователь авторизуется с помощью метода в @RestController, в котором вызывается метод из TokenAuthenticationService для создания токенов и добавления их в ответ сервера в виде cookies (access_token и refresh_token).
@RequestMapping(value = "/login", produces = "application/json", method = RequestMethod.GET)
@ResponseStatus(value = HttpStatus.NO_CONTENT)
public void login(HttpServletResponse response) {
SessionUser user = (SessionUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
tokenAuthenticationService.addAuthentication(response, user);
SecurityContextHolder.getContext().setAuthentication(null);
}
SessionUser — реализация UserDetails*
Так выглядит этот метод в TokenAuthenticationService:
public void addAuthentication(HttpServletResponse response, SessionUser user) {
Cookie access = new Cookie("access_token", tokenHandler.createAccessToken(user));
access.setPath("/");
access.setHttpOnly(true);
response.addCookie(access);
Cookie refresh = new Cookie("refresh_token", tokenHandler.createRefreshToken(user));
refresh.setPath("/");
refresh.setHttpOnly(true);
response.addCookie(refresh);
}
TokenHandler содержит стандартные методы для работы c JWT с использованием Jwts.builder() и Jwts.parser() из указанной выше библиотеки, поэтому не вижу смысла занимать место их кодом, но для ясности напишу что делает каждый из них:
public String createRefreshToken(SessionUser user) {
//возвращает токен, в котором хранится только username
}
public SessionUser parseRefreshToken(String token) {
//парсит username из токена и получает данные пользователя из реализации UserDetailsService
}
public String createAccessToken(SessionUser user) {
//возвращает токен, в котором хранятся все данные для воссоздания SessionUser
}
public SessionUser parseAccessToken(String token) {
//использует данные из токена для создания нового SessionUser
}
Теперь немного о том, как происходит обработка последующих запросов. В конфигурационном файле, наследованном от WebSecurityConfigurerAdapter, я добавил два известных вам bean-a:
@Bean
public TokenAuthenticationService tokenAuthenticationService() {
return new TokenAuthenticationService();
}
@Bean
public TokenHandler tokenHandler() {
return new TokenHandler();
}
Запретил создание/использование сессий и добавил фильтр для аутентификации с помощью токенов:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
//Ресурсы доступные анонимным пользователям
.antMatchers("/", "/login").permitAll()
//Все остальные доступны только после аутентификации
.anyRequest().authenticated()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(new StatelessAuthenticationFilter(tokenAuthenticationService()), UsernamePasswordAuthenticationFilter.class)
}
При каждом запросе фильтр получает аутентификацию из токенов, обрабатывает запрос и обнуляет аутентификацию:
public class StatelessAuthenticationFilter extends GenericFilterBean {
private final TokenAuthenticationService tokenAuthenticationService;
public StatelessAuthenticationFilter(TokenAuthenticationService tokenAuthenticationService) {
this.tokenAuthenticationService= tokenAuthenticationService;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
Authentication authentication = tokenAuthenticationService.getAuthentication(httpRequest, httpResponse);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
SecurityContextHolder.getContext().setAuthentication(null);
}
}
Как происходит создание аутентификации:
— Присутствует access токен?
— Нет? Отклонить запрос.
— Да?
— — Действительный и не истек?
— — Да? Разрешить запрос.
— — Нет? Попробовать получить новый access токен при помощи refresh токена.
— — — Получилось?
— — — Да? Разрешить запрос, и добавить новый access токен к ответу.
— — — Нет? Отклонить запрос.
За это отвечает еще один метод из уже известного вам TokenAuthenticationService:
public Authentication getAuthentication(HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = request.getCookies();
String accessToken = null;
String refreshToken = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if (("access_token").equals(cookie.getName())) {
accessToken = cookie.getValue();
}
if (("refresh_token").equals(cookie.getName())) {
refreshToken = cookie.getValue();
}
}
}
if (accessToken != null && !accessToken.isEmpty()) {
try {
SessionUser user = tokenHandler.parseAccessToken(accessToken);
return new UserAuthentication(user);
} catch (ExpiredJwtException ex) {
if (refreshToken != null && !refreshToken.isEmpty()) {
try {
SessionUser user = tokenHandler.parseRefreshToken(refreshToken);
Cookie access = new Cookie("access_token", tokenHandler.createAccessToken(user));
access.setPath("/");
access.setHttpOnly(true);
response.addCookie(access);
return new UserAuthentication(user);
} catch (JwtException e) {
return null;
}
}
return null;
} catch (JwtException ex) {
return null;
}
}
return null;
}
UserAuthentication — реализация Authentication*
Вот и все, что требуется для совместной работы Spring Security и JWT.
Но если вы не забыли, я собирался придумать что-то для продления refresh токенов. Как я уже говорил, я не хотел выдавать токены на долгий срок и в любом случае, это не спасло бы от истечения токена во время сеанса пользователя, будь это через час, день или месяц.
Первое, что пришло в голову, убрать из токена время истечения, а так как в моем случае cookies хранятся только до закрытия браузера, то токен бы просто удалялся и больше не использовался. Но здесь были свои «подводные камни», во первых в случае кражи токен мог бы использоваться неограниченное количество времени и не было бы возможности его отозвать, во вторых я хотел продлевать токен только для активного пользователя, а если пользователь просто оставит окно браузера открытым, то токен должен был истечь спустя некоторое время.
Чтобы обезопасить токен от кражи, нужно было реализовать возможность отзыва токена, поэтому я решил хранить все refresh токены в базе данных и перед выдачей новых access токенов, проверять наличие refresh токена в ней. Тогда в случае отзыва токена, достаточно удалить его из базы данных. Но это не решало моей проблемы с продлением токенов. И тогда я подумал, а что если хранить время до истечения токена не в самом токене, а в базе данных? И отодвигать это время на 60 минут вперед от каждого использования токена. Мне это показалось довольно не плохой идеей и я хотел бы поделиться с вами ее реализацией.
Я создал таблицу (refresh_token) в базе данных для хранения refresh токенов со следующими столбцами:
1. id (BIGINT)
2. username (VARCHAR)
3. token (VARCHAR)
4. expires (TIMESTAMP)
И создал класс RefreshTokenDao с двумя методами использующими JdbcTemplate для общения с этой таблицей:
public void insert(String username, String token, long expires) {
String sql = "INSERT INTO refresh_token "
+ "(username, token, expires) VALUES (?, ?, ?)";
jdbcTemplate.update(sql, username, token, new Timestamp(expires));
}
public int updateIfNotExpired(String username, String token, long expiration) {
String sql = "UPDATE refresh_token "
+ "SET expires = ? "
+ "WHERE username = ? "
+ "AND token = ? "
+ "AND expires > ?";
Timestamp now = new Timestamp(System.currentTimeMillis());
Timestamp newExpirationTime = new Timestamp(now.getTime() + expiration);
return jdbcTemplate.update(sql, newExpirationTime, username, token, now);
}
Методы довольно простые. Первый используется для добавления токена в базу данных при его создании. Второй для обновления токена, если он не истек и возвращения количества обновленных полей, в моем случае это количество всегда будет 0 или 1.
Используются они в классе TokenHandler. При создании токена (createRefreshToken()) я добавляю запись о нем с помощью метода insert(). А в случае когда мне нужно получить информацию из токена (parseRefreshToken()), я сначала пытаюсь вызвать updateIfNotExpired() и если в ответе получаю значение не равное 0, значит токен валиден и его дата обновилась, можно продолжать выполнение метода; а в случае, если возвращенное значение равно 0, из чего следует что токен не найден или истек, я просто выбрасываю исключение (new JwtException(«Token is expired or missing»)).
Это все что касалось продления refresh токенов. На просторах интернета я нигде не встречал такого способа (может плохо искал) и надеюсь что он будет кому-то полезен, как и вся статья в целом.
Спасибо за внимание!
Автор: AnarSultanov
Добрый день. Спасибо за статью, только не могли бы вы указать на репозиторий с полным кодом реализации этой задачи. Спасибо.
Добрый день. Хотел бы тоже взглянуть на полный список файлов реализации этой задачи. Спасибо. Или возможно могу представить свой небольшой проект по регистрация-аутентификация-активизация-передача данных в который необходимо внедрить аутентификацию JWT. С Уважением.