環境
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で生成するサイトから貼り付け。
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()
}
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);
}
}
spring:
ldap:
embedded:
base-dn: dc=sample,dc=com
ldif: classpath:test-server.ldif
port: 8389
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などをカスタマイズすると、少ないコードでシンプルに記述することができます。
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
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
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/
ログイン前に、http://localhost:8080 にアクセスする。
→ Status: 401 Unauthorizedで、「このリソースにアクセスするには認証をする必要があります」と出力される。WebFluxは「Not Authenticated」ログイン確認。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が利用できず「ユーザ名は空にできません」と表示されるので、要注意。ログイン後に、http://localhost:8080 にアクセスする。
→ 「Hello! 」が表示される。ログアウト確認。http://localhost:8080/logout にアクセスする。
→ Status: 401 Unauthorizedで、「このリソースにアクセスするには認証をする必要があります」と出力される。WebFluxは「Not Authenticated」