Spring SecurityをSPA(REST)+LDAPで利用する

#環境
Spring Boot 2.2.4
Java11
Gradle 6.0.1

#概要
Spring SecurityをSPA(REST)+LDAPで認証・認可する方法についてまとめてみた。

#SPA(REST)でSpring Securityを使うとき
- 最初、Login, LogoutのContollerを作る必要があるかと思っていたが、Spring Securityで提供される。
- デフォルトではSpring標準のForm画面のHTMLが表示されてしまう、認証成功・失敗、アクセス拒否などをカスタマイズが必要。
- 認可情報はLDAPのGROUP情報ではなく、独自で付与できるようにする。
- 途中、StatelessなOAuth 2.0、JWT(JSON Web Tokens、読み方はjot)も考えたが、以下の理由でCookie+Sessionにした。どこかで勉強はしたい。
    - サーバ側でSession情報が管理されないため、現在、誰がログインしているかわからなくなる。
    - 一度、Tokenを発行すると有効期限が切れるまで、ずっとログインできてしまう。有効期限を短くして、定期的なRefreshTokenの取得が必要となる。
    - Statelessにしたいなら、サーバ側のSessionをRedis(ElastiCache)で管理すれば十分。

#embedded LDAP
- LDAPはローカル環境で起動できる。OpenLDAPとか不要。参考URL : https://spring.io/guides/gs/authenticating-ldap/
- LDAPの動作確認、設定ファイル(ldifファイル)の編集は、LDAPAdminが便利。Exportもできる。参考URL : http://www.ldapadmin.org/
- LDAPAdminではbcryptでパスワード生成できないので、JavaScriptで生成するサイトから貼り付け。

```gradle:build.gradle
plugins {
	id 'org.springframework.boot' version '2.2.4.RELEASE'
	id 'io.spring.dependency-management' version '1.0.9.RELEASE'
	id 'java'
}

group = 'com.sample.authldap'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	// WebFlux版
	//implementation 'org.springframework.boot:spring-boot-starter-webflux'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.security:spring-security-ldap'
	implementation 'com.unboundid:unboundid-ldapsdk' // UnboundId, an open source LDAP server.
}

test {
	useJUnitPlatform()
}
```

```java:LdapserverApplication.java
package com.sample.authldap;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LdapserverApplication {

	public static void main(String[] args) {
		SpringApplication.run(LdapserverApplication.class, args);
	}

}
```

```yaml:application.yml
spring:
  ldap:
    embedded:
      base-dn: dc=sample,dc=com
      ldif: classpath:test-server.ldif
      port: 8389
```

```text:test-server.ldif
dn: dc=sample,dc=com
objectClass: top
objectClass: domain
objectClass: extensibleObject
dc: ulsystems

dn: ou=groups,dc=sample,dc=com
objectClass: top
objectClass: organizationalUnit
ou: groups

dn: cn=admin,ou=groups,dc=sample,dc=com
cn: admin
objectClass: groupOfUniqueNames
objectClass: top
uniquemember: uid=admin,ou=people,dc=sample,dc=com

dn: cn=user,ou=groups,dc=sample,dc=com
cn: user
objectClass: groupOfUniqueNames
objectClass: top
uniquemember: uid=admin,ou=people,dc=sample,dc=com
uniquemember: uid=user,ou=people,dc=sample,dc=com

dn: ou=people,dc=sample,dc=com
objectClass: top
objectClass: organizationalUnit
ou: people

dn: uid=admin,ou=people,dc=sample,dc=com
cn: admin
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
sn: admin
uid: admin
userpassword: $2a$08$1SP8XxyHil.tEHTw0NmT3.VVhQfP6oigy0B2DMMmsN4Hdpm9EfBVa

dn: uid=user,ou=people,dc=sample,dc=com
cn: user
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
sn: user
uid: user
userpassword: $2a$08$1SP8XxyHil.tEHTw0NmT3.VVhQfP6oigy0B2DMMmsN4Hdpm9EfBVa
```

#実装
##Spring Boot版
lamdaを使ってHandlerなどをカスタマイズすると、少ないコードでシンプルに記述することができます。

