Статьи

Сессия без сохранения состояния для мультитенантного приложения, использующего Spring Security

stateless_session Однажды я опубликовал одну статью, объясняющую принцип построения сессии без гражданства . По совпадению, мы снова работаем над той же задачей, но на этот раз для мультитенантного приложения. На этот раз вместо того, чтобы самим создавать механизм аутентификации, мы интегрируем наше решение в среду Spring Security.

Эта статья объяснит наш подход и реализацию.

Бизнес-требование

Нам нужно создать механизм аутентификации для приложения Saas. Каждый клиент получает доступ к приложению через выделенный поддомен. Поскольку приложение будет развернуто в облаке, совершенно очевидно, что сеанс без сохранения состояния является предпочтительным выбором, поскольку он позволяет без дополнительных усилий развертывать дополнительные экземпляры.

В глоссарии проекта у каждого клиента есть один сайт. Каждое приложение — это одно приложение. Например, сайт может быть Microsoft или Google. Приложение может быть Gmail, GooglePlus или Google Drive. Субдомен, который пользователь использует для доступа к приложению, будет включать приложение и сайт. Например, он может выглядеть как microsoft.mail.somedomain.com или google.map.somedomain.com

Пользователь, однажды войдя в одно приложение, может получить доступ к любым другим приложениям, если они для одного сайта. Время сеанса истекает после определенного периода бездействия.

Фон

Сессия без гражданства

Приложение без сохранения состояния с тайм-аутом не является чем-то новым. С момента выхода первого релиза в 2007 году игровая среда оставалась без состояния. Много лет назад мы также перешли на сессию без сохранения состояния. Преимущество довольно очевидно. Ваш балансировщик нагрузки не нуждается в липкости; следовательно, это легче настроить. Во время сеанса в браузере мы можем просто добавить новые серверы, чтобы немедленно увеличить емкость. Однако недостатком является то, что ваша сессия уже не такая большая и уже не такая конфиденциальная.

По сравнению с приложением с сохранением состояния, в котором сеанс хранится на сервере, приложение без сохранения состояния сохраняет сеанс в файле cookie HTTP, размер которого не может превышать 4 КБ. Более того, поскольку это cookie, разработчикам рекомендуется хранить только текст или цифры в сеансе, а не сложную структуру данных. Сеанс сохраняется в браузере и передается на сервер в каждом отдельном запросе. Поэтому мы должны держать сессию как можно меньше и не помещать на нее конфиденциальные данные. Короче говоря, сеанс без состояния вынуждает разработчика изменить способ использования приложения сессией. Это должен быть идентификатор пользователя, а не удобный магазин.

Структура безопасности

Идея, лежащая в основе Security Framework, довольно проста: она помогает определить принцип выполнения кода, проверить, есть ли у него разрешение на выполнение некоторых служб, и выдает исключения, если пользователь этого не делает. С точки зрения реализации, инфраструктура безопасности интегрируется с вашим сервисом в архитектуре стиля AOP. Каждая проверка будет выполняться платформой перед вызовом метода. Механизмом реализации проверки прав доступа может быть фильтр или прокси.

Как правило, инфраструктура безопасности будет хранить основную информацию в хранилище потоков (ThreadLocal в Java). Поэтому он может предоставить разработчикам статический метод доступа к принципалу в любое время. Я думаю, что это то, что разработчики должны хорошо знать; в противном случае они могут реализовать проверку разрешений или получение принципала в некоторых фоновых заданиях, выполняющихся в отдельных потоках. В этой ситуации очевидно, что структура безопасности не сможет найти принципала.

Единая точка входа

Единый вход в основном реализован с использованием сервера аутентификации. Он не зависит от механизма реализации сеанса (без сохранения состояния или с сохранением состояния). Каждое приложение по-прежнему поддерживает свой собственный сеанс. При первом доступе к приложению оно связывается с сервером аутентификации для аутентификации пользователя, а затем создает свой собственный сеанс.

Пища для размышлений

Рамки или построить с нуля

Поскольку сеанс без сохранения состояния является стандартом, самая большая проблема для нас — использовать или не использовать среду безопасности. Если мы используем, то Spring Security является самым дешевым и быстрым решением, потому что мы уже используем Spring Framework в нашем приложении. В интересах любой среды безопасности мы предоставляем быстрый и декларативный способ объявления правила оценки. Однако это не будет правилом доступа с учетом бизнес-логики. Например, мы можем определить, что только Агент может получить доступ к продуктам, но мы не можем определить, что один агент может получить доступ только к некоторым продуктам, которые ему принадлежат.

