Spring SecurityはSpring Bootアプリケーションの認証と認可を設定可能なフレームワークです。

標準のSpring SecurityにはOAuth2がサポートされていますが、SAMLは標準ではサポートされていません。 しかし、公式がSpring SecurityのSAML Extensionを提供しており比較的容易にSAMLをサポートできます。

今回はSpring SecurityでSAML対応する方法を紹介します。

この記事はFOLIO Advent Calendar 2019の12月23日の記事でもあります。

2020-07-02 追記

Spring Security SAMLの1系のサポートは続けられますが、この記事で紹介している2系は開発が行われません。

現在は、Spring Security本体でSAMLの機能開発が進められています。

これからSpring Securityを用いてSAMLの実装を行う場合は、Spring Security SAMLではなく、Spring SecurityのSAML実装を使用することを推奨します。

SAMLとは

SAMLはSecurity Assertion Markup Languageの略です。セキュアなWebドメイン間でユーザーの認証および承認データを交換するためのXMLベースの標準規格1のことです。 SAMLを使用することで、セキュアなコンテンツにアクセスしようとしているユーザーを認証できます。

SAMLには1.1と2.0の2つのバージョンがありますが、今から導入する場合は2.0をサポートしていればよいです。

Spring Security SAML

執筆時点では、1.0.10がGAとしてリリースされており、2.0.0が非公開なマイルストーンで開発されています。 しかし本家のREADME(2.0.0.M31時点)によると、

This repository is not being actively maintained, but will remain hosted for educational and reference purposes. It contains an independent, easier to use and abstracted through Java POJOs, SAML library on top of the OpenSAML library. The concept was also validated against Keycloak as the underlying dependency.

The effort continues at the code Spring Security project. It’s goals are to provide a framework abstraction, as opposed to a library, for SAML 2 Authentication.

We continue to accept pull request for all branches, but will not actively drive feature development in this repository.

とあるように、活発な開発が行われていません。 しかし、Spring SecurityのSAML対応のヒントになります。

Spring Security SAMLの導入にあたって

Spring Security SAMLは前述の通り活発な開発されておらず、1系や2系どちらを使用してもいくつかの不具合が存在します。 かと言って、ゼロから作り直すのはたいへんです。

不具合がある状態かつ一定の機能のみを限定的に使用することを許容するのであれば、spring-security-saml-dslを使用することで実現が可能です。

しかし、これは健全ではないため、ここでは執筆時点で最新版の2.0.0.M31を使用してSpring BootアプリケーションにSAMLを導入する方法を紹介します。

Spring Security SAMLを導入

公式でサンプルと合わせてドキュメント化されており、Spring Security SAML Extensionを参考にしつつ導入します。しかし、不具合などが多く、あくまで参考程度に土留ておいたほうがよいです。

導入にあたっては以下の環境で検証しました。

$ gradle  --version

------------------------------------------------------------
Gradle 4.5.1
------------------------------------------------------------

Build time:   2018-02-05 13:22:49 UTC
Revision:     37007e1c012001ff09973e0bd095139239ecd3b3

Groovy:       2.4.12
Ant:          Apache Ant(TM) version 1.9.9 compiled on February 2 2017
JVM:          1.8.0_212 (Amazon.com Inc. 25.212-b04)
OS:           Mac OS X 10.14.6 x86_64

依存の追加

依存にspring-security-saml2-coreを追加します。

plugins {
  id 'org.springframework.boot' version '1.5.22.RELEASE'
}

