arcanum_jp’s blog

おっさんの日記

SpringBootでWebAPI認証を学ぶのメモ、JWT

SpringBootでAPIを作成する際、認証などの仕組みが必要になります。そのため何がいいのか検索していたところ、JWTというのがある、とわかり、キータでサンプルを公開していた方がいたので、それをベースに勉強しました。


こちらを参考にしています。
qiita.com

サンプルソースGithubにあるようなのでこちらをクローンして確認してみます。
github.com

JWTは色々なサイトで書いている方がいますが、簡単にいうとトークンに署名がしてあり改ざんに強いとのことです。
webbibouroku.com

JWTはpom.xmlに以下の追加を行いますが、Javaの実行バージョンによりエラーになります。

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

※アクセス時、次のエラーが出る

java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:583) ~[na:na]
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178) ~[na:na]
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:521) ~[na:na]
	at io.jsonwebtoken.impl.Base64Codec.encode(Base64Codec.java:21) ~[jjwt-0.9.0.jar:0.9.0]
  ・・・省略

その場合は次をpom.xmlに追加します。Java9以上はこの追加でOKなようです。

<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
</dependency>

こちらに書いてありました。
github.com

実行すると確かに/user/login で認証が行われ、レスポンスヘッダにJWTのトークンが入ってきます。そのトークンを元に/privateにアクセスすると、トークンの情報からユーザーが特定され、ログに出力されます。ここまではあっというまにできました。

認証処理はどこでやるか

ここから作られたコードがどの順番で実行されるのだろうと疑問が浮かびます。エントリでは認証に関してはJWTAuthenticationFilterが責任を持つとありますが他はどのような順番なのでしょうか?ログはって追ってみたところ、以下の順番のようです。

①JWTAuthenticationFilter#attemptAuthentication( )が呼び出される
②UserDetailsServiceImpl#loadUserByUsername( )が呼び出される
③JWTAuthenticationFilter#successfulAuthentication( )が呼び出される


サンプルではあまり言及されていませんが、この流れの①が認証本体のようです。リクエストからID/PASSを取得していますので、本来はここでID/PASSの検証をするようです。検証がOKならAuthenticationオブジェクトを返します。この部分ですね。

