So many UserDetailsService samples considered harmful for beginners
   17 min read

やっぱり一発目の 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.xmldependenciesに追加します:

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 内の記事で見てみよう…というわけで根気が途切れるまで新着順で検索してみた:

(あんま時間かけて見てないので分類間違い御免)

…どちゃくそ多いという程ではなかった。でも第 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()でセキュリティコンテキストへセットする、というのが日本語での簡単な説明。

ここでセットされたAuthentictionisAuthenticated()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()この表の順序に割り当てられる。
例えばJWTAuthenticationFilterUsernamePasswordAuthenticationFilterを継承して作ってるので

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

いかがでしたか?