dependencies {
  compile('org.springframework.boot:spring-boot-starter-security')
  compile('org.springframework.boot:spring-boot-starter-web')
  compile('org.springframework.boot:spring-boot-configuration-processor'
  compile('org.springframework.session:spring-session')
  // Spring Security SAMLの2.0.0.M31を依存に追加
  compile('org.springframework.security.extensions:spring-security-saml2-core:2.0.0.M31')
}

Spring Securityの設定追加

Spring Securityの便利なところは、フレームワークが要求する値を設定するだけで動作することです。 Spring Security SAMLでは以下のように設定することで動作します。

spring:
  security:
    saml2:
      network:
        read-timeout: 10000
        connect-timeout: 5000
      service-provider:
        entity-id: sampleEntityId
        base-path: http://localhost:8080
        sign-metadata: false
        sign-requests: false
        want-assertions-signed: false
        single-logout-enabled: false
        keys:
          active:
            name: spring
            passphrase: secret
            certificate: |-
              -----BEGIN CERTIFICATE-----
              MIIDdzCCAl+gAwIBAgIEF6unJTANBgkqhkiG9w0BAQsFADBsMRAwDgYDVQQGEwdV
              bmtub3duMRAwDgYDVQQIEwdVbmtub3duMRAwDgYDVQQHEwdVbmtub3duMRAwDgYD
              VQQKEwdVbmtub3duMRAwDgYDVQQLEwdVbmtub3duMRAwDgYDVQQDEwdVbmtub3du
              MB4XDTE4MDQyNjA4MTE1NloXDTQ1MDkxMTA4MTE1NlowbDEQMA4GA1UEBhMHVW5r
              bm93bjEQMA4GA1UECBMHVW5rbm93bjEQMA4GA1UEBxMHVW5rbm93bjEQMA4GA1UE
              ChMHVW5rbm93bjEQMA4GA1UECxMHVW5rbm93bjEQMA4GA1UEAxMHVW5rbm93bjCC
              ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALpDwL3nsRf7zisRb5gzw4Ia
              i6tOAR3aMJDx/c4pVCkk3mXr3nHi88BSNWa6mhK7uwPcuBJCWJlBuPjB+zjCGxq7
              GzQae7oxXRTsPL2SscFX2l9Sk9je8vFoo8EsFZMj0isw/lj2W9p4zbtkWUy8xU1I
              NnlfECNSicB6UeqcsRwhHPQtmocmddGcfd7D9SpP4+YrdEFK18v24GurLnem2vKl
              zIJGZV1SYPvWjcwDpOuR6Yc7Q+UA9jWh/A/Qb7sDG19uM6ndll2u7+9zzlUepiXB
              +f30NhjjXlPtTOGYiegoIfFmAJawj25p7h/fXwYz+gVfOExQF5X13EVaI4eaSWkC
              AwEAAaMhMB8wHQYDVR0OBBYEFPH6nXVDVT1HS3xc1/iBiAgl6lvZMA0GCSqGSIb3
              DQEBCwUAA4IBAQBijy0KA3+pM+8hUklVMRRX2ZuZ3y8KrY7TeHeWQd88fyM0AjTP
              GyND6r5JsCBZJqiC6HgycEup6TL5L9NfpNuNOQi19ouAjvrLWDygpJW9zqrVyWqz
              Pnrl5H+6NSvd1pjWLGUwqisAKBPlIFWmWN2Z3ouDqc1rwgF4KZrjLW3v/+yILdjb
              lVR0r8o70ynOiUB2VN/7WX2a6MuBXv3JiPiyhqFdWFhcRWphZjo4Yh8dApj79d15
              MXI5uAm5K7ZHZsWFNvjnwxuhWwDURldvqL1VuChxD4hgfXO4t+oDQMQDa5tn6Ov5
              68aQRwURcoufrRJ6R0drTEQueJszi6FVx5Ld
              -----END CERTIFICATE-----
        providers:
          - alias: simpleSAMLphp
            metadata: http://localhost:4000/simplesaml/saml2/idp/metadata.php
            authentication-request-binding: urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST
            skip-ssl-validation: true
            assertion-consumer-service-index: 0

特に注意するべき項目は、

  1. spring.security.saml2.service-provider.entity-id
  2. spring.security.saml2.service-provider.keys
  3. ここには記載はないですが、spring.security.saml2.service-provider.name-ids

の3点です。

spring.security.saml2.service-provider.entity-idの注意点

spring.security.saml2.service-provider.entity-idは、Service ProviderをIdentity Provider側に一意に特定するためのエンティティIDです。Spring Security SAMLは実装上、Assertion Consumer Service2のURLが[hostname]/[SamlPrefix]/SSO/alias/[EntityID]となる点に注意が必要ですでSamlPrefixはデフォルトでsaml/spですが、Spring Securityの設定箇所で変更ができます。

spring.security.saml2.service-provider.keysの注意点

spring.security.saml2.service-provider.keysはService Providerのmetadataを署名する際に使用する鍵の設定です。 Microsoft Azure Active Directoryなどの多くのIdentity Providerはこの署名を確認しないため、必須ではありません。 しかし、Spring Security SAMLは必須として要求してくるため、ダミーの証明書を使用して署名する対応をする必要がある点に注意が必要です。

spring.security.saml2.service-provider.name-idsの注意点

spring.security.saml2.service-provider.name-idsは必須ではありません。 また、ymlで設定する分には特に問題ありません。 詳しく検証できていませんが、propertiesの形式で記載するとSpring Security SAMLが提供するNameId型に変換されない不具合があります。

設定値の読み込み

前述した設定値を読み込むためにSamlServerConfigurationを継承したクラスを作成します。@ConfigurationPropertiesアノテーションでspring.security.saml2を指定します。

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.saml.provider.SamlServerConfiguration;

@ConfigurationProperties(prefix = "spring.security.saml2")
@Configuration
public class SamlSecurityConfiguration extends SamlServerConfiguration {}

Spring Security SAMLが設定値を使用できるようにする

前述のSamlServerConfigurationを継承したクラス、つまり設定値をSpring Security SAMLが使用できるようにするためにSamlServiceProviderServerBeanConfigurationを継承したクラスのgetDefaultHostSamlServerConfigurationメソッドをオーバーライドしてSamlServerConfigurationのインスタンスを使用するように設定します。

import org.springframework.context.annotation.Configuration;
import org.springframework.security.saml.provider.SamlServerConfiguration;
import org.springframework.security.saml.provider.service.config.SamlServiceProviderServerBeanConfiguration;

@Configuration
public class SamlServerBeanConfiguration extends SamlServiceProviderServerBeanConfiguration {
  private final SamlSecurityConfiguration config;

  public SamlServerBeanConfiguration(SamlSecurityConfiguration config) {
    this.config = config;
  }

  @Override
  protected SamlServerConfiguration getDefaultHostSamlServerConfiguration() {
    return this.config;
  }
}

しかし、これだと設定したIdentity Providerを呼び出せない不具合が発生してしまします。

そのため、以下のようなワークアラウンドで回避します。

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.saml.provider.SamlServerConfiguration;
import org.springframework.security.saml.provider.service.config.ExternalIdentityProviderConfiguration;
import org.springframework.security.saml.provider.service.config.SamlServiceProviderServerBeanConfiguration;

import java.net.URI;
import java.util.ArrayList;
import java.util.List;

@Configuration
public class SamlServerBeanConfiguration extends SamlServiceProviderServerBeanConfiguration {
  @Value("${spring.security.saml2.service-provider.providers[0].metadata}")
  private String metadata;

  @Value("${spring.security.saml2.service-provider.providers[0].alias}")
  private String alias;

  @Value("${spring.security.saml2.service-provider.providers[0].authentication-request-binding}")
  private String authenticationRequestBinding;

  @Value("${spring.security.saml2.service-provider.providers[0].skip-ssl-validation}")
  private boolean skipSslValidation;

  @Value("${spring.security.saml2.service-provider.providers[0].assertion-consumer-service-index}")
  private int assertionConsumerServiceIndex;

  private final SamlSecurityConfiguration config;

  public SamlServerBeanConfiguration(SamlSecurityConfiguration config) {
    this.config = config;
  }

  @Override
  protected SamlServerConfiguration getDefaultHostSamlServerConfiguration() {
    List<ExternalIdentityProviderConfiguration> providers = new ArrayList<>();
    providers.add(externalProvider());
    // providers の properties を公式の指定通りに定義すると ClassCastException が起こるので独自に定義し直している
    config.getServiceProvider().setProviders(providers);
    return config;
  }

  private ExternalIdentityProviderConfiguration externalProvider() {
    final ExternalIdentityProviderConfiguration externalIdentityProviderConfiguration =
        new ExternalIdentityProviderConfiguration();
    return externalIdentityProviderConfiguration
        .setMetadata(metadata)
        .setAlias(alias)
        .setSkipSslValidation(skipSslValidation)
        .setAuthenticationRequestBinding(URI.create(authenticationRequestBinding))
        .setAssertionConsumerServiceIndex(assertionConsumerServiceIndex);
  }
}

これにより、型のキャストができます。 今回は1つのIdentity Providerのみを設定しているため、spring.security.saml2.service-provider.providers[0]のような形で設定値を取得しましたが、複数ある場合はリストでExternalIdentityProviderConfigurationを生成すると良いでしょう。

Spring SecurityでSpring Security SAMLを使えるようにする

Spring Security SAMLをSpring Securityで使用できるようにします。

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.saml.provider.service.config.SamlServiceProviderSecurityConfiguration;

@EnableWebSecurity
public class SecurityConfiguration {

  @Configuration
  @Order(1)
  public static class SamlSecurity extends SamlServiceProviderSecurityConfiguration {
    private SamlSecurityConfiguration samlSecurityConfiguration;

    public SamlSecurity(
        SamlServerBeanConfiguration samlServerBeanConfiguration,
        @Qualifier("samlSecurityConfiguration")
            SamlSecurityConfiguration samlSecurityConfiguration) {
      super(samlServerBeanConfiguration);
      this.samlSecurityConfiguration = samlSecurityConfiguration;
    }
  }

  @Configuration
  public static class AppSecurity extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
      http.authorizeRequests()
        .antMatchers("/login")
        .permitAll()
        .anyRequest()
        .authenticated()
        .and()
        .logout()
        .deleteCookies("xxxxx")
        .invalidateHttpSession(true)
        .logoutUrl("/logout")
        .logoutSuccessUrl("/login")
        .and()
        .formLogin()
        .loginPage("/login");
    }
  }
}