return authenticationManager.authenticate(
    new UsernamePasswordAuthenticationToken(
        userForm.getLoginId(),
        userForm.getPass(),
        new ArrayList<>())

ただ、サンプルではここでUsernamePasswordAuthenticationTokenのコンストラクタ第3引数に配列0のオブジェクトを入れていますが、第3引数はここでは指定しなくとも良いようです。

はじめ、各ユーザーが持つロールの配列(List<GrantedAuthority>)を入れるタイミングがサンプルでは2箇所あり、どこで入れるのが正しいのかなと思っていましたが、UserDetailsServiceImpl#loadUserByUsername( )で返すUserDetailsオブジェクトにセットして返すのが正解のようです。

①の処理が行われ、UsernamePasswordAuthenticationTokenオブジェクトをauthenticationManager.authenticate( )に渡した中で、②の処理が行われ、返却されたUserDetailsにセットされたList<GrantedAuthority>がAuthenticationにセットされ①の処理は完了(Authenticationを返す)、その後、③の処理の引数として引き渡されるようです。これは、①の処理でList<GrantedAuthority>をセット、②では空を返す、にすると③には引き渡されず、というので確認しています。また②の処理でList<GrantedAuthority>がnullで返すとエラーになります。

また、この流れの中でユーザが検索できないなどのエラーの場合は素直にUsernameNotFoundExceptionオブジェクトをスローすればレスポンスは403になります。ここは便利ですね。

認証処理のレスポンスで任意の情報を含める

サンプルではトークンにユーザの情報としてuseridを入れていますが、他に任意の情報も含められます。実験としてロールも含めてみます。この方法が良いかは別として任意の情報としてロールをセットする方法を書いてみます。ロールは先ほどの流れの中でAuthenticationオブジェクトにListとして含まれていますが、これをレスポンスのクレームに入れてみます。

String token = Jwts.builder()
    .setSubject(((User)auth.getPrincipal()).getUsername()) // usernameだけを設定する
   .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
   .signWith(SignatureAlgorithm.HS512, SECRET.getBytes())
   .claim("role", auth.getAuthorities())        // <---------------------ここに1行足した
   .compact();

サンプルでも言及されていますがauth.getPrincipal( )は先ほどの②の処理で返したUserDetailsそのものなので、こちらにも同様の情報があるため以下のようにも書き直すことができます。

User user = (User)auth.getPrincipal();
    String token = Jwts.builder()
    .setSubject(user.getUsername()) // usernameだけを設定する
    .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
    .signWith(SignatureAlgorithm.HS512, SECRET.getBytes())
    .claim("role", user.getAuthorities())
    .compact();


この追加のあと/user/loginにアクセスしてできたトークンは以下になります。

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJueWFzYmEiLCJleHAiOjE1NTg2ODM1NjUsInJvbGUiOlt7ImF1dGhvcml0eSI6IlJPTEVfQURNSU4ifSx7ImF1dGhvcml0eSI6IlJPTEVfVVNFUiJ9XX0.2NYpAF6BH1Pkskj2MYTKCsPABRozFlm-IDxaBSnVI6naJXHv53OgDbkvpwljtTQzK7fbzqqeS_fVJ5EtsF9jJQ

これを以下のサイトでデコードしてみます。
jwt.io


デコードしてみるとロールが追加されていますね。通常であればこんな大事な情報はすぐデコードできるような部分には追加しないと思いますが、例ということで・・・
f:id:arcanum_jp:20190524084050p:plain

トークンから任意のクレームを取り出す

今度はヘッダにトークンを追加されてきたときの認証部分です。ソースのJWTAuthorizationFilterというフィルタ処理がそうで、このクラスのgetAuthentication( )でクレームからの情報取得をしています。

この処理を以下のように書き換えました。

token = token.replace(HEADER_STRING, "");
token = token.replace(TOKEN_PREFIX, "").trim();

JwtParser parser = Jwts.parser().setSigningKey(SECRET.getBytes());
Claims claims = parser.parseClaimsJws(token).getBody();
        	
String user = claims.getSubject();
        	
List grants = (List) claims.get("role");
String[] arrayRole = new String[grants.size()];
for (int i = 0 ; i < grants.size(); i++) {
    LinkedHashMap grant = (LinkedHashMap) grants.get(i);
    String rolestr = (String) grant.get("authority");
    arrayRole[i] = rolestr;
}
List<GrantedAuthority> roles = AuthorityUtils.createAuthorityList(arrayRole);

if (user != null) {
    return new UsernamePasswordAuthenticationToken(user, null, roles);
}


あと、SampleController#privateApi( )も以下のようにトークンに入れた情報がわかるように修正します。

@GetMapping(value = "/private")
public String privateApi() {

    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    // JWTAuthenticationFilter#successfulAuthenticationで設定したusernameを取り出す
    String username = (String) (authentication.getPrincipal());

    return "this is private for " + username + " auth: " + authentication.getAuthorities();     //  <------修正
}


これをサンプルの通りにトークンをヘッダに入れて実行してみます。

curl -X GET -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJueWFzYmEiLCJleHAiOjE1NTg2ODM1NjUsInJvbGUiOlt7ImF1dGhvcml0eSI6IlJPTEVfQURNSU4ifSx7ImF1dGhvcml0eSI6IlJPTEVfVVNFUiJ9XX0.2NYpAF6BH1Pkskj2MYTKCsPABRozFlm-IDxaBSnVI6naJXHv53OgDbkvpwljtTQzK7fbzqqeS_fVJ5EtsF9jJQ" "http://localhost:8080/private"

こんな風に取得できました。トークンに任意の情報(この場合ロールの名前ですが)を入れてまたデコードすることが確認できました。

this is private for nyasba auth: [ROLE_ADMIN, ROLE_USER]

ロールによるURLの制限

さて、ここでトークンを介したロールの保存と複合ができたので、ユーザーにおけるロールによるURLの制限確認をしておわります。以下の修正をして確認します。/user/myurlは”ROLE_USER”、”ROLE_ADMIN”がアクセス可能、/adminは”ROLE_ADMIN”がアクセス可能という前提です。


SampleController.java に以下のパスを追加します。

@GetMapping(value = "/user/myurl")
public void myurl(@Valid @RequestBody UserForm user) {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    String username = (String) (authentication.getPrincipal());
    return "this is /user/myurl for " + username + " auth: " + authentication.getAuthorities();
}

@GetMapping(value = "/admin")
public void admin(@Valid @RequestBody UserForm user) {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    String username = (String) (authentication.getPrincipal());
    return "this is /admin for " + username + " auth: " + authentication.getAuthorities();
}

WebSecurityConfig.java に以下の修正をします。

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .cors()
        .and().authorizeRequests()
        .antMatchers("/public", SIGNUP_URL, LOGIN_URL).permitAll()
        .antMatchers("/user/myurl").hasAnyRole("USER", "ADMIN")        //  <---- 追加
        .antMatchers("/admin").hasAnyRole("ADMIN")                            //  <---- 追加
        .anyRequest().authenticated()
        .and().logout()
        .and().csrf().disable()
        .addFilter(new JWTAuthenticationFilter(authenticationManager(), bCryptPasswordEncoder()))
        .addFilter(new JWTAuthorizationFilter(authenticationManager()))
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}


さて、実行すると、トークンに入ったロールによってアクセス制限ができることが確認できました。

今回サンプルとした元ソースをForkして修正したものがこちらです。
※元リポジトリはGradleでしたがこちらではMavenに変更しています。またその際に発生したエラーの影響でテストは削除しています。
github.com