やっぱり一発目の Spring Security やってみたで UserDetailsService 使うのは止めようず。So many UserDetailsService samples considered harmful for beginners.
はじめに
ちょっと待って!その UserDetails、本当に必要ですか?で書いたことの繰り返しなんですけども。
Spring Security で認証機能やってみた系のエントリは高確率でUserDetailsService
, UserDetails
使って実装してると思うんだけど、そんなん使って実装してみても重要なところなんも理解できなかったでしょ?
(暗黙のデフォルト設定がゴイゴイ入っているので、そのデフォルト設定の仕組みを理解しないことには肝心の認証機能について入っていけないでしょ?)
自分は10. Authentication章の冒頭で列挙されているような要素を理解することがまず優先すべきことだと思ってる。もうちょっと絞て具体的に言うと
AuthenticationProvider
(及びそれを取りまとめる AuthenticationManager
): 具体的に認証処理を実装するところ
Filter
: リクエストをフックして認証処理を行うようにする
WebSecurityConfig
: 上記のフィルタやら認証プロバイダやらを使うようにする設定
の 3 点を理解するのが最初の一歩目だと信じてるんですねこれ。
ところが(すっとこ)どっこい(しょ)、UserDetailsService
使うと実際に認証処理を行うところであるAuthenticationProvider
からしてどこにいっちゃってるのかわかんなくなる。
いやいやさすがにそこ外したらいかんでしょ、というのが私が懸念するところです。
なのでここでUserDetailsService
を使わない「やってみた」記事をぶちかまそうというのが主旨です。
ちなみに、タイトルでは「一発目に使うのは止めよう」と言っていますが、個人的には二発目以降も別に要らんと思ってます。
ただ公式ドキュメントでえらい推されてるんで若干弱気(…と思って最新版のドキュメント見たらこの辺の記述無くなって、やや推し力は弱まっている感じも受けたけど(それでもまだドキュメント内を"userdetails"で検索すると 248 箇所もヒットするんだけどね))。
UserDetailsService
使えばこんな便利なんだぜ!みたいなことがあるのなら教えて欲しいんだぜ。
更にちなむと、公式ドキュメント中で最も簡潔にUserDetailsService
について説明されているのは"What is a UserDetailsService and do I need one?“節。そんな長くないので全文引っ張ってくると:
UserDetailsService
is a DAO interface for loading data that is specific to a user account. It has no other function other to load that data for use by other components within the framework. It is not responsible for authenticating the user. Authenticating a user with a username/password combination is most commonly performed by the DaoAuthenticationProvider
, which is injected with a UserDetailsService
to allow it to load the password (and other data) for a user in order to compare it with the submitted value. Note that if you are using LDAP, this approach may not work.
If you want to customize the authentication process then you should implement AuthenticationProvider
yourself. See this blog article for an example integrating Spring Security authentication with Google App Engine.
というわけで、前述のUserDetailsService
に隠されてしまったAuthenticationProvider
実装というのはDaoAuthenticationProvider
のことなんですけれども、UserDetailsService
使って「やってみた」人、その点理解できてました?
そして本記事の主旨としては、このドキュメントの文章を借りれば、“implement AuthenticationProvider yourself"をちゃんと「やってみた」しとこうよ、ということになるんだわさ。
コードへのリンク
https://github.com/yukihane/hello-java/tree/master/spring/springboot-auth-example-202006
以降の文章中では、そのタイミングでの実装コードのハッシュも記しています。
「やってみた」してみよう!
ところで、そもそも何を作ろうとしているの?
以下のの blog で実装している機能(の途中まで)をパクらせてもらいます。
(以降、こちらの blog エントリのことを「参考元」と呼称します。)
次のような機能を提供する Web API を実装します:
- ユーザ登録
- 登録したユーザの認証(ログイン)
- ログインしたユーザのみが取得できるリソース提供
あんま参考元タイトルに"JWT"という単語が入ってますがそこはあんま関係ないです。AuthenticationProvider
自作するためのネタになっているだけです。
あと、参考元あるんやったらそっち見た方が良いんじゃないの?という疑問については:
- 参考元は
UserDetailsService
使っているのに対しこちらは使っていない、というのが最も大きな違いです
- 参考元は一気にコードがドバっと出てくるので、実際に作る順番がわかりづらいかな、と思いました(のでこちらでは順番考えて説明しています)
- あとは、勘違いしそうな型の使い回しをなるべくやめたり、誤解しそうな箇所だと思う点について細かな修正を行っています
ベースプロジェクトの作成と基本設定追加
それじゃ早速やっていきましょう。
ベースプロジェクト作成
https://start.spring.io/ でベースを作成しよう。ちなみに Jav11 の想定で、今回利用する SpringBoot のバージョンはリリースされたてほやほやの 2.3.1 だ!(このサンプル書いてる途中にリリースされたよ!)
利用する dependencies は次の通り:
- 使う使わないに関わらず取り敢えずぶち込んどけ系: DevTools, Lombok, Spring Configuration Processor
- スタンダードなウェブアプリ作るので: Spring Web
- Spring Security をやってみたするので当然: Spring Security
- DB にユーザ登録してそのデータで認証処理するので: Spring Data JPA, H2 Database
ベースプロジェクトの作成が終わったら、ダウンロード&展開してSpring Tools 4 for Eclipseを起動してプロジェクトをインポートしよう。
続いて色々基本的な設定を加えていくよ。
依存ライブラリの追加
参考元に書いてある通り、今回のサンプルでは次のライブラリが追加で必要なのでpom.xml
のdependencies
に追加します:
1
2
3
4
5
6
7
8
9
|
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
|
SQL ログ出力
インメモリ DB 使うのでホンマに DB に入ってんの?とか気になると思うので SQL をログ出力するようにしときます。
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
UserDetailsService の auto-configuration を無効化
今回の目玉、UserDetailsService
を使わない、を確実にするためにUserDetailsServiceAutoConfiguration
を disable します。デフォルトだと良い感じに設定されちゃってるので、使ってないつもりで使ってた、みたいなことが(SpringBoot あるある)。
1
2
3
4
5
6
7
8
9
|
package com.example.springbootauthexample202006;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
public class SpringbootAuthExample202006Application {
...
|
websecurityconfg の auto-configuration を無効化
この件です。未設定の状態だと Boot 君がよしなに設定してくれちゃうんで取り敢えず次の設定をぶち込んでおきます:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
package com.example.springbootauthexample202006.security;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(final HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
|
いや空設定ちゃうんかい!csrf
とか何やねんそれ!というツッコミについては、今回のスコープから外れるのでパス。
画面じゃない Web API なんでこれで良いんですぅ。
ここまでのコード: 31312e8c94530bb6f6272d0b9c6c9607a83939ec
ユーザエンティティの作成
さてベースを設定し終わったので実装に入りましょう。
何から作るのが自然かと聞かれたときユーザのエンティティから作ると答えるのは別に変じゃなかろうもん。
ログイン ID とかパスワードを保持するところ、最初に欲しいよね?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
package com.example.springbootauthexample202006.user;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Version;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Data
@NoArgsConstructor
public class ApplicationUser {
@Id
@GeneratedValue
private Long id;
@Version
private int version;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
public ApplicationUser(final String username, final String password) {
this.username = username;
this.password = password;
}
}
|
はい。特筆すべきところが無い普通の JPA Entity クラスです。もうちょっとそれっぽく email とかの項目有っても良いんじゃないかとも一瞬考えましたが面倒なのでやめました。
ところでいきなり余談に入るんだけれど(なので読み飛ばして OK)、UserDetailsService
を使う場合、UserDetails
は上記のようなユーザエンティティに実装すべきでしょうか?
自分は、
- 一般的にはユーザエンティティは
UserDetails
を実装する必要はない(し、実装しちゃうと理解の妨げになるので、少なくともやってみたコードでは実装すべきではない)
派なんだけれども、巷にあふれるやってみたコードでは実装しちゃってるコードがどちゃくそ多い。
…書いてて気になってきたのでちょっと Qiita 内の記事で見てみよう…というわけで根気が途切れるまで新着順で検索してみた:
- ユーザエンティティに
UserDetails
を implements する 派
- ユーザエンティティに
UserDetails
を implements しない 派
- その他:
UserDetails
にユーザエンティティを所有させる派
(あんま時間かけて見てないので分類間違い御免)
…どちゃくそ多いという程ではなかった。でも第 3 の派閥を見つけてしまったよ…
ちなみに公式リファレンスではUserDetails
についての指針は特に無いし、公式ガイド(1,2)のサンプルコード含めてもインメモリでUserDetails
オブジェクト作ってる例ばっかりでそれを実際にはどこからどうやって取得すべきなのかが推測できないものばっかり。うーんこ 💩 の。
サインアップ機能(ユーザ登録機能)
さあさ続きましてはさっきのApplicationUser
の永続化でございます。
まだ Spring Security 関係ないのでサクッと行きましょう。
コントローラと、コントローラがつこてるApplicationUser
リポジトリを実装。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
package com.example.springbootauthexample202006.user;
import java.util.List;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
@Data
public static class UserForm {
private String username;
private String password;
}
private final ApplicationUserRepository applicationUserRepository;
@PostMapping("/sign-up")
public void signUp(@RequestBody final UserForm form) {
final ApplicationUser user = new ApplicationUser(
form.getUsername(),
form.getPassword());
final ApplicationUser saved = applicationUserRepository.save(user);
log.info("User sign-upped: {}", saved);
}
@GetMapping("")
public List<ApplicationUser> users() {
return applicationUserRepository.findAll();
}
}
|
1
2
3
4
5
6
7
8
|
package com.example.springbootauthexample202006.user;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ApplicationUserRepository extends JpaRepository<ApplicationUser, Long> {
}
|
ここまでのコード: 916d7bed6d26787b73091725a662a39051130f04
ここまで実装できたら実際に動かしてみよう。curl
を使って次を実行だ:
1
2
3
4
|
curl -H "Content-Type: application/json" -X POST -d '{
"username": "yamada",
"password": "password"
}' http://localhost:8080/users/sign-up
|
これでyamada
君が登録された。ログにそれっぽい出力があるはずだ。あるいは、
1
|
curl http://localhost:8080/users
|
で登録ユーザ一覧が見られる。
パスワードのハッシュ化
(今回の流れで出すには少し細かい話なのかなと思ったのだけれど、)
1
|
curl http://localhost:8080/users
|
を見て気づいたであろうか。そう!!誰も!!パスワードをハッシュ化していないのである!!
というわけでハッシュ化しましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@PostMapping("/sign-up")
public void signUp(@RequestBody final UserForm form) {
final PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
final ApplicationUser user = new ApplicationUser(
form.getUsername(),
passwordEncoder.encode(form.getPassword()));
final ApplicationUser saved = applicationUserRepository.save(user);
log.info("User sign-upped: {}", saved);
}
|
ここまでのコード: ec6b8045d0b007c1c6dd3eb58b31bd8b117ee362
もう一度上に書いたcurl
コマンドを実行してみよう。今度は生パスワードでなくハッシュ化されたパスワードが DB に保存されたはずだ。
ちなみにこのPasswordEncoder
、巷のやってみた記事では次のように Bean 化しているものが多い。
1
2
3
4
5
6
7
|
@Configuration
public class MyConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
...
|
だけどなァ、これをするとなァ、Spring Security のグローバルのデフォルト設定が置き換わっちまうんだよなァ。
わかっててやってるなら良いんだけど、何の説明もなしにいきなり書くなら参考元のようにBCryptPasswordEncoder
を Bean 化するのが無難じゃなかろかいな。
1
2
3
4
|
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
|
セキュリティ設定
ヒャハッー!ついに Spring Security の時間だぜ!
取り敢えず原則認証受けてないとアクセスできないように設定しよう。
ただし、上で実装したサインアップエンドポイントだけは例外だ。誰でもアクセスできなくちゃあならない。
(さもなくば、服を買いに行くための服が無い状態だ。)
1
2
3
4
|
http.authorizeRequests()
.antMatchers(HttpMethod.POST, "/users/sign-up")
.permitAll()
.anyRequest().authenticated();
|
ここもまあハマりポイントとかいろいろ有ったりすると思うんだけど涙をのんで今回は詳しい話をパス!
ここまでのコード: 2692a5c1fc141d412777d3e9126c6f4f99727d87
さて上記セキュリティ設定が済んだらもう一度上のcurl
コマンドを実行してみよう。
1
2
3
4
|
curl -H "Content-Type: application/json" -X POST -d '{
"username": "tanaka",
"password": "password"
}' http://localhost:8080/users/sign-up
|
ふむ、ユーザ登録は登録できているように見える。
1
|
curl http://localhost:8080/users
|
ん?403
に変わったぞ?となったら正解だ。自由にアクセスできないようにセキュリティ設定したんだからな!
認証の実現
認証フィルタ
さあそろそろヤマ場だ。
認証フィルタは冒頭「はじめに」で書いた通りリクエストをフックして認証処理を行わせるところだ。
今回、敢えて自作するサンプルを選んだわけだけれども、そういう場合でも 1 から作るみたいなことはあんまりないと思う。
一番よくあるのは今回みたいに UsernamePasswordAuthenticationFilter
を継承してカスタマイズする、みたいなものなんじゃなかろうか。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
package com.example.springbootauthexample202006.security;
import static com.auth0.jwt.algorithms.Algorithm.HMAC512;
import com.auth0.jwt.JWT;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.Date;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private static final String SECRET = "SecretKeyToGenJWTs";
private static final long EXPIRATION_TIME = 864_000_000; // 10 days
private static final String TOKEN_PREFIX = "Bearer ";
private static final String HEADER_STRING = "Authorization";
private final ObjectMapper objectMpper = new ObjectMapper();
public JWTAuthenticationFilter(final AuthenticationManager authenticationManager) {
super();
setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(final HttpServletRequest req,
final HttpServletResponse res) throws AuthenticationException {
try {
final LoginForm form = objectMpper.readValue(req.getInputStream(), LoginForm.class);
final UsernamePasswordAuthenticationToken creds = new UsernamePasswordAuthenticationToken(
form.getUsername(),
form.getPassword());
return getAuthenticationManager().authenticate(creds);
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
@Override
protected void successfulAuthentication(final HttpServletRequest req,
final HttpServletResponse res,
final FilterChain chain,
final Authentication auth) throws IOException, ServletException {
final String token = JWT.create()
.withSubject(auth
.getName())
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.sign(HMAC512(SECRET.getBytes()));
res.addHeader(HEADER_STRING, TOKEN_PREFIX + token);
}
}
|
ここまでのコード: 2d84598819a0574f188b57c930a06c23ff7c2db7
長い割に重要なポイントは 2 つだけなんだけど、
getAuthenticationManager().authenticate(creds);
としてるのが認証プロバイダ(※次節で実装)に認証処理を委譲しているところ。フィルタがやってるのはその認証プロバイダが認証を行うのに必要な情報の抽出。
- (このコード上には現れていなくて、親クラスがやっていることなんだけれど、)このフィルタが適用される、つまり認証処理が行われるのは
/login
に対する POST
。
というわけで、次は委譲先、認証プロバイダの実装だ。
認証プロバイダ
入力されたユーザ名とパスワードが DB データと一致してるか確認する、これが!これこそが!みんなの思い描く認証だ!
UserDetailsSevice
使ったときのモヤモヤが晴れるだろう!この素直な実装!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
package com.example.springbootauthexample202006.security;
import com.example.springbootauthexample202006.user.ApplicationUser;
import com.example.springbootauthexample202006.user.ApplicationUserRepository;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
@RequiredArgsConstructor
public class ApplicationUserAuthenticationProvider implements AuthenticationProvider {
private final PasswordEncoder passwordEncoder;
private final ApplicationUserRepository applicationUserRepository;
@Override
public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
final UsernamePasswordAuthenticationToken auth = (UsernamePasswordAuthenticationToken) authentication;
final String username = (String) auth.getPrincipal();
final String password = (String) auth.getCredentials();
final Optional<ApplicationUser> user = applicationUserRepository.findByUsername(username);
final Optional<ApplicationUserAuthentication> result = user.map(u -> {
if (passwordEncoder.matches(password, u.getPassword())) {
return new ApplicationUserAuthentication(username);
} else {
return null;
}
});
return result.orElseThrow(() -> new BadCredentialsException("illegal username or password"));
}
@Override
public boolean supports(final Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
|
ここまでのコード: a9478f869c84248cb7dcddff8d192878e0388810
認証処理の利用設定
さあ、認証の実装は行ったので、後はこの実装を使うように設定変更するだけだ。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
@Configuration
@RequiredArgsConstructor
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
private final ApplicationUserRepository applicationUserRepository;
@Override
protected void configure(final HttpSecurity http) throws Exception {
...
final PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
final AuthenticationProvider provider = new ApplicationUserAuthenticationProvider(passwordEncoder,
applicationUserRepository);
final AuthenticationManager manager = new ProviderManager(Arrays.asList(provider));
http.addFilter(new JWTAuthenticationFilter(manager));
}
}
|
ここまでのコード: 973f5f7a33b84ffbc2f9a069c0a9bd0b9393395c
http.addFilter()
で使用するフィルタを登録する、ってのがこのコードの本質。
そしてフィルタが利用する認証プロバイダ(を管理する認証マネージャ)をコンストラクタで指定してるってわけ。イージーだね!
当然だけどPasswordEncorder
は sign-up でユーザ登録したときのものと同じものを使わないと検証できないよ!
んで本題と関係ないけど Java わかってる感出すためにArrays.asList()
じゃなくて Java9 で導入されたList.of()
つこたろ、ってやったら流れるようにバグ踏んだ(#8689])ので皆もイキるときは気をつけよう。
もいっこあんまり関係ない話をすると、Filter を Bean 化するとちょっと困ったことになったりもした。
前述のPasswordEncoder
もそうだけど、よくわからんけど他人のコードコピペして Bean 化しました!ってやると予期しない範囲まで波及してしまうという、これも Spring Boot あるあるだね!
閑話休題。あとここで言っとくべきことは、フィルタの適用順って重要、ってことなんだけど、今回のサンプルではもう 1 個フィルタ追加するのでそんときに説明します。
さあさあ!ついに認証処理を通るリクエストが投げられるようになりましたよ!サインアップしてログインしてみよう!
1
2
3
4
|
curl -H "Content-Type: application/json" -X POST -d '{
"username": "suzuki",
"password": "password"
}' http://localhost:8080/users/sign-up
|
1
2
3
4
|
curl -i -H "Content-Type: application/json" -X POST -d '{
"username": "suzuki",
"password": "password"
}' http://localhost:8080/login
|
そうするとログイン成功してこんな感じのヘッダが付いて返ってくるはず。Bearer トークンてやつだね!
(今回説明した事の本質からは逸れてるのであんまり触れないけど、これは JWTAuthenticationFilter
が認証が正常に終了した後にsuccessfulAuthentication
で生成してるので気になる人はそこを見てね!)
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzdXp1a2kiLCJleHAiOjE1OTI4OTM3MDN9.ul4oibmjgOMZPoyqu6NqMENIRmoQ92Ht8WsDFr9UupsUo_FeJH4pCwzAa8RP3XNPojYxaJjjq6u91HKJuraz1g
次はこのトークンを使えば保護されたリソースへアクセスできる、ようにする実装だ。
認可フィルタの実装と適用
認可フィルタ
上で登場した Bearer トークンの使い方を先に書いとくと、
curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzdXp1a2kiLCJleHAiOjE1OTI4OTM3MDN9.ul4oibmjgOMZPoyqu6NqMENIRmoQ92Ht8WsDFr9UupsUo_FeJH4pCwzAa8RP3XNPojYxaJjjq6u91HKJuraz1g" \
http://localhost:8080/users
みたいにヘッダにつけて保護されたリソースを要求すると、サーバは、「おうおう、あんたなら見せてやれるよ」って言ってくれるわけね。
ただ現時点ではそんな実装してないので上のリクエスト投げても敢え無く403
になるわけなのよ。それを何とかするのが 2 つめのフィルタ。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
package com.example.springbootauthexample202006.security;
import static com.example.springbootauthexample202006.security.SecurityConstants.HEADER_STRING;
import static com.example.springbootauthexample202006.security.SecurityConstants.SECRET;
import static com.example.springbootauthexample202006.security.SecurityConstants.TOKEN_PREFIX;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
public class JWTAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(final HttpServletRequest req,
final HttpServletResponse res,
final FilterChain chain) throws IOException, ServletException {
final String header = req.getHeader(HEADER_STRING);
if (header == null || !header.startsWith(TOKEN_PREFIX)) {
chain.doFilter(req, res);
return;
}
final ApplicationUserAuthentication authentication = getAuthentication(req);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(req, res);
}
private ApplicationUserAuthentication getAuthentication(final HttpServletRequest request) {
final String token = request.getHeader(HEADER_STRING);
if (token != null) {
// parse the token.
final String username = JWT.require(Algorithm.HMAC512(SECRET.getBytes()))
.build()
.verify(token.replace(TOKEN_PREFIX, ""))
.getSubject();
if (username != null) {
return new ApplicationUserAuthentication(username);
}
return null;
}
return null;
}
}
|
ここまでのコード: 90c26b3dcf6e3ba52651f1ad00e9c8c52b0fd35a
ヘッダに設定されている Bearer トークンをデコードして、その結果から得られる情報をもとに Authentication
を生成しSecurityContextHolder.getContext().setAuthentication()
でセキュリティコンテキストへセットする、というのが日本語での簡単な説明。
ここでセットされたAuthentiction
のisAuthenticated()
がtrue
なので、SpringBoot 君は保護されたリソースへのアクセスを許してくれる。
認可フィルタの利用設定
フィルタの登録。基本は 1 つめのフィルタと同じだね。
1
2
3
4
5
6
7
8
9
10
|
@Configuration
@RequiredArgsConstructor
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(final HttpSecurity http) throws Exception {
...
http.addFilterAfter(new JWTAuthorizationFilter(), JWTAuthenticationFilter.class);
}
}
|
ここまでのコード: 881cf366e10ba61162470936e267cab6930a2e57
んで前に触れたフィルタの適用順の話。
フィルタが適用される順番はもちろん重要で、例えば今回のフィルタを例にとると、 http://localhost:8080/login にアクセスしたとき、JWTAuthorizationFilter
(Bearer トークンのデコード)よりJWTAuthenticationFilter
(Bearer トークンの生成)を優先してほしいわけですよ。
だってログインしようとしてるんだから Bearer トークン持ってるはずないじゃん。
なのに Bearer トークン要求されたらこれまた服を買いに行くための服以下略じゃないですか!
で、そのフィルタの順番なんですが、基本これ。
この票に登場するクラス、それを継承したクラスは、addFilter()
この表の順序に割り当てられる。
例えばJWTAuthenticationFilter
はUsernamePasswordAuthenticationFilter
を継承して作ってるので
http.addFilter(new JWTAuthenticationFilter(manager));
とするとUsernamePasswordAuthenticationFilter
のところに自動で割り当たる。
一方で、 JWTAuthorizationFilter
はこの表に登場しない OncePerRequestFilter
を継承して作っているので(※参考元コードとは異なります)、順序を明示的に教えてあげる必要がある。
なので、JWTAuthenticationFilter
の後にしてくれ
http.addFilterAfter(new JWTAuthorizationFilter(), JWTAuthenticationFilter.class);
ってやってるわけ。
完成
んじゃ実行してみましょうよ。
1
2
3
4
|
curl -H "Content-Type: application/json" -X POST -d '{
"username": "ito",
"password": "password"
}' http://localhost:8080/users/sign-up
|
1
2
3
4
|
curl -i -H "Content-Type: application/json" -X POST -d '{
"username": "ito",
"password": "password"
}' http://localhost:8080/login
|
1
2
|
curl -i -H "Authorization: Bearer <loginで取得したトークン文字列>" \
http://localhost:8080/users
|
いかがでしたか?