Spring Boot で LDAP 認証

Spring Boot で作った Web アプリケーションに LDAP 認証の機能をつける検証をしたのでメモしておく。

まず LDAP サーバーは CentOS 7 + OpenLDAP 2.4 で、ディレクトリ ツリーは以下のような構成。ユーザーは smbldap-tools で管理していて、グループ側のエントリーの memberUid 属性で所属グループを管理する。

  • dc=example,dc=org
    • ou=Users
      • uid=user1
      • uid=user2
      • ...
    • ou=Groups
      • cn=Domain Admins
      • cn=Domain Users
      • ...

さらにアクセス制限として、LDAP サーバーとの通信には暗号化が必須で 389/tcp への接続しか許可していないので、必然的に STARTTLS することになる。ou=Users サブツリーについては誰でも参照可能に設定してあるけど、これは後述する Spring Boot の動きに合わせたもの。

認証フロー

Spring Boot からのユーザー認証は Spring Security の機能が使われるという話は省略するとして、実際に LDAP を使って認証する方法は次の 2 通りある。

  1. 固定のアカウントでバインドしてユーザーを検索し、パスワード (のハッシュ) を比較する方法
  2. クライアントから提供された認証情報 (ID、パスワード) でバインドできるかどうか試す方法

前者の方法だとどうしてもどこかにパスワードを書かないといけないので、後者の方法を使うことにする。そうすると次のような動作になる。

  1. クライアントがフォームや Basic 認証で ID とパスワードを送信
  2. ID とパスワードで LDAP サーバーにバインド
  3. ユーザーの所属するグループを取得して権限を設定

3 については認証だけなら必要ないけど、権限によるアクセス制限を想定していれておく。それでこの時、認証とは違う接続が使われてクライアントからの ID とパスワードは使われない。別の認証情報も設定していないので匿名でバインドすることになる。これに対応するためにサーバーのアクセス制限を調整した。取得した権限は ROLE_DOMAIN USERS などのような authority として参照できる。

コード

Spring Initializr なり IDEプラグインなりで Spring Boot アプリケーションを作る。このとき、SecurityLDAP を含めておくこと。実際にはそれでも足りなくて、dependency を 1 つ追加する。Maven の場合は次のような感じ。

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-ldap</artifactId>
</dependency>

認証設定を定義するクラス。ここに載せているのは Bean 定義だけで、細かいアクセス制限は省略している。

package org.example.spring.ldap;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.ldap.core.ContextSource;
import org.springframework.ldap.core.support.AbstractContextSource;
import org.springframework.ldap.core.support.AbstractTlsDirContextAuthenticationStrategy;
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
import org.springframework.ldap.core.support.DefaultTlsDirContextAuthenticationStrategy;
import org.springframework.ldap.core.support.DirContextAuthenticationStrategy;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
import org.springframework.security.ldap.authentication.AbstractLdapAuthenticator;
import org.springframework.security.ldap.authentication.BindAuthenticator;
import org.springframework.security.ldap.authentication.LdapAuthenticationProvider;
import org.springframework.security.ldap.authentication.LdapAuthenticator;
import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator;
import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator;

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public DirContextAuthenticationStrategy authenticationStrategy() {
        // STARTTLS してくれる DirContextAuthenticationStrategy
        AbstractTlsDirContextAuthenticationStrategy authenticationStrategy = new DefaultTlsDirContextAuthenticationStrategy();
        // 毎回 TLS を切断する。これをしないと "TLS already started" というエラーになって 2 回目以降認証できない。
        authenticationStrategy.setShutdownTlsGracefully(true);

        return authenticationStrategy;
    }

    @Bean
    public BaseLdapPathContextSource contextSource(DirContextAuthenticationStrategy authenticationStrategy) {
        // LDAP サーバーの URL とルート DN
        AbstractContextSource contextSource = new DefaultSpringSecurityContextSource(
                "ldap://example.org/dc=example,dc=org");
        contextSource.setAuthenticationStrategy(authenticationStrategy);

        return contextSource;
    }

    @Bean
    public LdapAuthenticator authenticator(BaseLdapPathContextSource contextSource) {
        AbstractLdapAuthenticator authenticator = new BindAuthenticator(contextSource);
        // ユーザー DN のテンプレートをルート DN からの相対で指定
        authenticator.setUserDnPatterns(new String[] { "uid={0},ou=Users" });

        return authenticator;
    }

    @Bean
    public LdapAuthoritiesPopulator authoritiesPopulator(ContextSource contextSource) {
        // グループ検索で使うベース DN
        DefaultLdapAuthoritiesPopulator authoritiesPopulator = new DefaultLdapAuthoritiesPopulator(contextSource,
                "ou=Groups");
        // グループ検索で使うフィルター。{0} は DN に置き換わる
        authoritiesPopulator.setGroupSearchFilter("(memberUid={1})");

        return authoritiesPopulator;
    }

    @Bean
    public AuthenticationProvider authenticationProvider(LdapAuthenticator authenticator,
            LdapAuthoritiesPopulator authoritiesPopulator) {
        return new LdapAuthenticationProvider(authenticator, authoritiesPopulator);
    }
}