S-JIS[2017-08-26/2017-09-19] 変更履歴

Spring Boot web Basic認証

Spring BootのウェブアプリケーションのBasic認証・Digest認証のサンプル。


概要

Basic認証は、HTTPで規定されている認証方式。
ブラウザーでサイトを見ようとしたときに「ユーザーID・パスワードを入力するダイアログ」が出てくることがあるけど、概ねあれのこと。
(ログイン画面を作ってログインするのはフォーム認証

ただし、Basic認証はパスワードが暗号化されずにインターネット上を流れるので、より安全なDigest認証というものもある。
(Digest認証も今となっては気休め程度らしいが^^;)

Spring BootでBasic認証やDigest認証を行う場合は、Spring Security(spring-boot-starter-security)を使用する。

build.gradle:

〜
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認証の例

全ウェブページに対してBasic認証を要求する例。(Spring Boot 1.5.6、Spring Security 4.2.3)

ウェブページに初めてアクセスするときに認証を要求し、それが通ったらアクセスできるようにしてみる。


まず、どのURIに対して認証を要求するかを設定するクラスを作成する。

src/main/java/com/example/demo/auth/WebSecurityBasicConfig.java:

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等にエンコードされて入っているパスワード」と比較する)


次に、ユーザーのパスワードを管理するクラスを用意する。

src/main/java/com/example/demo/auth/LoginUserService.java:

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認証?の例

全ウェブページに対してDigest認証っぽい要求する例。(Spring Boot 1.5.6、Spring Security 4.2.3)

Basic認証に対してDigestの設定を追加する形のコーディングが出来る。
(が、これはどうも正しいDigest認証ではなさそう。→正しいDigest認証 [2017-09-19]

src/main/java/com/example/demo/auth/WebSecurityDigestConfig.java:

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認証の例

全ウェブページに対してDigest認証を要求する例。(Spring Boot 1.5.6、Spring Security 4.2.3)[2017-09-19]

src/main/java/com/example/demo/auth/WebSecurityDigestConfig.java:

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認証を行ってしまうようで、その認証が通らなくて認証失敗扱いになってしまう。


src/main/java/com/example/demo/auth/LoginUserService.java:

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"
〜

パスワードのMD5ハッシュ化

Digest認証でパスワードを平文のままDBに保存するのは嫌なので、MD5ハッシュ化する。
(と言っても、今やMD5も強度不足らしいが(苦笑))

src/main/java/com/example/demo/auth/WebSecurityDigestConfig.java:

	private DigestAuthenticationFilter digestAuthenticationFilter(DigestAuthenticationEntryPoint authenticationEntryPoint) throws Exception {
		DigestAuthenticationFilter digestAuthenticationFilter = new DigestAuthenticationFilter();
〜
		digestAuthenticationFilter.setPasswordAlreadyEncoded(true); // DB上のパスワードはMD5ハッシュ
		return digestAuthenticationFilter;
	}

src/main/java/com/example/demo/auth/LoginUserService.java:

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


Spring Bootへ戻る / Spring Frameworkへ戻る / 技術メモへ戻る
メールの送信先:ひしだま