В этой статье говорится о том, как запоминать пользователя между сессиями с помощью Remember-Me токена.
Код отличается от примера одной строкой:
.and().rememberMe();
Введение
Долгие сессии могут нагрузить сервер, так как все объекты пользовательских сессий хранятся в куче контейнера. Поэтому сессии имеет смысл сделать короче, а идентичность пользователя запоминать с помощью специального долгосрочного Hash-Based токена. Он содержит только имя пользователя и хэш, с помощью которого можно проверить подлинность токена. При этом истекшие сессии не восстанавливаются, а начинаются заново. Зато пользователь может не совершать заново вход в систему — его помнят благодаря Remember-Me токену. Есть два вида Remember-Me токенов, мы будем использовать Hash-Based. Он идет по умолчанию.
О сессиях
Сессии придуманы для того, чтобы сервер «помнил» пользователя при повторных запросах от него. То есть пользователь вводит однократно имя и пароль, и при дальнейших запросах сервер понимает, от кого именно пришел запрос, а также какие объекты есть в данном сеансе (например, товары в корзине покупок).
Реализуется это с помощью идентификаторов сессий. Стандартный алгоритм следующий.
Сервер высылает клиенту при первом запросе (например, при успешном логине, но можно и анонимному клиенту) заголовок типа:
Set-Cookie: JSESSIONID=4C7871D1EF406F69C7CF20CD6BD283F1
Браузер сохраняет эти значения (свои для каждого сайта), и далее при каждом запросе на конкретный сайт браузер автоматически добавляет к запросу соответствующий заголовок:
Cookie: JSESSIONID=4C7871D1EF406F69C7CF20CD6BD283F1
При последующих запросах от того же клиента сервер (в нашем примере это Apache Tomcat — контейнер сервлетов) опознает клиента по идентификатору сессии. Контейнер хранит эти идентификаторы сессий и соответствующие данные клиента как словарь в Map:
ключ JSESSIONID (конкретный идентификатор) - данные сессии
Сессия имеет срок годности. Как только он истекает, данные исчезают, и в последующих запросах контейнер не принимает истекший Cookie конкретного клиента.
Cессии исчезнут, если перезапустить Tomcat, так как они хранятся в «куче» Tomcat.
Заметьте, что сессии работают и без Spring Boot, это фишка контейнера сервлетов (того, кто реализует интерфейс javax.servlet.http.HttpSession) — в нашем примере Apache Tomcat.
А вот Remember-Me аутентификация — это уже фишка Spring Boot.
Remember-Me аутентификация
По умолчанию (без Remember-Me функциональности) форма входа выглядит так:
Но если включить Remember-Me аутентификацию, то появится флажок:
Если пользователь включит флажок, то будет создан Remember-Me токен. Он позволяет помнить пользователя и после того, как срок годности сессии истечет, а также после перезапуска сервера.
Токен высылается клиенту в Set-Cookie аналогично сессии:
Но восстановить из него можно только имя пользователя, никакие другие данные по нему не восстанавливаются — хранить в нем объекты нельзя (а в сессии можно).
При каждом запросе автоматически выполняется проверка подлинности токена, подробнее об этом ниже.
И если серверов несколько, то Remember-Me аутентификация будет работать, так как она не завязана на конкретный Tomcat-контейнер (и хранящуюся в его памяти сессию). Опознание пользователя происходит не путем обращения в Map в куче конкретного запущенного сервера (где содержатся ключи JSESSIONID и соответствующие данные сессии, среди которых имя пользователя), а иначе — путем проверки подлинности токена.
Simple Hash Based токен и проверка подлинности
Вообще в Remember-Me аутентификации можно выбрать два вида токенов: Simple Hash-Based Token и Persistence Token (хранится в базе).
при Simple Hash Based токене токен содержит:
- имя пользователя и срок годности токена в открытом виде (почти открытом — Base64)
- и некий хеш (md5Hex) — значение, вычисляемое на основе имени, пароля, срока годности токена и секретного ключа. Вычисляется он так:
md5Hex(username + ":" + expirationTime + ":" password + ":" + key)
Весь токен такой:
base64(username + ":" + expirationTime + ":" + md5Hex(username + ":" + expirationTime + ":" password + ":" + key)), где username: As identifiable to the UserDetailsService password: That matches the one in the retrieved UserDetails expirationTime: The date and time when the remember-me token expires, expressed in milliseconds key: A private key to prevent modification of the remember-me token
Из этого хеша md5Hex пароль обратно не восстановить, но на бэкенде можно по доступному из токена имени найти пароль и вычислить хеш заново. Приложение так и делает — каждый раз когда токен приходит, оно находит по имени пароль, вычисляет md5Hex, убеждается что он совпадает с полученным и выносит вердикт — является ли пользователь тем, за кого себя выдает.
Теоретически клиент каждый раз мог бы просто высылать имя и пароль, а приложение каждый раз так же находить по имени пароль и проверять, совпадает ли он. Но пароль передавать опасно. Суть в том, чтобы передавать именно хеш — значение, из которого невозможно извлечь данные, на которых он построен. То есть он работает в одну сторону. Зная данные (у нас это имя, пароль, срок годности и секретный ключ), хэш можно посчитать, а из хэша обратно данные не получить. То есть хотя в браузере и виден токен, пароль из него не извлечь.
Настройка Remember-Me аутентификации
Сессии по умолчанию включены. Давайте добавим Remember-Me:
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/user/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN") .antMatchers("/admin/**").hasAuthority("ROLE_ADMIN") .antMatchers("/**").permitAll() .and().formLogin() .and().rememberMe(); }
При такой настройке будут использоваться как сессии, так и Simple Hash-Based токен. Токен продолжит действовать, когда сессия истечет, но данные из сессии (если они есть) будет уже не извлечь.
Чтобы задать срок действия токена 24 часа:
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/user/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN") .antMatchers("/admin/**").hasAuthority("ROLE_ADMIN") .antMatchers("/**").permitAll() .and().formLogin() .and().rememberMe().tokenValiditySeconds(86400); }
Можно отключить сессии и использовать только токен:
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/user/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN") .antMatchers("/admin/**").hasAuthority("ROLE_ADMIN") .antMatchers("/**").permitAll() .and().formLogin() .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and().rememberMe(); }
Чтобы задать секретный ключ, используем key(«secretkey»):
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/user/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN") .antMatchers("/admin/**").hasAuthority("ROLE_ADMIN") .antMatchers("/**").permitAll() .and().formLogin() .and().rememberMe().key("secretkey"); }
Если ключ не задать, он генерируется автоматически.
Также можно сделать создание токена обязательным, независимо от того, включен ли на форме флажок remember-me:
.alwaysRemember(true);
При отключенных сессиях так и надо делать (а флажок на форме убрать), потому что тогда Remember-Me токен остается единственным способом идентифицировать пользователя при последующих запросах, а значит, он должен быть обязательным.
Проверка
Итак, теперь в запросе появляется новый Cookie remember-me (см. картинку выше).
Если удалить сессию, аутентификация продолжает работать — доступ к защищенным страницам открыт.
Можно это проверить, удалив сookie JSESSIONID и перезагрузив страницу:
Либо можно в настройках сделать Tomcat сессию короткой и дождаться, когда она истечет.
Код примера
Код примера есть на GitHub.
Получается, если подключена сессия и remember-me, то при первой аунтефикации мы получаем
Set-Cookie: JSESSIONID=4C7871D1EF406F69C7CF20CD6BD283F1
и дальше например 30 минут ходим с этим JSESSIONID, даже не проверяя
Set-Cookie: remember-me=…. ?
И если ответ на вопрос выше «да», то получается когда мы ходим с Set-Cookie: remember-me= , то сервер нам не выдает сессию?И чтобы ее нам получить нужно выйти и еще раз аунтефицироваться?
1) Да, remember-me вначале не проверяется (потому что когда запрос приходит в RememberMeAuthenticationFilter, к этому моменту уже есть заполненный в более ранних фильтрах Authentication, и блок проверки пропускается).
2) Когда ходим с Cookie: remember-me=, то сессия выдается новая (если сессии не отключены). Но аунтетифицироваться заново не надо, remember-me с захэшированным именем и паролем как раз играет роль автоматического перелогина, просто без интерфейса (перенаправления на форму, ручного ввода, вот этого всего). Разница в том, что при вводе с формы имя и пароль проверяются в UsernamePasswordAuthenticationFilter (и в нем создается объект Authentication), а при при передаче захешированного имени_пароля в remember-me-токене это происходит в RememberMeAuthenticationFilter.
Кстати, Remember-me сохранит аутентификацию не только при естественном истечении времени сессии, но и при перезапуске приложения. (Только надо тогда задать .key(“secretkey”), иначе ключ будет автогенериться при перезапуске приложения и проверка Remember-me токена с новым секретом не пройдет; впрочем, секретный ключ надо в любом случае задавать)