Spring BootのウェブアプリケーションのBasic認証・Digest認証のサンプル。
|
Basic認証は、HTTPで規定されている認証方式。
ブラウザーでサイトを見ようとしたときに「ユーザーID・パスワードを入力するダイアログ」が出てくることがあるけど、概ねあれのこと。
(ログイン画面を作ってログインするのはフォーム認証)
ただし、Basic認証はパスワードが暗号化されずにインターネット上を流れるので、より安全なDigest認証というものもある。
(Digest認証も今となっては気休め程度らしいが^^;)
Spring BootでBasic認証やDigest認証を行う場合は、Spring Security(spring-boot-starter-security)を使用する。
〜 dependencies { compile('org.springframework.boot:spring-boot-starter-security') compile('org.springframework.boot:spring-boot-starter-thymeleaf') runtime('org.springframework.boot:spring-boot-devtools') testCompile('org.springframework.boot:spring-boot-starter-test') testCompile('org.springframework.security:spring-security-test') }
全ウェブページに対してBasic認証を要求する例。(Spring Boot 1.5.6、Spring Security 4.2.3)
ウェブページに初めてアクセスするときに認証を要求し、それが通ったらアクセスできるようにしてみる。
まず、どのURIに対して認証を要求するかを設定するクラスを作成する。
package com.example.demo.auth; import org.springframework.context.annotation.Bean; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
/** * Basic認証 */ @EnableWebSecurity public class WebSecurityBasicConfig extends WebSecurityConfigurerAdapter {
@Override protected void configure(HttpSecurity http) throws Exception { http.httpBasic(); // Basic認証を行う http.authorizeRequests() // .anyRequest().authenticated(); // 全リクエストに対して認証を要求 }
// パスワードのエンコーダー @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
クラスには@EnableWebSecurityアノテーションを付け、WebSecurityConfigurerAdapterを継承し、configureメソッドをオーバーライドする。
configureメソッドでBasic認証を行う事と、認証を行うURIを定義する。
今回の例では(面倒なので^^;)全リクエストに対して認証を要求するようにしたが、URI毎に認証の有無を指定できる。
パスワードのチェックは、「入力されたパスワード」と「DB等に保存されたパスワード」が一致するするかどうかを確認するわけだが、
DBにパスワードをそのまま(平文のまま)保存するなど有り得ず、何らかの方式でエンコード(暗号化・ハッシュ化)して保存するだろう。
その方式を決めるのがPasswordEncoder。
とりあえず今はBCryptを使うのが良いらしい。
PasswordEncoderを返すメソッドを定義し、@Beanアノテーションを付けておくと、Spring
Securityフレームワークの中でこれが使われる。(ユーザーから入力されたパスワードをPasswordEncoderを使ってエンコードし、「DB等にエンコードされて入っているパスワード」と比較する)
次に、ユーザーのパスワードを管理するクラスを用意する。
package com.example.demo.auth; import java.util.Collections; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service;
@Service public class LoginUserService implements UserDetailsService {
// 実体は@Beanの付いたpasswordEncoderメソッドから取得される @Autowired private PasswordEncoder passwordEncoder;
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { if (username == null) { throw new UsernameNotFoundException("empty"); } // 本来ならDBアクセスしてパスワードを取得するところだが、サンプルなのでプログラム直書き String password; switch (username) { case "hishidama": password = passwordEncoder.encode("hoge"); // パスワードは「hoge」 break; default: throw new UsernameNotFoundException("not found"); } return new User(username, password, Collections.emptySet()); } }
Spring Securityでは、ログインユーザーの情報はUserDetailsで管理することになっている。
UserDetailsServiceで、ユーザー毎のUserDetailsを返すよう実装する。
フィールドに@Autowiredアノテーションを付けたPasswordEncoderフィールドを用意しておくと、@Beanを付けたPasswordEncoderを返すメソッドの値が自動的にセットされる。
DBにパスワードを保存する際には、これを使ってエンコードしたパスワードを保存する。
今回の例ではDBは使っていないので、毎回エンコードしている。
これで、ブラウザーからアクセスしようとすると、Basic認証(ユーザーIDとパスワードの入力)が求められる。
全ウェブページに対してDigest認証っぽい要求する例。(Spring Boot 1.5.6、Spring Security 4.2.3)
Basic認証に対してDigestの設定を追加する形のコーディングが出来る。
(が、これはどうも正しいDigest認証ではなさそう。→正しいDigest認証
[2017-09-19])
package com.example.demo.auth; import org.springframework.context.annotation.Bean; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.authentication.www.DigestAuthenticationEntryPoint;
/** * ダイジェスト認証? */ @EnableWebSecurity public class WebSecurityDigestConfig extends WebSecurityConfigurerAdapter {
@Override protected void configure(HttpSecurity http) throws Exception { http.httpBasic().authenticationEntryPoint(digestAuthenticationEntryPoint()); // Digest認証? http.authorizeRequests() // .anyRequest().authenticated(); // 全リクエストに対して認証を要求 }
private DigestAuthenticationEntryPoint digestAuthenticationEntryPoint() { DigestAuthenticationEntryPoint entry = new DigestAuthenticationEntryPoint(); entry.setRealmName("example.realm"); entry.setKey("digest-key"); entry.setNonceValiditySeconds(60); return entry; }
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
httpBasic()に対してauthenticationEntryPointを指定する以外は、Basic認証と全く同じ。
(UserDetailsServiceも全く同じ)
これで、ブラウザーからアクセスしようとすると、認証(ユーザーIDとパスワードの入力)が求められる。
が、見た目上ではBasic認証と区別が付かない^^;
UNIX(WindowsならCygwinやWindows10のbash
on Windows)のcurlコマンドを使うとHTTPヘッダーを表示することが出来るので、それで確認できる。
$ curl -i http://127.0.0.1:8080/hello HTTP/1.1 401 X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Set-Cookie: JSESSIONID=D614872A81B3FF519EE633F105ACA1D5; Path=/; HttpOnly WWW-Authenticate: Digest realm="example.realm", qop="auth", nonce="MTUwMzc1MDU5NTY1Nzo4MGI3MjZjZjIzMWVkY2Y0Y2ZkNjMyNzc0MDIxZDQ3MQ==" Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Sat, 26 Aug 2017 12:28:55 GMT {"timestamp":1503750535659,"status":401,"error":"Unauthorized","message":"Full authentication is required to access this resource","path":"/hello"}
自分がプログラムで指定したrealmが表示されていればOK。
ちなみにcurlコマンドでは、-uでユーザー(およびパスワード)を指定できる。
$ curl -i http://127.0.0.1:8080/hello -u hishidama $ curl -i http://127.0.0.1:8080/hello -u hishidama:hoge
ただ、--digestオプションを付けなくても動作するので、実はDigest認証ではないっぽい。[2017-09-19]
また、ブラウザーからユーザーIDとパスワードを入力しても認証が通らない。
→正しいDigest認証
全ウェブページに対してDigest認証を要求する例。(Spring Boot 1.5.6、Spring Security 4.2.3)[2017-09-19]
package com.example.demo.auth; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.concurrent.ConcurrentMapCache; 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.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.cache.SpringCacheBasedUserCache; import org.springframework.security.web.authentication.www.DigestAuthenticationEntryPoint; import org.springframework.security.web.authentication.www.DigestAuthenticationFilter;
/** * ダイジェスト認証 */ @EnableWebSecurity public class WebSecurityDigestConfig extends WebSecurityConfigurerAdapter { public static final String REALM_NAME = "example.realm";
@Autowired private UserDetailsService userDetailsService;
@Override protected void configure(HttpSecurity http) throws Exception { DigestAuthenticationEntryPoint authenticationEntryPoint = digestAuthenticationEntryPoint(); http.addFilter(digestAuthenticationFilter(authenticationEntryPoint)) // Digest認証 .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint); http.authorizeRequests() // .anyRequest().authenticated(); // 全リクエストに対して認証を要求 }
private DigestAuthenticationFilter digestAuthenticationFilter(DigestAuthenticationEntryPoint authenticationEntryPoint) throws Exception { DigestAuthenticationFilter digestAuthenticationFilter = new DigestAuthenticationFilter(); digestAuthenticationFilter.setAuthenticationEntryPoint(authenticationEntryPoint); digestAuthenticationFilter.setUserDetailsService(userDetailsService); digestAuthenticationFilter.setUserCache(new SpringCacheBasedUserCache(new ConcurrentMapCache("digestUserCache"))); digestAuthenticationFilter.setPasswordAlreadyEncoded(false); // DB上のパスワードは平文 return digestAuthenticationFilter; } private DigestAuthenticationEntryPoint digestAuthenticationEntryPoint() { DigestAuthenticationEntryPoint entry = new DigestAuthenticationEntryPoint(); entry.setRealmName(REALM_NAME); entry.setKey("digest-key"); entry.setNonceValiditySeconds(60); return entry; } }
HttpSecurityに対し、addFilterメソッドでDigestAuthenticationFilterを登録する。
また、例外ハンドラーにDigestAuthenticationEntryPointを登録する。
ブラウザーとの認証のやりとりは以下のようになる。
ブラウザーがSpring Bootにリクエストを送る(初回なので認証情報は無し)
↓
DigestAuthenticationFilterは認証チェックを行うが、認証情報が無いのでスルー
↓
認証失敗を例外ハンドラーが捕捉し、DigestAuthenticationEntryPoint(realm等)を元にブラウザーに認証情報を要求
(この為、例外ハンドラーにDigestAuthenticationEntryPointを登録しておく必要がある)
↓
ブラウザーがSpring Bootに認証情報を送る
↓
DigestAuthenticationFilterは認証チェックを行う(認証が通ればOK)
なお、「@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
」が残っていると、Digest認証が通った後にBasic認証を行ってしまうようで、その認証が通らなくて認証失敗扱いになってしまう。
package com.example.demo.auth; import java.util.Collection; import java.util.EnumSet; import java.util.Set; import java.util.stream.Collectors; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service;
@Service public class LoginUserService implements UserDetailsService {
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { if (username == null) { throw new UsernameNotFoundException("empty"); } // 本来ならDBアクセスしてパスワードを取得するところだが、サンプルなのでプログラム直書き String password; switch (username) { case "hishidama": password = "hoge"; // パスワード(平文) break; default: throw new UsernameNotFoundException("not found"); } return new User(username, password, Collections.emptySet()); } }
DigestAuthenticationFilterのsetPasswordAlreadyEncodedメソッドにfalse(デフォルト)を指定している場合、
UserDetailsのパスワードは平文のままにする。
→MD5ハッシュ化
curlコマンドでDigest認証をする場合は、「--digest」オプションを付ける。
「-v」オプションを付けると、ヘッダーも見られる。
$ curl -v --digest http://127.0.0.1:8080/ -X GET -u hishidama:hoge 〜 > GET / HTTP/1.1 > Host: 127.0.0.1:8080 〜 < WWW-Authenticate: Digest realm="example.realm", qop="auth", nonce="MTUwNTgyMzIyODQ1MTpkMmZmNDgzYzI5M2I3MzAyMmZjMDVhOGQwMDExZDhlNA==" 〜 > GET / HTTP/1.1 > Host: 127.0.0.1:8080 > Authorization: Digest username="hishidama", realm="example.realm", nonce="MTUwNTgyMzIyODQ1MTpkMmZmNDgzYzI5M2I3MzAyMmZjMDVhOGQwMDExZDhlNA==", uri="/", cnonce="ZWU1ZTQ3YTMyYWYyYjBlNTY0ZjU1N2ExN2E5NDI2MjQ=", nc=00000001, qop=auth, response="c93e172431d32d800070892aac815a1b" 〜
Digest認証でパスワードを平文のままDBに保存するのは嫌なので、MD5ハッシュ化する。
(と言っても、今やMD5も強度不足らしいが(苦笑))
private DigestAuthenticationFilter digestAuthenticationFilter(DigestAuthenticationEntryPoint authenticationEntryPoint) throws Exception { DigestAuthenticationFilter digestAuthenticationFilter = new DigestAuthenticationFilter(); 〜 digestAuthenticationFilter.setPasswordAlreadyEncoded(true); // DB上のパスワードはMD5ハッシュ return digestAuthenticationFilter; }
import org.springframework.util.DigestUtils;
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { if (username == null) { throw new UsernameNotFoundException("empty"); } // 本来ならDBアクセスしてパスワードを取得するところだが、サンプルなのでプログラム直書き String password; switch (username) { case "hishidama": // パスワード(Digest認証) password = DigestUtils.md5DigestAsHex((username + ":" + WebSecurityDigestConfig.REALM_NAME + ":" + "hoge").getBytes()); break; default: throw new UsernameNotFoundException("not found"); } return new User(username, password, Collections.emptySet()); } }
Digest認証用のMD5ハッシュは、ユーザーID・realm・パスワードを「:
」(コロン)区切りでつないだ文字列をハッシュ化する。