1. Предыстория
Не смотря на то, что функция централизованного входа (Single Sign On, SSO) существует, обсуждается и применяется уже давно, на практике ее внедрение зачастую сопровождается преодолением самых различных проблем. Целью данной статьи будет показать, как реализовать простейший собственный Service Provider 1 (SP) для SAML 2.0 identity provider (idP) и с его помощью осуществить интеграции SSO в Java Web приложение.
Одним из наших последних проектов была подготовка и кластеризация портального решения для крупного университета. В рамках проекта мы столкнулись с задачей реализации (а также кластеризации) функции единой аутентификации для следующих систем:
- Liferay version 6.1.20-ee-ga2.
- Простое java web-приложение.
- Google apps.
Со стороны заказчика были выдвинуты основные требования построения SSO:
- Для построения SSO должен использоваться протокол SAML 2.0.
- Требуется интеграция с Jasig CAS для поддержания работы уже существующих систем.
- LDAP используется для проверки аутентификации пользователей.
В качестве idP решили использовать Shibboleth (http://shibboleth.net/about/index.html) как open source-систему, реализующую в полном объеме протоколы SAML 1.0 && SAML 2.0.
Сложные моменты, с которыми мы столкнулись при решении данной задачи:
- Отсутствие экспертизы по работе с протоколом SAML 2.0 и продуктом Shibboleth.
- Сырая и еще не достаточно хорошо структурированная документация по Shibboleth от производителя.
- Отсутствие качественных примеров по реализации Service Provider’а для интеграции SSO в свое Java Web-приложение.
Преодоление этих барьеров и стало мотивацией для публикации данной статьи. Мы хотим поделиться приобретенными знаниями, помочь разработчикам решать подобные задачи, а также облегчить знакомство с протоколом SAML 2.0.
2. Для кого предназначена статья?
Данная статья ориентирована на следующую аудиторию:
- Разработчики, интегрирующие функцию SSO в своих проектах средствами SAML 2.0.
- Java-Разработчики, которым нужен практический пример интеграции в свое приложение функции SSO средствами SAML 2.0.
- Java-Разработчики, которые хотят опробовать в качестве SSO Identity Provider’а (idP) компонент Shibboleth.
Для понимания статьи рекомендуется иметь минимальные знания по протоколу SAML 2.0.
3. Основные компоненты работы SSO
На схеме ниже изображена общая схема функционирования нашего централизованного входа.
Основные компоненты и моменты, отмеченные на диаграмме:
- В системе SSO участвует 2 приложения:
a. Java Web App — обычное Java Web-приложение
b. Google Apps — приложение из облачных служб Google. Мы будем использовать его лишь для проверки работы SSO. - SP Filter — реализация Service Provider, функцией которого будет взаимодействие с Shibboleth idP средствами отправки и разбора сообщений SAML 2.0
- Shibboleth idP — приложение для осуществления аутентификации и авторизации средствами SAML 1.0 и SAML 2.0.
- Tomcat AS — Java Application Server.
- Взаимодействие между SP filter и Shibboleth idP происходят по защищенному протоколу HTTPS.
Замечание: На диаграмме Shibboleth idP и Java Web-приложение физически разнесены на разные серверы Tomcat. Однако вы можете развернуть окружение на одном сетевом узле, используя всего лишь один инстанс Tomcat.
4. Настраиваем окружение для Shibboleth idP
Установка и конфигурация shibboleth idP:
1. Скачиваем последнюю версию idP тут shibboleth.net/downloads/identity-provider/latest/2 и разархивируем в произвольное место $shDistr.
2. Проверяем, что переменная JAVA_HOME установлена корректно3.
Запускаем $shDistr/install.sh (будем считать, что используется UNIX-подобная операционная систем).4
Инсталлятор запросит следующую информацию, которую следует держать в уме:
- путь установки (например: /opt/shib)
- название idP сервера (например: idp.local.ru).
Добавьте idP сервер в список алиасов для локалхоста в файле /etc/hosts:
127.0.0.1 localhost idp.local.ru - пароль для java key store, который генерируется в процессе установки (например: 12345).
Далее проверяем, что процесс установки успешно закончен.
Введем обозначения:
- $shHome — директория, куда был установлен Shibboleth;
- $shHost — название idP сервера;
- $shPassword — пароль для java key store (JKS).
3. Определяем, какие атрибуты и из каких источников будут извлекаться idP. В нашем случае мы будем передавать login пользователя. Добавляем описание атрибута в файл $shHome/conf/attribute-resolver.xml после элемента <resolver:AttributeDefinition id=«transientId» xsi:type=«ad:TransientId»>.
<resolver:AttributeDefinition xsi:type="PrincipalName"
xmlns="urn:mace:shibboleth:2.0:resolver:ad" id="userLogin" >
<resolver:AttributeEncoder xsi:type="SAML1String"
xmlns="urn:mace:shibboleth:2.0:attribute:encoder" name="userLogin" />
<resolver:AttributeEncoder xsi:type="SAML2String"
xmlns="urn:mace:shibboleth:2.0:attribute:encoder" name="userLogin" />
</resolver:AttributeDefinition>
Замечание: в этом же файле можно настроить получение атрибутов из различных источников данных как например LDAP или DBMS через JDBC. Подробнее тут https://wiki.shibboleth.net/confluence/display/SHIB2/IdPAddAttribute.
4. Для того чтобы idP отдавал этот атрибут SAML SP фильтру описываем его в файле $shHome/conf/attribute-filter.xml.
<afp:AttributeFilterPolicy id="releaseUserLoginToAnyone">
<afp:PolicyRequirementRule xsi:type="basic:ANY"/>
<afp:AttributeRule attributeID="userLogin">
<afp:PermitValueRule xsi:type="basic:ANY"/>
</afp:AttributeRule>
</afp:AttributeFilterPolicy>
Замечание: Здесь можно задать более сложное и корректное правило. Например, можно указать, чтобы данный атрибут передавался только определенному SAML SP.
5. Наш Shibboleth idP должен знать о тех узлах, с которыми он может взаимодействовать – так называемые relying party (https://wiki.shibboleth.net/confluence/display/SHIB2/IdPUnderstandingRP). Эта информация хранится в файле $shHome/conf/relying-party.xml.
Открываем файл и добавляем в него следующий элемент:
<rp:RelyingParty id="sp.local.ru" provider="https://idp.local.ru/idp/shibboleth"
defaultSigningCredentialRef="IdPCredential">
<rp:ProfileConfiguration xsi:type="saml:SAML2SSOProfile"
signResponses="never" signAssertions="never"
encryptNameIds="never" encryptAssertions="never" />
</rp:RelyingParty>
Здесь мы указываем, что для SP с id = «sp.local.ru» будет использоваться idP с id="https://idp.local.ru/idp/shibboleth".
Добавьте SP в список алиасов для локалхоста в файле /etc/hosts:
127.0.0.1 localhost sp.local.ru
Также даем указание shibboleth idP не подписывать SAML 2.0 ответы и набор assertions (утверждений). До текущего времени наш shibboleth idP не имел понятия, что из себя представляет компонент с id=«sp.local.ru». Время исправить этот момент. Идем на следующий шаг.
6. Добавляем описание нашего SAML 2.0 SP фильтра. Для этого в файле $shHome/conf/relying-party.xml определяем метаинформацию для нашего SP, рядом с элементом <metadata:MetadataProvider id=«IdPMD» xsi:type=«metadata:FilesystemMetadataProvider»… >
<metadata:MetadataProvider id="spMD" xsi:type="metadata:FilesystemMetadataProvider"
metadataFile="/opt/shib/metadata/saml-sp-metadata.xml"/>
Мы дали указанию shibboleth idP искать определение SP в файле /opt/shib/metadata/saml-sp-metadata.xml. Создаем этот файл со следующим содержимым:
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="sp.local.ru">
<md:SPSSODescriptor AuthnRequestsSigned="false" ID="sp.local.ru"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://sp.local.ru:8443/sso/acs" index="1" isDefault="true"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>
Здесь нужно понимать следующее:
- наш SAML 2.0 SP имеет идентификатор «sp.local.ru»
- адрес, по которому shibboleth idP будет возвращать SAML 2.0 сообщения Location="https://sp.local.ru:8443/sso/acs" указан в элементе md:AssertionConsumerService.
- И, наконец, параметр Binding=«urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST» показывает, что ответ SP будет отправляться от shibboleth idP через редирект броузера.
7. Осталось выбрать способ, с которым shibboleth idP будет проводить реальную аутентификацию пользователей. В окружении production тут могут быть самые разные конфигурации, включая аутентификацию через LDAP, DBMS и даже CAS. Здесь, как говорится, на вкус и цвет. Мы будем использовать уже включенный Remote User Authentication механизм (https://wiki.shibboleth.net/confluence/display/SHIB2/IdPAuthRemoteUser). При получении запроса на аутентификацию shibboleth idP будет смотреть в контексте переменную REMOTE_USER. Если такая переменная есть, то shibboleth idP будет считать, что пользователь уже прошел аутентификацию через внешнюю систему (например, через Web Apache сервер). Дабы не усложнять данную статью мы решили пойти на хитрость и устанавливать переменную REMOTE_USER искусственно для каждого запроса.
Это будет сделано в следующем разделе при настройке Tomcat AS (пункт 7).
Настройка Shibboleth завершена, поздравляем :)
Установка и конфигурация Tomcat для shibboleth idP:
- Скачиваем томкат 6 http://tomcat.apache.org/download-60.cgi, разархивируем в произвольную папку $tomcatHome (например: в opt/shib-tomcat).
Важно отметить, что в данный момент Tomcat 7.* не может использоваться в случае, когда общение между SP и idP происходит напрямую по протоколу SOAP. И хотя в примерах этой статьи мы будем использовать прямые редиректы броузера для осуществления данных коммуникаций, мы все же рекомендуем использовать Tomcat версии 6.
- Копируем папку $shDistr/endorsed в папку $tomcatHome.
- Изменяем файл $tomcatHome/bin/setenv.sh, ставим настройки для динамической и перманентной памяти JVM:
JAVA_OPTS="$JAVA_OPTS -Xmx512m -XX:MaxPermSize=128m" - Скачиваем библиотеку (https://build.shibboleth.net/nexus/content/repositories/releases/edu/internet2/middleware/security/tomcat6/tomcat6-dta-ssl/1.0.0/tomcat6-dta-ssl-1.0.0.jar) для поддержки протокола SOAP в процессе общения между SP и idP в папку $tomcatHome/lib.
Открываем $tomcatHome/conf/server.xml и настраиваем доступ к томкату через HTTPS.
Для этого определяем следующий элемент Connector:<Connector port="8443" protocol="org.apache.coyote.http11.Http11Protocol" SSLImplementation="edu.internet2.middleware.security.tomcat6.DelegateToApplicationJSSEmplementation" scheme="https" SSLEnabled="true" clientAuth="want" keystoreFile="$shHome/credentials/idp.jks" keystorePass="$shPassword" />
Не забудьте заменить переменные $shHome и $shPassword реальными значениями.
- Деплоим приложение shibboleth idP в Tomcat. Для этого создаем файл
$tomcatHome/conf/Catalina/localhost/idp.xml с содержимым:<Context docBase="$shHome/war/idp.war" privileged="true" antiResourceLocking="false" antiJARLocking="false" unpackWAR="false" swallowOutput="true" />
Не забудьте заменить переменные $shHome реальным значением
- Скомпилировать5 следующий класс в произвольную библиотеку tomcat-valve.jar:
public class RemoteUserValve extends ValveBase{ public RemoteUserValve() { } @Override public void invoke(final Request request, final Response response) throws IOException, ServletException { final String username = "idpuser"; final String credentials = "idppass"; final List<String> roles = new ArrayList<String>(); final Principal principal = new GenericPrincipal(null, username, credentials, roles); request.setUserPrincipal(principal); getNext().invoke(request, response); } }
Библиотеку положить в папку ${tomcatHome}/lib. И в файл server.xml добавить строчку
<Valve сlassName=«ru.eastbanctech.java.web.RemoteUserValve» /> внутри элемента
<Host name=«localhost» appBase=«webapps» ..>. После старта сервера при обращении к любому приложению Tomcat сервера автоматически будет проставляться параметр REMOTE_USER со значением idpuser в контекст реквеста.
5. Реализация SP Filter для протокола SAML 2.0
Для реализации данного решения создадим SAML 2.0 Service Provider фильтр, задачами которого будет:
- Фильтр пропускает запросы на публичные ресурсы, для которых не нужна аутентификация.
- Фильтр хранит в себе информацию по аутентифицированному пользователю, чтобы сократить количество обращений к Shibboleth idP.
- Фильтр создает SAML 2.0 запрос на аутентификацию в виде SAML 2.0 сообщения (AuthN) и средствами редиректа браузера перенаправляет пользователя к Shibboleth idP.
- Фильтр обрабатывает ответ от Shibboleth idP, и если процесс аутентификация пользователя прошла успешно, система показывает первоначально запрашиваемый ресурс.
- Фильтр удаляет локальную сессию при логауте пользователя из Java Web-приложения.
- При этом сессия на shibboleth idP продолжает оставаться активной.
С технической точки зрения фильтр будет представлять собой реализацию стандартного интерфейса javax.filter.Filter. Область действия фильтра будет задаваться в конкретном web-приложении.
Теперь, когда функциональность фильтра понятна, приступим к реализации:
1. Создаем скелет maven проекта
Можно сделать через плагин mvn:archetype:
mvn archetype:generate -DgroupId=ru.eastbanctech.java.web -DartifactId=saml-sp-filter -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
Параметры groupId и artefactId можете указать на свой вкус и цвет.
Структура нашего проекта в Intellij Idea будет выглядеть так:
2. Файл сборки pom.xml:
<source lang="xml">
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.eastbanctech.web</groupId>
<artifactId>saml-sp-filter</artifactId>
<name>${project.artifactId}</name>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<jdk.version>1.6</jdk.version>
<encoding>UTF-8</encoding>
<project.build.sourceEncoding>${encoding}</project.build.sourceEncoding>
<project.reporting.outputEncoding>${encoding}</project.reporting.outputEncoding>
</properties>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.5.1</version>
<configuration>
<encoding>${encoding}</encoding>
<sourсe>${jdk.version}</sourсe>
<target>${jdk.version}</target>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
<dependency>
<groupId>org.opensaml</groupId>
<artifactId>opensaml</artifactId>
<version>2.5.1-1</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>2.5</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>log4j-over-slf4j</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.1</version>
</dependency>
</dependencies>
</project>
3. Сердцем нашего фильтра будет класс SAMLSPFilter:
public class SAMLSPFilter implements Filter {
public static final String SAML_AUTHN_RESPONSE_PARAMETER_NAME = "SAMLResponse";
private static Logger log = LoggerFactory.getLogger(SAMLSPFilter.class);
private FilterConfig filterConfig;
private SAMLResponseVerifier checkSAMLResponse;
private SAMLRequestSender samlRequestSender;
@Override
public void init(javax.servlet.FilterConfig config) throws ServletException {
OpenSamlBootstrap.init();
filterConfig = new FilterConfig(config);
checkSAMLResponse = new SAMLResponseVerifier();
samlRequestSender = new SAMLRequestSender();
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
/*
ШАГ 1: Игнорируем запросы, предназначенные не для фильтра
ШАГ 2: Если пришел ответ от Shibboleth idP, обрабатываем его
ШАГ 3: Если получен запрос на logout, удаляем локальную сессию
ШАГ 4: Если пользователь уже аутентифицирован, даем доступ к ресурсу
ШАГ 5: Создаем SAML запрос на аутентификацию и отправляем пользователя к
Shibboleth idP
*/
}
}
В класс FilterConfig определим основные переменные фильтра (область действия фильтра, название idP, путь до метадаты idP, название SP и т.д.). Значения этих параметров будут задаваться в конфигурационном файле web.xml Java Web-приложения. Объекты checkSAMLResponse и samlRequestSender нужны для проверки валидности SAML 2.0 сообщений и отправки запроса аутентификации. К ним вернемся чуть позже.
public class FilterConfig {
/**
* The parameters below should be defined in web.xml file of Java Web Application
*/
public static final String EXCLUDED_URL_PATTERN_PARAMETER = "excludedUrlPattern";
public static final String SP_ACS_URL_PARAMETER = "acsUrl";
public static final String SP_ID_PARAMETER = "spProviderId";
public static final String SP_LOGOUT_URL_PARAMETER = "logoutUrl";
public static final String IDP_SSO_URL_PARAMETER = "idProviderSSOUrl";
private String excludedUrlPattern;
private String acsUrl;
private String spProviderId;
private String logoutUrl;
private String idpSSOUrl;
public FilterConfig(javax.servlet.FilterConfig config) {
excludedUrlPattern = config.getInitParameter(EXCLUDED_URL_PATTERN_PARAMETER);
acsUrl = config.getInitParameter(SP_ACS_URL_PARAMETER);
spProviderId = config.getInitParameter(SP_ID_PARAMETER);
idpSSOUrl = config.getInitParameter(IDP_SSO_URL_PARAMETER);
logoutUrl = config.getInitParameter(SP_LOGOUT_URL_PARAMETER);
}
// getters and should be defined below
}
Класс OpenSamlBootstrap инициализирует библиотеки для работы с SAML 2.0 сообщениями:
public class OpenSamlBootstrap extends DefaultBootstrap {
private static Logger log = LoggerFactory.getLogger(OpenSamlBootstrap.class);
private static boolean initialized;
private static String[] xmlToolingConfigs = {
"/default-config.xml",
"/encryption-validation-config.xml",
"/saml2-assertion-config.xml",
"/saml2-assertion-delegation-restriction-config.xml",
"/saml2-core-validation-config.xml",
"/saml2-metadata-config.xml",
"/saml2-metadata-idp-discovery-config.xml",
"/saml2-metadata-query-config.xml",
"/saml2-metadata-validation-config.xml",
"/saml2-protocol-config.xml",
"/saml2-protocol-thirdparty-config.xml",
"/schema-config.xml",
"/signature-config.xml",
"/signature-validation-config.xml"
};
public static synchronized void init() {
if (!initialized) {
try {
initializeXMLTooling(xmlToolingConfigs);
} catch (ConfigurationException e) {
log.error("Unable to initialize opensaml DefaultBootstrap", e);
}
initializeGlobalSecurityConfiguration();
initialized = true;
}
}
}
Набор XML файлов содержит инструкции, как разбирать элементы SAML 2.0 сообщений и содержится в библиотеке opensaml-*.jar, которая подключится при сборке проекта через maven.
ШАГ 1: Игнорируем запросы, не предназначенные для фильтра
Параметр excludedUrlPattern, который заключает в себе регулярное выражение. Если запрашиваемый ресурс попадает в шаблон excludedUrlPattern, то фильтр не обрабатывает его:
if (!isFilteredRequest(request)) {
log.debug("According to {} configuration parameter request is ignored + {}",
new Object[]{FilterConfig.EXCLUDED_URL_PATTERN, request.getRequestURI()});
chain.doFilter(servletRequest, servletResponse);
return;
}
// В класс фильтра добавляем метод, проверяющий нужно ли обрабатывать данный запрос
private boolean isFilteredRequest(HttpServletRequest request) {
return !(filterConfig.getExcludedUrlPattern() != null &&
getCorrectURL(request).matches(filterConfig.getExcludedUrlPattern()));
}
// Также добавляем вспомогательный метод получения корректного URL
private String getCorrectURL(HttpServletRequest request) {
String contextPath = request.getContextPath();
String requestUri = request.getRequestURI();
int contextBeg = requestUri.indexOf(contextPath);
int contextEnd = contextBeg + contextPath.length();
String slash = "/";
String url = (contextBeg < 0 || contextEnd == (requestUri.length() - 1))
? requestUri : requestUri.substring(contextEnd);
if (!url.startsWith(slash)) {
url = slash + url;
}
return url;
}
Шаг 2: Если пришел ответ от Shibboleth idP, обрабатываем его
Ищем в реквесте параметр “SAMLResponse” и если он найден, значит, мы получили ответ от shibboleth idP на запрос аутентификации. Приступаем к обработке SAML 2.0 сообщения.
log.debug("Attempt to secure resource is intercepted : {}", ((HttpServletRequest) servletRequest).getRequestURL().toString());
/*
Check if response message is received from identity provider;
In case of successful response system redirects user to relayState (initial) request
*/
String responseMessage = servletRequest.getParameter(SAML_AUTHN_RESPONSE_PARAMETER_NAME);
if (responseMessage != null) {
log.debug("Response from Identity Provider is received");
try {
log.debug("Decoding of SAML message");
SAMLMessageContext samlMessageContext =
SAMLUtils.decodeSamlMessage((HttpServletRequest) servletRequest,
(HttpServletResponse) servletResponse);
log.debug("SAML message has been decoded successfully");
samlMessageContext.setLocalEntityId(filterConfig.getSpProviderId());
String relayState = getInitialRequestedResource(samlMessageContext);
checkSAMLResponse.verify(samlMessageContext);
log.debug("Starting and store SAML session..");
SAMLSessionManager.getInstance().createSAMLSession(request.getSession(),
samlMessageContext);
log.debug("User has been successfully authenticated in idP. Redirect to initial
requested resource {}", relayState);
response.sendRedirect(relayState);
return;
} catch (Exception e) {
throw new ServletException(e);
}
}
Для этого декодируем SAML сообщение в методе SAMLUtils.decodeSamlMessage(..), проверяем выполнимость SAML утверждений — checkSAMLResponse.verify(..). Если все проверки выполнены, то создаем внутреннюю SAML сессию SAMLSessionManager.getInstance().createSAMLSession(..) и редиректим пользователя на первоначально запрашиваемый ресурс response.sendRedirect(..).
В классе SAMLUtils будем размещать полезные промежуточные методы при работе с SAML 2.0 сообщениями. Одним из таких методов будет метод decodeSamlMessage, который декодирует полученные через HTTPS SAML 2.0 сообщения.
public class SAMLUtils {
public static SAMLMessageContext decodeSamlMessage(HttpServletRequest request, HttpServletResponse response) throws Exception {
SAMLMessageContext<SAMLObject, SAMLObject, NameID> samlMessageContext =
new BasicSAMLMessageContext<SAMLObject, SAMLObject, NameID>();
HttpServletRequestAdapter httpServletRequestAdapter =
new HttpServletRequestAdapter(request);
samlMessageContext.setInboundMessageTransport(httpServletRequestAdapter);
samlMessageContext.setInboundSAMLProtocol(SAMLConstants.SAML20P_NS);
HttpServletResponseAdapter httpServletResponseAdapter =
new HttpServletResponseAdapter(response, request.isSecure());
samlMessageContext.setOutboundMessageTransport(httpServletResponseAdapter);
samlMessageContext.setPeerEntityRole(IDPSSODescriptor.DEFAULT_ELEMENT_NAME);
SecurityPolicyResolver securityPolicyResolver =
getSecurityPolicyResolver(request.isSecure());
samlMessageContext.setSecurityPolicyResolver(securityPolicyResolver);
HTTPPostDecoder samlMessageDecoder = new HTTPPostDecoder();
samlMessageDecoder.decode(samlMessageContext);
return samlMessageContext;
}
private static SecurityPolicyResolver getSecurityPolicyResolver(boolean isSecured) {
SecurityPolicy securityPolicy = new BasicSecurityPolicy();
HTTPRule httpRule = new HTTPRule(null, null, isSecured);
MandatoryIssuerRule mandatoryIssuerRule = new MandatoryIssuerRule();
List<SecurityPolicyRule> securityPolicyRules = securityPolicy.getPolicyRules();
securityPolicyRules.add(httpRule);
securityPolicyRules.add(mandatoryIssuerRule);
return new StaticSecurityPolicyResolver(securityPolicy);
}
}
В этот же класс поместим вспомогательный метод для преобразования SAML объектов в String. Это будет полезно при логировании SAML сообщений.
public static String SAMLObjectToString(XMLObject samlObject) {
try {
Marshaller marshaller =
org.opensaml.Configuration.getMarshallerFactory().getMarshaller(samlObject);
org.w3c.dom.Element authDOM = marshaller.marshall(samlObject);
StringWriter rspWrt = new StringWriter();
XMLHelper.writeNode(authDOM, rspWrt);
return rspWrt.toString();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
Создадим класс SAMLResponseVerifier, в который поместим функциональность для проверки SAML 2.0 сообщений, полученных от shibboleth idP. В главном методе verify(..) реализуем следующие проверки:
- Данному SAML 2.0 ответу от idP предшествовал SAML 2.0 запрос, отправленный нашим фильтром.
- Сообщение содержится положительный результат аутентификации пользователя через shibboleth idP.
- Основные утверждения в ответе SAML 2.0 выполнены (срок давности сообщения не истек, данное сообщение предназначено для нашего SP и т.д.).
public class SAMLResponseVerifier {
private static Logger log = LoggerFactory.getLogger(SAMLResponseVerifier.class);
private SAMLRequestStore samlRequestStore = SAMLRequestStore.getInstance();
public void verify(SAMLMessageContext<Response, SAMLObject, NameID> samlMessageContext)
throws SAMLException {
Response samlResponse = samlMessageContext.getInboundSAMLMessage();
log.debug("SAML Response message : {}", SAMLUtils.SAMLObjectToString(samlResponse));
verifyInResponseTo(samlResponse);
Status status = samlResponse.getStatus();
StatusCode statusCode = status.getStatusCode();
String statusCodeURI = statusCode.getValue();
if (!statusCodeURI.equals(StatusCode.SUCCESS_URI)) {
log.warn("Incorrect SAML message code : {} ",
statusCode.getStatusCode().getValue());
throw new SAMLException("Incorrect SAML message code : " + statusCode.getValue());
}
if (samlResponse.getAssertions().size() == 0) {
log.error("Response does not contain any acceptable assertions");
throw new SAMLException("Response does not contain any acceptable assertions");
}
Assertion assertion = samlResponse.getAssertions().get(0);
NameID nameId = assertion.getSubject().getNameID();
if (nameId == null) {
log.error("Name ID not present in subject");
throw new SAMLException("Name ID not present in subject");
}
log.debug("SAML authenticated user " + nameId.getValue());
verifyConditions(assertion.getConditions(), samlMessageContext);
}
private void verifyInResponseTo(Response samlResponse) {
String key = samlResponse.getInResponseTo();
if (!samlRequestStore.exists(key)) { {
log.error("Response does not match an authentication request");
throw new RuntimeException("Response does not match an authentication request");
}
samlRequestStore.removeRequest(samlResponse.getInResponseTo());
}
private void verifyConditions(Conditions conditions, SAMLMessageContext samlMessageContext) throws SAMLException{
verifyExpirationConditions(conditions);
verifyAudienceRestrictions(conditions.getAudienceRestrictions(), samlMessageContext);
}
private void verifyExpirationConditions(Conditions conditions) throws SAMLException {
log.debug("Verifying conditions");
DateTime currentTime = new DateTime(DateTimeZone.UTC);
log.debug("Current time in UTC : " + currentTime);
DateTime notBefore = conditions.getNotBefore();
log.debug("Not before condition : " + notBefore);
if ((notBefore != null) && currentTime.isBefore(notBefore))
throw new SAMLException("Assertion is not conformed with notBefore condition");
DateTime notOnOrAfter = conditions.getNotOnOrAfter();
log.debug("Not on or after condition : " + notOnOrAfter);
if ((notOnOrAfter != null) && currentTime.isAfter(notOnOrAfter))
throw new SAMLException("Assertion is not conformed with notOnOrAfter condition");
}
private void verifyAudienceRestrictions(
List<AudienceRestriction> audienceRestrictions,
SAMLMessageContext<?, ?, ?> samlMessageContext)
throws SAMLException{
// TODO: Audience restrictions should be defined below<sup>7</sup>
}
}
В методе verifyInResponseTo делается проверка о том, что SAML 2.0-ответу предшествовал запрос от нашего фильтра. Для реализации используется объект класса SAMLRequestStore, в котором хранятся отправленные SAML 2.0 запросы к shibboleth idP.
final public class SAMLRequestStore {
private Set<String> samlRequestStorage = new HashSet<String>();
private IdentifierGenerator identifierGenerator = new RandomIdentifierGenerator();
private static SAMLRequestStore instance = new SAMLRequestStore();
private SAMLRequestStore() {
}
public static SAMLRequestStore getInstance() {
return instance;
}
public synchronized void storeRequest(String key) {
if (samlRequestStorage.contains(key))
throw new RuntimeException("SAML request storage has already contains key " + key);
samlRequestStorage.add(key);
}
public synchronized String storeRequest(){
String key = null;
while (true) {
key = identifierGenerator.generateIdentifier(20);
if (!samlRequestStorage.contains(key)){
storeRequest(key);
break;
}
}
return key;
}
public synchronized boolean exists(String key) {
return samlRequestStorage.contains(key);
}
public synchronized void removeRequest(String key) {
samlRequestStorage.remove(key);
}
}
Для создании локальной сессии будем использовать свой класс SAMLSessionManager. Его задачей будет создавать/уничтожать локальные сессии, которая представляет из себя объект следующего класса SAMLSessionInfo.
public class SAMLSessionInfo {
private String nameId;
private Map<String, String> attributes;
private Date validTo;
public SAMLSessionInfo(String nameId, Map<String, String> attributes, Date validTo) {
this.nameId = nameId;
this.attributes = attributes;
this.validTo = validTo;
}
// getters should be defined below
}
Собственно сам класс SAMLSessionManager, который создает и уничтожает локальные SAML сессии в Session контексте cервлета, используя SAMLContext.
<source lang="java">
public class SAMLSessionManager {
public static String SAML_SESSION_INFO = "SAML_SESSION_INFO";
private static SAMLSessionManager instance = new SAMLSessionManager();
private SAMLSessionManager() {
}
public static SAMLSessionManager getInstance() {
return instance;
}
public void createSAMLSession(HttpSession session, SAMLMessageContext<Response,
SAMLObject, NameID> samlMessageContext) {
List<Assertion> assertions =
samlMessageContext.getInboundSAMLMessage().getAssertions();
NameID nameId = (assertions.size() != 0 && assertions.get(0).getSubject() != null) ?
assertions.get(0).getSubject().getNameID() : null;
String nameValue = nameId == null ? null : nameId.getValue();
SAMLSessionInfo samlSessionInfo = new SAMLSessionInfo(nameValue,
getAttributesMap(getSAMLAttributes(assertions)),
getSAMLSessionValidTo(assertions));
session.setAttribute(SAML_SESSION_INFO, samlSessionInfo);
}
public boolean isSAMLSessionValid(HttpSession session) {
SAMLSessionInfo samlSessionInfo = (SAMLSessionInfo)
session.getAttribute(SAML_SESSION_INFO);
if (samlSessionInfo == null)
return false;
return samlSessionInfo.getValidTo() == null || new
Date().before(samlSessionInfo.getValidTo());
}
public void destroySAMLSession(HttpSession session) {
session.removeAttribute(SAML_SESSION_INFO);
}
public List<Attribute> getSAMLAttributes(List<Assertion> assertions) {
List<Attribute> attributes = new ArrayList<Attribute>();
if (assertions != null) {
for (Assertion assertion : assertions) {
for (AttributeStatement attributeStatement :
assertion.getAttributeStatements()) {
for (Attribute attribute : attributeStatement.getAttributes()) {
attributes.add(attribute);
}
}
}
}
return attributes;
}
public Date getSAMLSessionValidTo(List<Assertion> assertions) {
org.joda.time.DateTime sessionNotOnOrAfter = null;
if (assertions != null) {
for (Assertion assertion : assertions) {
for (AuthnStatement statement : assertion.getAuthnStatements()) {
sessionNotOnOrAfter = statement.getSessionNotOnOrAfter();
}
}
}
return sessionNotOnOrAfter != null ?
sessionNotOnOrAfter.toCalendar(Locale.getDefault()).getTime() : null;
}
public Map<String, String> getAttributesMap(List<Attribute> attributes) {
Map<String, String> result = new HashMap<String, String>();
for (Attribute attribute : attributes) {
result.put(attribute.getName(), attribute.getDOM().getTextContent());
}
return result;
}
}
Шаг 3: Если получен запрос на logout, удаляем локальную сессию
if (getCorrectURL(request).equals(filterConfig.getLogoutUrl())) {
log.debug("Logout action: destroying SAML session.");
SAMLSessionManager.getInstance().destroySAMLSession(request.getSession());
chain.doFilter(request, response);
return;
}
Замечание: стоит отметить, что сессия остается активной на shibboleth idP и при следующем запросе на аутентификацию shibboleth idP просто вернет нам активную сессию. Реализация же глобального logout требует дополнительных настроек и до версии 2.4.0 не поддерживалась shibboleth idP. Подробнее можно почитать тут https://wiki.shibboleth.net/confluence/display/SHIB2/SLOIssues
Шаг 4: Если пользователь уже аутентифицирован, даем доступ к ресурсу
Если пользователь имеет активную SAML сессию в нашем фильтре, то даем пользователю данный ресурс.
if (SAMLSessionManager.getInstance().isSAMLSessionValid(request.getSession())) {
log.debug("SAML session exists and valid: grant access to secure resource");
chain.doFilter(request, response);
return;
}
Шаг 5: Создаем SAML запрос на аутентификацию и отправляем пользователя к
Shibboleth idP
log.debug("Sending authentication request to idP");
try {
samlRequestSender .sendSAMLAuthRequest(request, response,
filterConfig.getSpProviderId(), filterConfig.getAcsUrl(),
filterConfig.getIdpSSOUrl());
} catch (Exception e) {
throw new ServletException(e);
}
Класс SAMLRequestSender создает, кодирует и отправляет запросы в виде SAML 2.0-сообщений.
<source lang="java">
public class SAMLRequestSender {
private static Logger log = LoggerFactory.getLogger(SAMLRequestSender.class);
private SAMLAuthnRequestBuilder samlAuthnRequestBuilder =
new SAMLAuthnRequestBuilder();
private MessageEncoder messageEncoder = new MessageEncoder();
public void sendSAMLAuthRequest(HttpServletRequest request, HttpServletResponse
servletResponse, String spId, String acsUrl, String idpSSOUrl) throws Exception {
String redirectURL;
String idpUrl = idpSSOUrl;
AuthnRequest authnRequest = samlAuthnRequestBuilder.buildRequest(spId, acsUrl,
idpUrl);
// store SAML 2.0 authentication request
String key = SAMLRequestStore.getInstance().storeRequest();
authnRequest.setID(key);
log.debug("SAML Authentication message : {} ",
SAMLUtils.SAMLObjectToString(authnRequest));
redirectURL = messageEncoder.encode(authnRequest, idpUrl, request.getRequestURI());
HttpServletResponseAdapter responseAdapter =
new HttpServletResponseAdapter(servletResponse, request.isSecure());
HTTPTransportUtils.addNoCacheHeaders(responseAdapter);
HTTPTransportUtils.setUTF8Encoding(responseAdapter);
responseAdapter.sendRedirect(redirectURL);
}
private static class SAMLAuthnRequestBuilder {
public AuthnRequest buildRequest(String spProviderId, String acsUrl, String idpUrl){
/* Building Issuer object */
IssuerBuilder issuerBuilder = new IssuerBuilder();
Issuer issuer =
issuerBuilder.buildObject("urn:oasis:names:tc:SAML:2.0:assertion",
"Issuer", "saml2p");
issuer.setValue(spProviderId);
/* Creation of AuthRequestObject */
DateTime issueInstant = new DateTime();
AuthnRequestBuilder authRequestBuilder = new AuthnRequestBuilder();
AuthnRequest authRequest =
authRequestBuilder.buildObject(SAMLConstants.SAML20P_NS,
"AuthnRequest", "saml2p");
authRequest.setForceAuthn(false);
authRequest.setIssueInstant(issueInstant);
authRequest.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI);
authRequest.setAssertionConsumerServiceURL(acsUrl);
authRequest.setIssuer(issuer);
authRequest.setNameIDPolicy(nameIdPolicy);
authRequest.setVersion(SAMLVersion.VERSION_20);
authRequest.setDestination(idpUrl);
return authRequest;
}
}
private static class MessageEncoder extends HTTPRedirectDeflateEncoder {
public String encode(SAMLObject message, String endpointURL, String relayState)
throws MessageEncodingException {
String encodedMessage = deflateAndBase64Encode(message);
return buildRedirectURL(endpointURL, relayState, encodedMessage);
}
public String buildRedirectURL(String endpointURL, String relayState, String message)
throws MessageEncodingException {
URLBuilder urlBuilder = new URLBuilder(endpointURL);
List<Pair<String, String>> queryParams = urlBuilder.getQueryParams();
queryParams.clear();
queryParams.add(new Pair<String, String>("SAMLRequest", message));
if (checkRelayState(relayState)) {
queryParams.add(new Pair<String, String>("RelayState", relayState));
}
return urlBuilder.buildURL();
}
}
}
SAML 2.0-сообщение с инструкцией по аутентификации пользователя создается в методе buildRequest и представляет из себя XML объект:
<saml2p:AuthnRequest xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
AssertionConsumerServiceURL="https://sp.local.ru:8443/sso/acs"
Destination="https://idp.local.ru:8443/idp/profile/SAML2/Redirect/SSO"
ForceAuthn="false"
ID="_0ddb303f9500839762eabd30e7b1e3c28b596c69"
IssueInstant="2013-09-12T09:46:41.882Z"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Version="2.0">
<saml2p:Issuer
xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:assertion">sp.local.ru</saml2p:Issuer>
</saml2p:AuthnRequest>
Параметр AssertionConsumerServiceURL задает URL, по которому shibboleth idP будет возвращать ответ, а параметр ProtocolBinding указывает каким образом возвращать ответ нашему фильтру (POST метод протокола HTTP)
Параметр ID определяет идентификатор сообщения, который мы сохраняем при отправке сообщения
String key = SAMLRequestStore.getInstance().storeRequest();
и проверяем при разборе сообщения в методе verifyInResponseTo класса SAMLResponseVerifier.
Элемент saml2p:Issuer определяет имя нашего SP. Используя значение saml2p:Issuer shibboleth idP определяет от какого SP прислан запрос на аутентификацию, и как его нужно обрабатывать (через метадату SP).
В ответ на приведенное выше SAML 2.0 сообщение мы получим ответ от idP в виде SAML 2.0 сообщения в XML формате:
<source lang="xml">
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
Destination="https://sp.local.ru:8443/sso/acs"
ID="_9c5e6028df334510cce22409ddbca6ac"
InResponseTo="_0ddb303f9500839762eabd30e7b1e3c28b596c69"
IssueInstant="2013-09-12T10:13:35.177Z" Version="2.0">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">
https://idp.local.ru/idp/shibboleth
</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_0a299e86f4b17b5e047735121a880ccb" IssueInstant="2013-09-12T10:13:35.177Z"
version="2.0">
<saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">
https://idp.local.ru/idp/shibboleth
</saml2:Issuer>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
NameQualifier="https://idp.local.ru/idp/shibboleth">
_f1de09ee54294d4b5ddeb3aa5e6d2aab
</saml2:NameID>
<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml2:SubjectConfirmationData Address="127.0.0.1"
InResponseTo="_0ddb303f9500839762eabd30e7b1e3c28b596c69"
NotOnOrAfter="2013-09-12T10:18:35.177Z"
Recipient="https://sp.local.ru:8443/sso/acs"/>
</saml2:SubjectConfirmation>
</saml2:Subject>
<saml2:Conditions
NotBefore="2013-09-12T10:13:35.177Z"
NotOnOrAfter="2013-09-12T10:18:35.177Z">
<saml2:AudienceRestriction>
<saml2:Audience>sp.local.ru</saml2:Audience>
</saml2:AudienceRestriction>
</saml2:Conditions>
<saml2:AuthnStatement AuthnInstant="2013-09-12T10:13:35.137Z"
SessionIndex="_91826738984ca8bef18a8450135b1821">
<saml2:SubjectLocality Address="127.0.0.1"/>
<saml2:AuthnContext>
<saml2:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
</saml2:AuthnContextClassRef>
</saml2:AuthnContext>
</saml2:AuthnStatement>
<saml2:AttributeStatement>
<saml2:Attribute Name="userLogin" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">idpuser</saml2:AttributeValue>
</saml2:Attribute>
</saml2:AttributeStatement>
</saml2:Assertion>
</saml2p:Response>
Сообщение будет обработано в уже реализованном методе SAMLResponseVerifier.verify(..)
Вот собственно и все, наш фильтр реализован!
Структура нашего проекта выглядит так:
Собираем реализованный фильтр в jar библиотеку в локальный репозиторий.
Для этого выполняем команду в директории с pom.xml: mvn clean install
6. Создаем Java Web приложение с поддержкой SSO
Создаем Java Web приложение
Для наглядного примера мы создадим простое Java Web-приложение с приватными и публичными ресурсами. Доступ до приватных ресурсов требует аутентификацию пользователя через веб-приложение Shibboleth idP. Одним из приватных ресурсов сделаем страницу, которая выводит информацию по текущему пользователю системы.
Структура нашего приложения выглядит следующим образом:
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId> ru.eastbanctech.web</groupId>
<artifactId>SimpleSSOApplication</artifactId>
<packaging>war</packaging>
<version>1.0-SNAPSHOT</version>
<name>SimpleSSOApplication</name>
<url>http://maven.apache.org</url>
<!-- Задаем значения для нашего приложения -->
<properties>
<sp.id>sp.local.ru</sp.id>
<acs.url>https://sp.local.ru:8443/sso/acs</acs.url>
<idp.sso.url>https://idp.local.ru:8443/idp/profile/SAML2/Redirect/SSO</idp.sso.url>
<logout.url>/logout</logout.url>
</properties>
<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>2.5</version>
</dependency>
<dependency>
<groupId> ru.eastbanctech.web</groupId>
<artifactId>saml-sp-filter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency><sup>8</sup>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.1</version>
</dependency>
</dependencies>
<build>
<finalName>sso</finalName>
<plugins>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<configuration>
<webResources>
<resource>
<filtering>true</filtering>
<directory>src/main/webapp/WEB-INF</directory>
<targetPath>WEB-INF</targetPath>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
</webResources>
</configuration>
</plugin>
</plugins>
</build>
</project>
Тут нужно обратить на секцию properties, где задаются основные параметры нашего фильтра
<sp.id>sp.local.ru</sp.id> — название SAML 2.0 фильтра SP
<acs.url>https://sp.local.ru:8443/sso/acs</acs.url> — URL фильтра, по которому он
будет обрабатывать SAML 2.0 сообщения от shibboleth idP
<idp.sso.url>https://idp.local.ru:8443/idp/profile/SAML2/Redirect/SSO</idp.sso.url> — URL, по
которому наш фильтр будет отправлять сообщения shibboleth idP
<logout.url>/logout</logout.url> — logout URL
web.xml
В web.xml файле определяем параметры нашего фильтра и область его действия. Сделаем ресурсы в формате ".jpg" открытыми через параметр excludedUrlPattern.
<!DOCTYPE web-app PUBLIC
"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd" >
<web-app>
<display-name>Simple SSO Java Web Application</display-name>7.
<filter>
<filter-name>SSOFilter</filter-name>
<filter-class> ru.eastbanctech.java.web.filter.saml.SAMLSPFilter</filter-class>
<init-param>
<param-name>excludedUrlPattern</param-name>
<param-value>.*.jpg</param-value>
</init-param>
<init-param>
<param-name>idProviderSSOUrl</param-name>
<param-value> ${idp.sso.url}</param-value>
</init-param>
<init-param>
<param-name>spProviderId</param-name>
<param-value>${sp.id}</param-value>
</init-param>
<init-param>
<param-name>acsUrl</param-name>
<param-value>${acs.url}</param-value>
</init-param>
<init-param>
<param-name>logoutUrl</param-name>
<param-value>${logout.url}</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>SSOFilter</filter-name>
<url-pattern>/pages/private/*</url-pattern>
</filter-mapping>
<filter-mapping>
<filter-name>SSOFilter</filter-name>
<url-pattern>${logout.url}</url-pattern>
</filter-mapping>
<filter-mapping>
<filter-name>SSOFilter</filter-name>
<url-pattern>/acs</url-pattern>
</filter-mapping>
</web-app>
private/page.jsp
Страничка представляет собой просто вывод id и атрибутов аутентифицированного пользователя.
<%@ page import=" ru.eastbanctech.java.web.filter.saml.store.SAMLSessionManager" %>
<%@ page import=" ru.eastbanctech.java.web.filter.saml.store.SAMLSessionInfo" %>
<%@ page import="java.util.Map" %>
<html>
<body>
<h2>Private Resource</h2>
<%
SAMLSessionInfo info =
(SAMLSessionInfo)request.getSession().getAttribute(SAMLSessionManager.SAML_SESSION_INFO);
out.println("User id = " + info.getNameId() + "
");
out.println("<TABLE> <TR> <TH> Attribute name </TH> <TH> Attribulte value </TH></TR>");
for (Map.Entry entry : info.getAttributes().entrySet()) {
out.println("<TR><TD>" + entry.getKey() + "</TD><TD>" + entry.getValue() + "</TD></TR>");
}
out.println("</TABLE>");
%>
<a href="<%=request.getContextPath()%>/logout">Logout</a>
</body>
</html>
Собираем приложение командой: mvn clean package.
Проверяем работу Java Web-приложения
Деплоим приложение в Tomcat AS и проверяем работу SSO:
- Описываем контекст приложения в файле ${tomcatHome}/conf/Catalina/localhost/sso.xml
<Context docBase="$pathToWebApp" privileged="true" antiResourceLocking="false" antiJARLocking="false" unpackWAR="false" swallowOutput="true" />
или просто копируем наше приложение sso.war в ${tomcatHome}/webapps
- Для того, чтобы приложения томката могли устанавливать соединение с shibboleth idP по протоколу HTTPS нужно добавить сертификат shibboleth idP в java keystore.
Для этого нужно воспользоваться Java утилитой keytool:keytool -alias idp.local.ru -importcert -file ${shHome}/idp.crt -keystore ${keystorePath}
- Запускаем Tomcat AS
- Открываем браузер и стучимся в закрытый ресурс приложения sp.local.ru:8443/sso/pages/private/page.jsp
- Проверяем, что открылась страница и система вывела id и имя пользователя
- Как упражнение проверьте, что фильтр пропускает запросы к картинкам в формате .jpg в папке /pages/private.
Интеграция с Google Apps.
А теперь время проверить, что у нас действительно работает SSO.
Для этого будем использовать приложение из облачных служб Google Apps (http://www.google.com/enterprise/apps/business/).
- Зарегистрируйте себе доменное имя и супер-администратора, используя бесплатную пробную версию. После того как все завершено зайдите в систему admin.google.com/ под созданным пользователем (используя полное доменное имя).
- Пользуясь административной панелью создать пользователя idpuser, дать ему право Super Administrator.
- Выбрать внизу экрана «Добавить элементы управления» и в выпадающем списке нажать
на пункт «Безопасность».
- Далее выбрать Расширенные настройки -> Установить единый вход.
- Отметить пункт Разрешить единый вход и поставить параметры:
URL входной страницы * = https://idp.local.ru:8443/idp/profile/SAML2/Redirect/SSO
URL страницы выхода * = gmail.com
Изменить пароль URL * = gmail.comНажать на кнопку Сохранить изменения.
- Загрузить сертификат для работы с shibboleth idP по HTTPS
Сертификат находится в $shHome/credentials/idp.crt
Нажать на кнопку Сохранить изменения.
- Пользуясь инструкцией https://shibboleth.usc.edu/docs/google-apps/ настроить shibboleth idP на работу с Google Apps.
Замечание: указывайте имя схемы для добавляемых элементов, иначе получите ошибку при старте shibboleth idP. Например, вместо RelyingParty нужно указывать rp:RelyingParty.
- Для logger’а с именем edu.internet2.middleware.shibboleth устанавливаем уровень DEBUG
<!-- Logs IdP, but not OpenSAML, messages --> <logger name="edu.internet2.middleware.shibboleth" level="DEBUG"/>
Перезапускаем shibboleth idP и идем на страницу https://admin.google.com в новой сессии броузера (возможно потребуется удаление куков, в Google Chrome можно использовать режим Инкогнито).
Вводим idpuser@domain_name, где domain_name – имя вашего зарегистрированного домена и пароль. Нажимаем «Войти».
Принимаем не подписанные сертификаты и удостоверяемся, что вы вошли в google apps под пользователем idpuser.
В логе ${shHome}/logs/idp-process.log шибболета вы должны увидеть, как shibboleth idP обрабатывает ваш запрос. Там будет видно, что проходит процесс аутентификации через RemoteUserLoginHandler22:19:49.172 - DEBUG [edu.internet2.middleware.shibboleth.idp.authn.provider.RemoteUserLoginHandler:66] - Redirecting to <a href="https://idp.local.ru:8443/idp/Authn/RemoteUser">https://idp.local.ru:8443/idp/Authn/RemoteUser</a>
Вообще логи в shibboleth idP достаточно простые и в то же время информативные. Рекомендуем потратить немного времени, чтобы разобраться в них.
Далее открываем наше приложение по урлу sp.local.ru:8443/sso/pages/private/page.jsp
и смотрим в логах, что shibboleth idP находит имеющуюся сессию для пользователя idpuser.Ну вот и все. Наша простейшая система SSO функционирует. Надеемся, что вы нашли что-то полезное для себя.
Примечания
1 — Можно также использовать Service Provider от производителя. В случае с Shibboleth это приводит к усложнению инфраструктуры приложения, поскольку требуется ставить дополнительный Apache-сервер перед Application Server’ом.
2 — На момент написания данной статьи последняя версия Shibboleth idP 2.4.0
3 — Мы использовали Java 7 в своем окружении.
4 — Мы использовали CentOS 6.3 в качестве OS. Также проверялась на Ubuntu 12.04.
5 — Для компиляции потребуются библиотека servlet-api 2.5 и ${tomcatHome}/lib/catalina.jar
6 — ru.eastbanctech.java.web.RemoteUserValve – полный путь до класса RemoteUserValve. В вашем случае требуется исправить исходя из иерархии пакетов.
7 — Предлагаем реализовать самостоятельно в качестве упражнения.
8 — Измените параметры выделенные красным цветом в зависимости от вашего окружения.Полезные ссылки
- https://developers.google.com/google-apps/sso/saml_reference_implementation — SSO сервис для Google Apps. Объясняется каким образом можно интегрировать SSO в Google Docs средствами SAML.
- https://shibboleth.usc.edu/docs/google-apps/ — Инструкция по интеграции Shibboleth с Google docs.
- http://stackoverflow.com/questions/7553967/getting-a-value-from-httpservletrequest-getremoteuser-in-tomcat-without-modify — Как реализовать свой Tomcat Valve
- https://wiki.shibboleth.net/confluence/display/SHIB2/Home — документация по Shibboleth
Автор: eastbanctech