В этой ситуации у нас есть два варианта: создать собственную проверку разрешений бизнес-логики с нуля или создать 2 уровня проверки разрешений, один только на основе ролей, другой — на основе бизнес-логики. После сравнения двух подходов мы выбрали последний, потому что он дешевле и быстрее строится. Наше приложение будет работать аналогично любому другому приложению Spring Security. Это означает, что пользователь будет перенаправлен на страницу входа при доступе к защищенному контенту без сеанса. Если сеанс существует, пользователь получит код состояния 403. Если пользователь получит доступ к защищенному контенту с допустимой ролью, но с неавторизованными записями, он получит вместо этого 401.

Аутентификация

Следующая проблема — как интегрировать наш механизм аутентификации и авторизации с Spring Security. Стандартное приложение Spring Security может обработать запрос, как показано ниже:

standard_spring_security

Диаграмма упрощена, но все же дает нам общее представление о том, как все работает. Если запросом является вход или выход из системы, два верхних фильтра обновляют сеанс на стороне сервера. После этого еще один фильтр поможет проверить права доступа для запроса. Если проверка разрешения прошла успешно, другой фильтр поможет сохранить сеанс пользователя в хранилище потоков. После этого контроллер выполнит код с правильно настроенной средой.

Для нас мы предпочитаем создать наш механизм аутентификации, поскольку учетные данные должны содержать домен сайта. Например, у нас могут быть Joe из Xerox и Joe из WDS, которые обращаются к Saas-приложению. Поскольку Spring Security берет на себя управление подготовкой токена аутентификации и провайдера аутентификации, мы обнаруживаем, что дешевле осуществлять вход в систему и выходить из нее на уровне контроллера, чем тратить усилия на настройку Spring Security.

Поскольку мы реализуем сеанс без сохранения состояния, нам нужно реализовать две работы. Во-первых, нам нужно построить сессию из cookie перед проверкой авторизации. Нам также необходимо обновить отметку времени сеанса, чтобы сеанс обновлялся каждый раз, когда браузер отправляет запрос на сервер.

Из-за более раннего решения сделать аутентификацию в контроллере, здесь мы сталкиваемся с проблемой. Мы не должны обновлять сессию до запуска контроллера, потому что здесь мы выполняем аутентификацию. Тем не менее, некоторые методы контроллера подключены к View Resolver, которые немедленно записывают в выходной поток. Поэтому у нас нет шансов обновить cookie после запуска контроллера. Наконец, мы выбираем слегка скомпрометированное решение, используя HandlerInterceptorAdapter. Этот обработчик перехватчика позволяет нам выполнять дополнительную обработку до и после каждого метода контроллера. Мы реализуем обновление cookie после метода контроллера, если метод предназначен для аутентификации, и перед методами контроллера для любых других целей. Новая диаграмма должна выглядеть так

stateless_spring_security

печенье

Чтобы быть осмысленным, у пользователя должен быть только один сессионный cookie. Поскольку сессия всегда меняет отметку времени после каждого запроса, нам нужно обновлять сессию для каждого отдельного ответа. По протоколу HTTP это можно сделать только в том случае, если файлы cookie соответствуют имени, пути и домену.

Получая это бизнес-требование, мы предпочитаем попробовать новый способ реализации единого входа путем обмена сессионными cookie. Если каждое приложение находится в одном родительском домене и понимает один и тот же файл cookie сеанса, фактически мы имеем глобальный сеанс. Следовательно, сервер аутентификации больше не нужен. Чтобы реализовать это видение, мы должны установить домен в качестве родительского домена для всех приложений.

Производительность

Теоретически сеанс без сохранения состояния должен быть медленнее. Предполагая, что реализация сервера хранит таблицу сеансов в памяти, передача файла cookie JSESSIONID вызовет только однократное чтение объекта из таблицы сеансов и необязательную однократную запись для обновления последнего доступа (для расчета времени ожидания сеанса). Напротив, для сеанса без сохранения состояния нам нужно вычислить хеш для проверки cookie сеанса, загрузить участника из базы данных, назначить новую метку времени и снова хэшировать.

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

