я опубликовал одну статью, объясняющую принцип построения сессии без гражданства . По совпадению, мы снова работаем над той же задачей, но на этот раз для мультитенантного приложения. На этот раз вместо того, чтобы самим создавать механизм аутентификации, мы интегрируем наше решение в среду 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). Поэтому он может предоставить разработчикам статический метод доступа к принципалу в любое время. Я думаю, что это то, что разработчики должны хорошо знать; в противном случае они могут реализовать проверку разрешений или получение принципала в некоторых фоновых заданиях, выполняющихся в отдельных потоках. В этой ситуации очевидно, что структура безопасности не сможет найти принципала.
Единый вход
Единый вход в основном реализован с использованием сервера аутентификации. Он не зависит от механизма реализации сеанса (без сохранения состояния или с сохранением состояния). Каждое приложение по-прежнему поддерживает свой собственный сеанс. При первом доступе к приложению оно связывается с сервером аутентификации для аутентификации пользователя, а затем создает свой собственный сеанс.
Food for Thought
Framework или создание с нуля
Поскольку сеанс без сохранения состояния является стандартом, для нас самой большой проблемой является использование или отсутствие использования структуры безопасности. Если мы используем, то Spring Security является самым дешевым и быстрым решением, потому что мы уже используем Spring Framework в нашем приложении. В интересах любой среды безопасности мы предоставляем быстрый и декларативный способ объявления правила оценки. Однако это не будет правилом доступа с учетом бизнес-логики. Например, мы можем определить, что только Агент может получить доступ к продуктам, но мы не можем определить, что один агент может получить доступ только к некоторым продуктам, которые ему принадлежат.
В этой ситуации у нас есть два варианта: создать собственную проверку разрешений бизнес-логики с нуля или создать 2 уровня проверки разрешений, один только на основе ролей, другой — на основе бизнес-логики. После сравнения двух подходов мы выбрали последний, потому что он дешевле и быстрее строится. Наше приложение будет работать аналогично любому другому приложению Spring Security. Это означает, что пользователь будет перенаправлен на страницу входа при доступе к защищенному контенту без сеанса. Если сеанс существует, пользователь получит код состояния 403. Если пользователь получит доступ к защищенному контенту с допустимой ролью, но с неавторизованными записями, он получит вместо этого 401.
Аутентификация
Следующая проблема — как интегрировать наш механизм аутентификации и авторизации с Spring Security. Стандартное приложение Spring Security может обработать запрос, как показано ниже:
Диаграмма упрощена, но все же дает нам общее представление о том, как все работает. Если запросом является вход или выход из системы, два верхних фильтра обновляют сеанс на стороне сервера. После этого еще один фильтр поможет проверить права доступа для запроса. Если проверка разрешения прошла успешно, другой фильтр поможет сохранить сеанс пользователя в хранилище потоков. После этого контроллер выполнит код с правильно настроенной средой.
Для нас мы предпочитаем создать наш механизм аутентификации, поскольку учетные данные должны содержать домен сайта. Например, у нас могут быть Joe из Xerox и Joe из WDS, которые обращаются к Saas-приложению. Поскольку Spring Security берет на себя управление подготовкой токена аутентификации и провайдера аутентификации, мы находим, что дешевле осуществлять вход в систему и выходить из нее самостоятельно на уровне контроллера, чем тратить усилия на настройку Spring Security.
Поскольку мы реализуем сеанс без сохранения состояния, нам нужно реализовать две работы. Во-первых, нам нужно построить сессию из cookie перед проверкой авторизации. Нам также необходимо обновить отметку времени сеанса, чтобы сеанс обновлялся каждый раз, когда браузер отправляет запрос на сервер.
Из-за более раннего решения сделать аутентификацию в контроллере, здесь мы сталкиваемся с проблемой. Мы не должны обновлять сессию до запуска контроллера, потому что здесь мы выполняем аутентификацию. Тем не менее, некоторые методы контроллера подключены к View Resolver, которые немедленно записывают в выходной поток. Поэтому у нас нет шансов обновить cookie после запуска контроллера. Наконец, мы выбираем слегка скомпрометированное решение, используя HandlerInterceptorAdapter. Этот обработчик перехватчика позволяет нам выполнять дополнительную обработку до и после каждого метода контроллера. Мы реализуем обновление cookie после метода контроллера, если метод предназначен для аутентификации, и перед методами контроллера для любых других целей. Новая диаграмма должна выглядеть так
Cookie
Чтобы иметь смысл, у пользователя должен быть только один сессионный cookie. Поскольку сессия всегда меняет отметку времени после каждого запроса, нам нужно обновлять сессию для каждого отдельного ответа. По протоколу HTTP это можно сделать только в том случае, если файлы cookie соответствуют имени, пути и домену.
Получая это бизнес-требование, мы предпочитаем попробовать новый способ реализации единого входа путем обмена сессионными cookie. Если каждое приложение находится в одном родительском домене и понимает один и тот же файл cookie сеанса, фактически мы имеем глобальный сеанс. Следовательно, сервер аутентификации больше не нужен. Чтобы реализовать это видение, мы должны установить домен в качестве родительского домена для всех приложений.
Чтобы проиллюстрировать этот глобальный сеанс, давайте вернемся к более раннему примеру, где у нас есть два приложения, которые содержат доменное имя как
microsoft.mail.somedomain.com или
google.map.somedomain.com
Чтобы файл cookie сеанса был глобальным, мы установим домен как
somedomain.com . Очевидно, что 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 и свойств проекта.
Теперь давайте подробнее рассмотрим реализацию
Session.
Нам нужны два объекта сеанса, один представляет файл cookie сеанса, один представляет объект сеанса, который мы строим внутри среды безопасности Spring:
public class SessionCookieData { private int userId; private String appId; private int siteId; private Date timeStamp; }
и
public class UserSession { private User user; private Site site; public SessionCookieData generateSessionCookieData(){ return new SessionCookieData(user.getId(), user.getAppId(), site.getId()); } }
Благодаря этой комбинации у нас есть объекты для хранения объекта сеанса в cookie и памяти. Следующим шагом является реализация метода, который позволяет нам строить объект сеанса из данных cookie.
public interface UserSessionService { public UserSession getUserSession(SessionCookieData sessionData); }
Теперь еще один сервис для извлечения и создания файлов cookie из данных cookie.
public class SessionCookieService { public Cookie generateSessionCookie(SessionCookieData cookieData, String domain); public SessionCookieData getSessionCookieData(Cookie sessionCookie); public Cookie generateSignCookie(Cookie sessionCookie); }
До этого момента у нас был сервис, который помогает нам выполнять преобразование
Cookie -> SessionCookieData -> UserSession
and
Session -> SessionCookieData -> Cookie.
Теперь у нас должно быть достаточно материала для интеграции сеанса без сохранения состояния с платформой Spring Security.
Интеграция с безопасностью Spring
Сначала нам нужно добавить фильтр для построения сеанса из cookie. Поскольку это должно происходить до проверки прав доступа, лучше использовать
AbstractPreAuthenticatedProcessingFilter
@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, который сможет аутентифицировать пользователя на основе этого принципала.
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, который был настроен ранее в фильтре предварительной аутентификации.
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 сеанса, имеющий аналогичный домен и путь, заменит старый файл cookie сеанса, который хранится в браузере.
Как обсуждалось выше, обновление сеанса лучше выполнять после метода контроллера, потому что мы реализуем аутентификацию на этом уровне. Однако существует проблема, вызванная ViewResolver из Spring MVC. Иногда он пишет в OutputStream так скоро, что любая попытка добавить cookie в ответ будет бесполезной.
После рассмотрения мы придумали компромиссное решение, которое обновляет сеанс до методов контроллера для обычных запросов и после методов контроллера для запросов аутентификации. Чтобы узнать, предназначены ли запросы для аутентификации, мы помещаем новую определенную аннотацию в методы аутентификации.
@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.