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・パスワードを「:」(コロン)区切りでつないだ文字列をハッシュ化する。