これでかゆいところに手が届かない、および不具合がありつつもSpring Bootアプリケーションが起動し、SAMLでログインが可能になりました。

認証情報はSpring BootのセッションにDefaultSamlAuthenticationインスタンスが格納されているので、org.springframework.security.core.Authenticationをキャストすることで取得できます。

各種不具合や要望に対応

ここでは公式ドキュメントどおりに設定しても対応が難しい不具合や要望の対応する方法を紹介します。

すべてのページで認証が無効化される

SAMLでSSOするからには何かしら認証が通らないとアクセスさせないようなページがあると思われます。 しかし、SamlServiceProviderSecurityConfigurationの不具合により、すべてのページで認証が必要なくない状態になってしまいます。 そのため、不具合が解消されるまでは以下のように対応する必要があります。

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.saml.provider.service.config.SamlServiceProviderSecurityConfiguration;

@EnableWebSecurity
public class SecurityConfiguration {

  @Configuration
  @Order(1)
  public static class SamlSecurity extends SamlServiceProviderSecurityConfiguration {
    // 今回追加したもの
    @Override
    protected void configure(final HttpSecurity http) throws Exception {
      String prefix = getPrefix();

      String filterChainPattern = "/" + stripSlashes(prefix) + "/**";

      http.antMatcher(filterChainPattern)
          .csrf()
          .disable()
          .authorizeRequests()
          .antMatchers(filterChainPattern)
          .permitAll();

      // スーパークラスではspSelectIdentityProviderFilterも追加しているがしているがその画面は複数Identity Providerがないと意味がないので外している
      http
        .addFilterAfter(
          getConfiguration().samlConfigurationFilter(),
          BasicAuthenticationFilter.class
        )
        .addFilterAfter(
          getConfiguration().spMetadataFilter(),
          getConfiguration().samlConfigurationFilter().getClass()
        )
        .addFilterAfter(
          getConfiguration().spAuthenticationRequestFilter(),
          getConfiguration().spMetadataFilter().getClass()
        )
        .addFilterAfter(
          getConfiguration().spAuthenticationResponseFilter(),
          getConfiguration().spAuthenticationRequestFilter().getClass()
        )
        .addFilterAfter(
          getConfiguration().spSamlLogoutFilter(),
          getConfiguration().spAuthenticationResponseFilter().getClass()
        );
    }
  }
}

