Stateless аутентификация при помощи Spring Security и JWT

в 17:07, , рубрики: java, jwt, Spring Security

Недавно передо мной встала задача отказаться от 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
}

UserDetailsService*

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

Источник


  1. Andrei:

    Добрый день. Спасибо за статью, только не могли бы вы указать на репозиторий с полным кодом реализации этой задачи. Спасибо.

  2. Vitliy:

    Добрый день. Хотел бы тоже взглянуть на полный список файлов реализации этой задачи. Спасибо. Или возможно могу представить свой небольшой проект по регистрация-аутентификация-активизация-передача данных в который необходимо внедрить аутентификацию JWT. С Уважением.

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js