LoginSignup
4
6

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-02-24

環境

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で生成するサイトから貼り付け。
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()
}
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);
    }

}
application.yml
spring:
  ldap:
    embedded:
      base-dn: dc=sample,dc=com
      ldif: classpath:test-server.ldif
      port: 8389
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などをカスタマイズすると、少ないコードでシンプルに記述することができます。

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

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

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」

4
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
6