В лучшем случае сеанс без сохранения состояния может выполняться достаточно близко к состоянию, если не было выполнено ни одного вызова БД. Вместо загрузки из таблицы сеансов, которая поддерживается контейнером, сеанс загружается из внутреннего кэша, который поддерживается приложением. В худшем случае запросы направляются на множество разных серверов, и основной объект хранится во многих случаях. Это добавляет дополнительные усилия для загрузки принципала в кэш один раз для каждого сервера. Хотя стоимость может быть высокой, это происходит только время от времени.

Если мы применим липкую маршрутизацию для балансировщика нагрузки, мы сможем достичь наилучшей производительности сценария. При этом мы можем воспринимать cookie сеанса без сохранения состояния как механизм, аналогичный JSESSIONID, но с возможностью восстановления объекта сеанса.

Реализация

Я опубликовал пример этой реализации в хранилище https://github.com/tuanngda/sgdev-blog. Пожалуйста, проверьте проект без сохранения состояния. Проект требует базы данных MySQL для работы. Поэтому, пожалуйста, настройте схему в соответствии с build.properties или измените файл свойств в соответствии с вашей схемой.

Проект включает конфигурацию maven для запуска сервера Tomcat на порту 8686. Поэтому вы можете просто набрать mvn cargo: run для запуска сервера.

Вот иерархия проекта:

проект

Я упаковал сервер Tomcat 7 и базу данных, чтобы он работал без какой-либо другой установки, кроме MySQL. Файл конфигурации Tomcat TOMCAT_HOME / conf / context.xml содержит файл объявлений DataSource и свойств проекта.

Теперь давайте посмотрим ближе на реализацию.

сессия

Нам нужны два объекта сеанса, один представляет cookie-файл сеанса, один представляет объект сеанса, который мы создаем внутри среды безопасности Spring:

01
02
03
04
05
06
07
08
09
10
public class SessionCookieData {
  
 private int userId;
  
 private String appId;
  
 private int siteId;
  
 private Date timeStamp;
}

и

01
02
03
04
05
06
07
08
09
10
public class UserSession {
  
 private User user;
  
 private Site site;
 
 public SessionCookieData generateSessionCookieData(){
  return new SessionCookieData(user.getId(), user.getAppId(), site.getId());
 }
}

Благодаря этой комбинации у нас есть объекты для хранения объекта сеанса в cookie и памяти. Следующим шагом является реализация метода, который позволяет нам строить объект сеанса из данных cookie.

1
2
3
4
public interface UserSessionService {
  
 public UserSession getUserSession(SessionCookieData sessionData);
}

Теперь еще один сервис для извлечения и создания файлов cookie из данных cookie.

1
2
3
4
5
6
7
8
public class SessionCookieService {
 
 public Cookie generateSessionCookie(SessionCookieData cookieData, String domain);
 
 public SessionCookieData getSessionCookieData(Cookie sessionCookie);
 
 public Cookie generateSignCookie(Cookie sessionCookie);
}

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

Cookie -> SessionCookieData -> UserSession

и

Сессия -> SessionCookieData -> Cookie

Теперь у нас должно быть достаточно материала для интеграции сеанса без сохранения состояния с платформой Spring Security.

Интеграция с безопасностью Spring

Во-первых, нам нужно добавить фильтр для построения сессии из cookie. Поскольку это должно происходить до проверки прав доступа, лучше использовать AbstractPreAuthenticatedProcessingFilter

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component(value="cookieSessionFilter")
public class CookieSessionFilter extends AbstractPreAuthenticatedProcessingFilter {
  
...
  
 @Override
 protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
  SecurityContext securityContext = extractSecurityContext(request);
   
  if (securityContext.getAuthentication()!=null 
     && securityContext.getAuthentication().isAuthenticated()){
   UserAuthentication userAuthentication = (UserAuthentication) securityContext.getAuthentication();
   UserSession session = (UserSession) userAuthentication.getDetails();
   SecurityContextHolder.setContext(securityContext);
   return session;
  }
   
  return new UserSession();
 }
 ...
  
}

Фильтр выше создает основной объект из cookie сессии. Фильтр также создает PreAuthenticatedAuthenticationToken, который будет использоваться позже для аутентификации. Очевидно, что Spring не поймет этого принципала. Поэтому нам нужно предоставить нашего собственного AuthenticationProvider, который сможет аутентифицировать пользователя на основе этого принципала.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class UserAuthenticationProvider implements AuthenticationProvider {
@Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    PreAuthenticatedAuthenticationToken token = (PreAuthenticatedAuthenticationToken) authentication;
 
    UserSession session = (UserSession)token.getPrincipal();
 
    if (session != null && session.getUser() != null){
      SecurityContext securityContext = SecurityContextHolder.getContext();
      securityContext.setAuthentication(new UserAuthentication(session));
      return new UserAuthentication(session);
    }
 
    throw new BadCredentialsException("Unknown user name or password");
  }
}