Identity Providerのセッションの発行日のチェックを無視

Spring Security SAMLはDefaultValidatorでIdentity Providerのセッションの発行日を厳格にチェックしています。 しかし、Identity Providerから正しいリクエストがきているので、セッションの発行日を信頼するかはService Provider次第です。そのため、このチェックをなくしたいという要望があります。

一番簡単な方法はDefaultValidatorを継承し、セッションの発行日の確認をしているメソッドを上書きしてしまうことです。 ゼロからSamlValidatorを継承したクラスを作成してもよいですが、SAMLの各種レスポンスをチェックするのは面倒であるため、この手法を選択しました。

public class CustomSamlValidator extends DefaultValidator {
  public AzureAdSamlValidator(SpringSecuritySaml implementation) {
    super(implementation);
  }

  @Override
  protected boolean isDateTimeSkewValid(int skewSeconds, int forwardSeconds, DateTime time) {
    return true;
  }
}

独自に拡張したSamlValidatorをSpring Security SAMLに設定する必要があるので、SamlServiceProviderServerBeanConfigurationを継承したクラスでsamlValidatorメソッドをオーバーライドして設定します。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.saml.SamlValidator;
import org.springframework.security.saml.provider.SamlServerConfiguration;
import org.springframework.security.saml.provider.service.config.SamlServiceProviderServerBeanConfiguration;