```java:config/WebSecurityConfig.java
package com.sample.authldap.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // CSRFを無効にする
        http.csrf().disable()
                // From認証 ※Basic認証の場合は、httpBasic
                .formLogin()
                    // ログインURL、指定しないとSpring標準のLogin画面(/login)が表示される。
                    .loginPage("/login")
                    // 認証成功時の処理
                    .successHandler((request, response, authentication) -> {
                        // デフォルトは/にリダイレクトするため、権限情報をカンマ区切りで出力するように変更
                        // → Listをカンマ区切りに変換するときは、Java8から導入されたStringJoinerとラムダ式が便利
                        response.setContentType(MediaType.TEXT_PLAIN_VALUE);
                        response.getWriter().write(authentication.getAuthorities().stream()
                                .map(s -> s.getAuthority()).collect(Collectors.joining(",")));
                        response.getWriter().flush();
                    })
                    // 認証失敗時の処理
                    .failureHandler((request, response, exception) -> {
                        // デフォルトはエラーページがなく、404となるため、401エラーとして、認証失敗時のエラーメッセージのみ出力する。
                        response.setContentType(MediaType.TEXT_PLAIN_VALUE);
                        response.setStatus(HttpStatus.UNAUTHORIZED.value());
                        response.getWriter().write(exception.getMessage());
                        response.getWriter().flush();
                    })
                .and().logout()
                    // ログアウトURL、デフォルトは/logout
                    .logoutUrl("/logout")
                // アクセス拒否された場合
                .and().exceptionHandling()
                    .authenticationEntryPoint((request, response, authException) -> {
                        // デフォルトではloginPageにリダイレクトするため、401にして、エラーメッセージを出力するように変更
                        // エラーメッセージが日本語のため、UTF-8を設定
                        response.setCharacterEncoding("UTF-8");
                        response.setContentType(MediaType.TEXT_PLAIN_VALUE);
                        response.setStatus(HttpStatus.UNAUTHORIZED.value());
                        response.getWriter().write(authException.getMessage());
                    })
                // 認可設定
                .and().authorizeRequests()
                    // すべてのURLを認証あり
                    .anyRequest().authenticated();
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.ldapAuthentication()
                // LDAP検索条件
                .userDnPatterns("uid={0},ou=people")
                .contextSource()
                    // LDAP接続先情報
                    .url("ldap://localhost:8389/dc=sample,dc=com")
                // 権限情報を独自で付与
                .and().ldapAuthoritiesPopulator((userData, username) -> {
                    // LDAPのアクセス権限ではなく、独自のアクセス権限を付与できる。LDAPの付加情報もここで取得可能。
                    List<GrantedAuthority> authorities = new ArrayList<>();
                    if (username.equals("admin")) {
                        authorities.add(new SimpleGrantedAuthority("ADMIN"));
                    } else {
                        authorities.add(new SimpleGrantedAuthority("USER"));
                    }
                    return authorities;
                })
                // パスワード設定
                .passwordCompare()
                    // 暗号化設定
                    .passwordEncoder(new BCryptPasswordEncoder())
                    // パスワード項目名、指定しないと認識しない
                    .passwordAttribute("userPassword");
    }
}
```

##WebFlux版
WebFlux版では、HttpSecurityはHttpSecurityServerになったが、ほぼ書き方は同じ。
LDAP周りは、書き方が大幅に変更となり、参考URLだけでは使えなかったので苦労した。
参考URL : https://stackoverflow.com/questions/50506803/spring-security-webflux-and-ldap