Это весенний путь. Пользователь аутентифицируется, если нам удается предоставить действительный объект аутентификации. Практически, мы позволяем пользователю входить в систему сессионным cookie для каждого отдельного запроса.

Однако бывают случаи, когда нам нужно изменить сеанс пользователя, и мы можем сделать это как обычно в методе контроллера. Мы просто перезаписываем SecurityContext, который был настроен ранее в фильтре.

Он также сохраняет UserSession в SecurityContextHolder, который помогает настроить среду. Поскольку это фильтр предварительной аутентификации, он должен хорошо работать для большинства запросов, кроме аутентификации.

Мы должны обновить SecurityContext в методе аутентификации вручную:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
public ModelAndView login(String login, String password, String siteCode) throws IOException{
     
    if(StringUtils.isEmpty(login) || StringUtils.isEmpty(password)){
      throw new HttpServerErrorException(HttpStatus.BAD_REQUEST, "Missing login and password");
    }
     
    User user = authService.login(siteCode, login, password);
    if(user!=null){
      SecurityContext securityContext = SecurityContextHolder.getContext();
      UserSession userSession = new UserSession();
      userSession.setSite(user.getSite());
      userSession.setUser(user);
      securityContext.setAuthentication(new UserAuthentication(userSession));
    }else{
      throw new HttpServerErrorException(HttpStatus.UNAUTHORIZED, "Invalid login or password");
    }
     
    return new ModelAndView(new MappingJackson2JsonView());
     
  }

Обновить сессию

До сих пор вы можете заметить, что мы никогда не упоминали о написании cookie. При условии, что у нас есть действительный объект аутентификации и наш SecurityContext содержит UserSession, важно, чтобы нам нужно было отправить эту информацию в браузер. Прежде чем сгенерировать HttpServletResponse, мы должны прикрепить к нему cookie сессии. Этот файл cookie с похожим доменом и путем заменит старый сеанс, который хранится в браузере.

Как обсуждалось выше, обновление сеанса лучше выполнять после метода контроллера, потому что мы реализуем здесь аутентификацию. Однако проблема вызвана ViewResolver Spring MVC. Иногда он пишет в OutputStream так скоро, что любая попытка добавить cookie в ответ будет бесполезной. Наконец, мы придумали компромиссное решение, которое обновляет сессию до методов контроллера для обычных запросов и после методов контроллера для запросов аутентификации. Чтобы узнать, относятся ли запросы к аутентификации, мы размещаем аннотацию к методам аутентификации.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    if (handler instanceof HandlerMethod){
      HandlerMethod handlerMethod = (HandlerMethod) handler;
      SessionUpdate sessionUpdateAnnotation = handlerMethod.getMethod().getAnnotation(SessionUpdate.class);
       
      if (sessionUpdateAnnotation == null){
        SecurityContext context = SecurityContextHolder.getContext();
        if (context.getAuthentication() instanceof UserAuthentication){
          UserAuthentication userAuthentication = (UserAuthentication)context.getAuthentication();
          UserSession session = (UserSession) userAuthentication.getDetails();
          persistSessionCookie(response, session);
        }
      }
    }
    return true;
  }
 
  @Override
  public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
      ModelAndView modelAndView) throws Exception {
    if (handler instanceof HandlerMethod){
      HandlerMethod handlerMethod = (HandlerMethod) handler;
      SessionUpdate sessionUpdateAnnotation = handlerMethod.getMethod().getAnnotation(SessionUpdate.class);
       
      if (sessionUpdateAnnotation != null){
        SecurityContext context = SecurityContextHolder.getContext();
        if (context.getAuthentication() instanceof UserAuthentication){
          UserAuthentication userAuthentication = (UserAuthentication)context.getAuthentication();
          UserSession session = (UserSession) userAuthentication.getDetails();
          persistSessionCookie(response, session);
        }
      }
    }
  }

Вывод

Решение хорошо работает для нас, но у нас нет уверенности в том, что это лучшие практики. Однако это просто и не требует больших усилий для внедрения (около 3 дней включает тестирование).

Пожалуйста, оставьте отзыв, если у вас есть идея построить сеанс без сохранения состояния с помощью Spring.