@Configuration
public class SamlServerBeanConfiguration extends SamlServiceProviderServerBeanConfiguration {
  // 今回追加したもの
  @Autowired private SpringSecuritySaml samlImplementation;

  // 今回追加したもの
  @Override
  @Bean
  public SamlValidator samlValidator() {
    return new CustomSamlValidator(samlImplementation);
  }
}

Spring Security SAML独自のエラー画面を無効化

SAMLの処理中に何かしらの例外が発生した際に、Spring Security SAMLは独自のエラー画面を表示してしまいます。 しかし、例外時のエラー画面はアプリケーションによって調整したいものです。

以下は、SpringのAuthenticationFailureHandlerを利用してセッションに例外をセットしてして指定されたURLにリダイレクトするハンドラです。

import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.WebAttributes;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

public class SamlErrorAuthenticationFailureHandler implements AuthenticationFailureHandler {
  private static final Logger logger =
      LoggerFactory.getLogger(SamlErrorAuthenticationFailureHandler.class.getName());
  private String redirectUrl;
  private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

  public SamlErrorAuthenticationFailureHandler(String redirectUrl) {
    this.redirectUrl = redirectUrl;
  }

  @Override
  public void onAuthenticationFailure(
      HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
      throws IOException, ServletException {
    saveException(request, exception);
    getRedirectStrategy().sendRedirect(request, response, getRedirectUrl());
  }

  protected void saveException(HttpServletRequest request, AuthenticationException exception) {
    logger.warn(exception.getMessage(), exception);
    request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, exception);
  }

  public void setRedirectStrategy(RedirectStrategy redirectStrategy) {
    this.redirectStrategy = redirectStrategy;
  }

  protected RedirectStrategy getRedirectStrategy() {
    return redirectStrategy;
  }

  protected String getRedirectUrl() {
    return redirectUrl;
  }
}

Spring Security SAMLに設定する必要があるので、SamlServiceProviderServerBeanConfigurationを継承したクラスでspAuthenticationResponseFilterメソッドをオーバーライドします。

import javax.servlet.Filter;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.saml.provider.service.authentication.SamlAuthenticationResponseFilter;
import org.springframework.security.saml.provider.service.config.SamlServiceProviderServerBeanConfiguration;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;

@Configuration
public class SamlServerBeanConfiguration extends SamlServiceProviderServerBeanConfiguration {

  // 今回追加したもの
  @Override
  public Filter spAuthenticationResponseFilter() {
    SamlAuthenticationResponseFilter authenticationFilter = (SamlAuthenticationResponseFilter) super.spAuthenticationResponseFilter()
    authenticationFilter.setAuthenticationFailureHandler(
        new SamlErrorAuthenticationFailureHandler("/login"));

    return authenticationFilter;
  }
}

Identity Providerのレスポンス属性を使用したい

Identity Providerの多くはレスポンスにメールアドレスなどの情報を付加します。 その中でもAttributeStatementにはカスタム値が格納されており、使用したいケースがあります。 しかし、SimpleAuthenticationManagerではカスタム値はパースされずDefaultSamlAuthenticationインスタンスとしてピュアなstringとして保持されているだけです。 また、このXMLを使用するたびに毎回パースするのはコストが高いため、一度だけパースされて認証情報として保持されていてほしいです。