```java:config/WebSecurityConfig.java
package com.sample.authldap.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManagerAdapter;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
import org.springframework.security.ldap.authentication.LdapAuthenticationProvider;
import org.springframework.security.ldap.authentication.PasswordComparisonAuthenticator;
import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator;
import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Configuration
public class WebSecurityConfig {
    @Bean
    protected SecurityWebFilterChain configure(ServerHttpSecurity http) {
        return http
                // CSRFを無効にする
                .csrf().disable()
                // ログイン ※Basic認証の場合は、httpBasic
                .formLogin()
                // ログインURL、指定しないとSpring標準のLogin画面(/login)が表示され、FailureHandlerがなぜか呼び出されない。
                .loginPage("/login")
                // 認証成功時の処理
                .authenticationSuccessHandler((webFilterExchange, authentication) -> {
                    // デフォルトは/にリダイレクトするため、権限情報をカンマ区切りで出力するように変更
                    // → Listをカンマ区切りに変換するときは、Java8から導入されたStringJoinerとラムダ式が便利
                    // →　mapで項目ごとにデータ変換し、collectで集計
                    byte[] msgByte = authentication.getAuthorities().stream()
                            .map(s -> s.getAuthority()).collect(Collectors.joining(",")).getBytes(StandardCharsets.UTF_8);
                    ServerHttpResponse response = webFilterExchange.getExchange().getResponse();
                    response.setStatusCode(HttpStatus.OK);
                    return response.writeWith(Mono.just(response.bufferFactory().wrap(msgByte)));
                })
                // 認証失敗時の処理
                .authenticationFailureHandler((webFilterExchange, exception) -> {
                    // 認証失敗時のエラーメッセージのみ出力する。
                    byte[] msgByte = exception.getMessage().getBytes(StandardCharsets.UTF_8);
                    ServerHttpResponse response = webFilterExchange.getExchange().getResponse();
                    response.setStatusCode(HttpStatus.UNAUTHORIZED);
                    return response.writeWith(Mono.just(response.bufferFactory().wrap(msgByte)));
                })
                // ログアウト、デフォルトは/logout
                .and().logout()
                    // デフォルトでは/login?logoutにリダイレクトされてしまうため、200を連携するように変更
                    .logoutSuccessHandler(new HttpStatusReturningServerLogoutSuccessHandler(HttpStatus.OK))
                // アクセス拒否された場合
                .and().exceptionHandling()
                .authenticationEntryPoint((exchange, e) -> {
                    // デフォルトではloginPageにリダイレクトするため、401にして、エラーメッセージを出力するように変更
                    byte[] msgByte = e.getMessage().getBytes(StandardCharsets.UTF_8);
                    ServerHttpResponse response = exchange.getResponse();
                    response.setStatusCode(HttpStatus.UNAUTHORIZED);
                    return response.writeWith(Mono.just(response.bufferFactory().wrap(msgByte)));
                })
                // 認可設定
                .and().authorizeExchange()
                // すべてのURLを認証あり
                .anyExchange().authenticated()
                .and().build();
    }

    @Bean
    CorsConfigurationSource corsConfiguration() {
        // CORS設定(RESTで認証させる場合は必要）
        CorsConfiguration corsConfig = new CorsConfiguration();
        corsConfig.applyPermitDefaultValues();
        corsConfig.addAllowedMethod(HttpMethod.POST);
        corsConfig.addAllowedMethod(HttpMethod.GET);
        corsConfig.addAllowedMethod(HttpMethod.PUT);
        corsConfig.addAllowedOrigin("*");
        corsConfig.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source =
                new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfig);
        return source;
    }

    @Bean
    DefaultSpringSecurityContextSource contextSource() {
        // LDAP接続先情報
        return new DefaultSpringSecurityContextSource(Arrays.asList("ldap://localhost:8389"),"dc=sample,dc=com");
    }

    @Bean
    ReactiveAuthenticationManager authenticationManager(DefaultSpringSecurityContextSource contextSource) {
        PasswordComparisonAuthenticator authenticator = new PasswordComparisonAuthenticator(contextSource);
        // LDAP検索条件
        authenticator.setUserDnPatterns(new String[] {"uid={0},ou=people"});
        // 暗号化設定(BCrypt)
        authenticator.setPasswordEncoder(new BCryptPasswordEncoder());
        LdapAuthoritiesPopulator ldapAuthoritiesPopulator = (userData, username) -> {
            // LDAPのアクセス権限ではなく、独自のアクセス権限を付与できる。LDAPの付加情報もここで取得可能。
            List<GrantedAuthority> authorities = new ArrayList<>();
            if (username.equals("admin")) {
                authorities.add(new SimpleGrantedAuthority("ADMIN"));
            } else {
                authorities.add(new SimpleGrantedAuthority("USER"));
            }
            return authorities;
        };
        LdapAuthenticationProvider ldapAuthenticationProvider = new LdapAuthenticationProvider(authenticator, ldapAuthoritiesPopulator);
        AuthenticationManager authenticationManager = new ProviderManager(Arrays.asList(ldapAuthenticationProvider));
        return new ReactiveAuthenticationManagerAdapter(authenticationManager);
    }
}
```

##テスト用Contoller
```java:controller/HelloController.java
package com.sample.authldap.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    @RequestMapping("/")
    public String index() {
        return "Hello!";
    }
}
```

#動作確認

実際のログイン画面は、Vue.jsやReactなどで実装するが、ここでは動作確認として、Postmanを利用する。参考URL : https://www.postman.com/downloads/

1. ログイン前に、http://localhost:8080 にアクセスする。
→ Status: 401 Unauthorizedで、「このリソースにアクセスするには認証をする必要があります」と出力される。WebFluxは「Not Authenticated」

2. ログイン確認。http://localhost:8080/login に対して、以下の内容をPOSTしてみる。
 Content-Type : x-www-form-urlencoded
 username : user
 password : test2
→　Status: 401 Unauthorized、「Bad credentials」が表示される。WebFluxは「ユーザ名かパスワードが正しくありません」
 username : admin
 password : test
→ 「ROLE_ADMIN」が表示される。WebFluxは「ADMIN」
 username : user
 password : test
→ 「ROLE_USER」が表示される。WebFluxは「USER」
 Content-Type : form-data
 username : user
 password : test
→ 「ROLE_USER」が表示される。WebFluxはform-dataが利用できず「ユーザ名は空にできません」と表示されるので、要注意。

3. ログイン後に、http://localhost:8080 にアクセスする。
→ 「Hello! 」が表示される。

4. ログアウト確認。http://localhost:8080/logout にアクセスする。
→ Status: 401 Unauthorizedで、「このリソースにアクセスするには認証をする必要があります」と出力される。WebFluxは「Not Authenticated」