まずは、なにをするにしてもXMLをパースしなければいけません。SAMLレスポンスのXMLは名前空間がついている可能性があるのでそれを考慮してパースする必要があります。

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import javax.xml.namespace.NamespaceContext;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

public final class SamlResponseXmlParser {
  private static XPathFactory xPathFactory;

  static {
    try {
      xPathFactory =
          XPathFactory.newInstance(
              XPathFactory.DEFAULT_OBJECT_MODEL_URI,
              "com.sun.org.apache.xpath.internal.jaxp.XPathFactoryImpl",
              java.lang.ClassLoader.getSystemClassLoader());
    } catch (XPathFactoryConfigurationException e) {
      throw new RuntimeException("Generated XPathFactory instance error.", e)
    }
  }

  private String responseXml;
  private Document document;
  // See also:
  // http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0-cd-02.html#4.4.3.Attribute%20Statement%20Structure|outline
  private final String RESPONSE_ATTRIBUTE_XPATH =
      "/samlp:Response/saml:Assertion/saml:AttributeStatement/saml:Attribute";

  public SamlResponseXmlParser(String responseXml) {
    this.responseXml = responseXml;
  }

  public List<SamlResponseAttribute> getAttributes() {
    try {
      XPath xPath = xPathFactory.newXPath();
      NamespaceContext namespaceContext = new SamlNamespaceResolver();
      xPath.setNamespaceContext(namespaceContext);
      XPathExpression attributesXPath = xPath.compile(RESPONSE_ATTRIBUTE_XPATH);
      NodeList nl = (NodeList) attributesXPath.evaluate(this.getDocument(), XPathConstants.NODESET);
      List<SamlResponseAttribute> attributes = new ArrayList<SamlResponseAttribute>();
      for (Node attributeNode : XmlUtil.asList(nl)) {
        List<String> attributeValues = new ArrayList<String>();
        if (attributeNode.hasChildNodes()) {
          for (Node attributeValueNode : XmlUtil.asList(attributeNode.getChildNodes())) {
            attributeValues.add(attributeValueNode.getTextContent());
          }
        }
        attributes.add(
            new SamlResponseAttribute(
                attributeNode.getAttributes().getNamedItem("Name").getNodeValue(),
                attributeValues));
      }
      return attributes;
    } catch (XPathExpressionException e) {
      throw new RuntimeException(
          "Set invalid xpath expression. Please check your xpath expression", e);
    }
  }

  private Document getDocument() {
    if (this.document != null) {
      return this.document;
    }

    DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
    documentBuilderFactory.setNamespaceAware(true);
    try {
      DocumentBuilder builder = documentBuilderFactory.newDocumentBuilder();
      this.document =
          builder.parse(
              new ByteArrayInputStream(this.responseXml.getBytes(StandardCharsets.UTF_8)));
      return this.document;
    } catch (ParserConfigurationException | SAXException | IOException e) {
      throw new RuntimeException(e);
    }
  }

  private static class SamlNamespaceResolver implements NamespaceContext {

    @Override
    public String getNamespaceURI(String prefix) {
      String result = null;
      if (prefix.equals("samlp") || prefix.equals("samlp2")) {
        result = Constants.NS_SAMLP;
      } else if (prefix.equals("saml") || prefix.equals("saml2")) {
        result = Constants.NS_SAML;
      } else if (prefix.equals("ds")) {
        result = Constants.NS_DS;
      } else if (prefix.equals("xenc")) {
        result = Constants.NS_XENC;
      } else if (prefix.equals("md")) {
        result = Constants.NS_MD;
      }
      return result;
    }

    @Override
    public String getPrefix(String namespaceURI) {
      return null;
    }

    @SuppressWarnings("rawtypes")
    @Override
    public Iterator getPrefixes(String namespaceURI) {
      return null;
    }
  }

  private static class XmlUtil {
    private XmlUtil() {}

    public static List<Node> asList(NodeList n) {
      return n.getLength() == 0 ? Collections.<Node>emptyList() : new NodeListWrapper(n);
    }

    static final class NodeListWrapper extends AbstractList<Node> implements RandomAccess {
      private final NodeList list;

      NodeListWrapper(NodeList l) {
        list = l;
      }

      public Node get(int index) {
        return list.item(index);
      }

      public int size() {
        return list.getLength();
      }
    }
  }
}

XMLがパースできたので、パースしたものを取得できるようにSamlAuthenticationインタフェースが実装されているクラスを作成します。 今回は面倒だったのでDefaultSamlAuthenticationを継承して作成します。

import java.util.List;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.saml.saml2.authentication.Assertion;
import org.springframework.security.saml.spi.DefaultSamlAuthentication;

public class CustomAuthentication extends DefaultSamlAuthentication {
  private List<SamlResponseAttribute> attributes;
  private SamlResponseXmlParser samlResponseXmlParser;

  public CustomAuthentication(
      boolean authenticated,
      Assertion assertion,
      String assertingEntityId,
      String holdingEntityId,
      String relayState,
      String responseXml) {
    super(authenticated, assertion, assertingEntityId, holdingEntityId, relayState);
    super.setResponseXml(responseXml);
  }

  public List<SamlResponseAttribute> getAttributes() {
    if (attributes == null) {
      this.attributes = samlResponseXmlParser().getAttributes();
    }

    return this.attributes;
  }

  private SamlResponseXmlParser samlResponseXmlParser() {
    if (this.samlResponseXmlParser == null) {
      this.samlResponseXmlParser = new SamlResponseXmlParser(getResponseXml());
    }
    return this.samlResponseXmlParser;
  }
}

この認証情報を保持するインスタンスをSpring Securityで使用できるようにするにはSpringのAuthenticationManagerインタフェースの実装に組み込まれている必要があります。

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.saml.spi.DefaultSamlAuthentication;

public class CustomAuthenticationManager implements AuthenticationManager {
  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    DefaultSamlAuthentication defaultSamlAuthentication =
        (DefaultSamlAuthentication) authentication;
    CustomAuthentication customAuthentication =
        new CustomAuthentication(
            defaultSamlAuthentication.isAuthenticated(),
            defaultSamlAuthentication.getAssertion(),
            defaultSamlAuthentication.getAssertingEntityId(),
            defaultSamlAuthentication.getHoldingEntityId(),
            defaultSamlAuthentication.getRelayState(),
            defaultSamlAuthentication.getResponseXml());
    if (customAuthentication.isAuthenticated()) {
      SecurityContextHolder.getContext().setAuthentication(authentication);
    }
    return customAuthentication;
  }
}

このAuthenticationManagerをSpring Security SAMLに設定する必要があるので、SamlServiceProviderServerBeanConfigurationを継承したクラスでspAuthenticationResponseFilterメソッドをオーバーライドします

import javax.servlet.Filter;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.saml.provider.service.authentication.SamlAuthenticationResponseFilter;
import org.springframework.security.saml.provider.service.config.SamlServiceProviderServerBeanConfiguration;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;

@Configuration
public class SamlServerBeanConfiguration extends SamlServiceProviderServerBeanConfiguration {

  // 今回追加したもの
  @Override
  public Filter spAuthenticationResponseFilter() {
    SamlAuthenticationResponseFilter authenticationFilter = (SamlAuthenticationResponseFilter) super.spAuthenticationResponseFilter()
    authenticationFilter.setAuthenticationManager(new CustomAuthenticationManager());

    return authenticationFilter;
  }
}

これにより、SecurityContextHolderのコンテキストを介して以下のようにして認証情報を取得できます。

import org.springframework.security.core.context.SecurityContextHolder;

public AuthenticationUtils {
  public static CustomAuthentication getAuthentication() {
    CustomAuthentication customAuthentication =
          (CustomAuthentication) SecurityContextHolder.getContext().getAuthentication();
    return customAuthentication;
  }
}

終わりに

Spring BootアプリケーションでSAMLを使用したSSOの対応方法を紹介しました。 いくつかの不具合のために独自のパッチを当てて対応する必要があり、容易に導入というわけにはいきませんでした。

この量のカスタマイズが必要なのであれば、本家のサポートの状態と合わせて鑑みると別途ライブラリ化するなどの対応が必要であると感じました。 事業の状況によってそのような時間が取れない場合は、同じように独自にカスタマイズすることが無難な場合もあると思いますので、その際には参考になれば幸いです。

  1. RFC7522で標準化 

  2. Assertion Consumer Serviceは、Identity ProviderのAssertionを渡す先(Service ProviderのURL)