Spring Security の認可処理をテストしてみる

Spring Security のテストってどうやるのか調査してみました。

Spring Security ではテストもサポートされていて、Spring Security に関係するコードをJUnitなどで簡単にテストできるようになっています。今回は認可に関係する部分を探っていきたいなと思います。

参考となるリファレンスはこちら。

https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html

https://docs.spring.io/spring-security/site/docs/5.4.2/reference/html5/#test

Spring Test の使い方(特に MockMvc や WebTestClient)はあまり深追いできていないので、このあたりは後で調査したいですね。

この記事内で書いたコードはこちらのリポジトリにアップしてあります。

github.com

認可処理をテストする

Security Context にモックユーザをセットすることで、認証に関するテストを実施できるようです。Spring Security が認証認可を行う時、認証が最初に行われるわけですが、認証処理が終わるということは Security Context に Authenticationオブジェクトが存在する事になるわけですね。なので、テストではその状況を再現できればよいということになります。

f:id:bau1537:20210220130134p:plain

テスト内では MockMvc を使って保護されたエンドポイントへアクセスし、正しく認可が行われているかを検証できます。

では、早速コードを書いていきたいと思います。

保護するエンドポイントの用意

Configuration クラスはこちら。ユーザーを2名追加し、保護するエンド先斗を指定しました。パスワードエンコーダーはテスト用なのでハッシュ化しません。

package com.example.spring.security.test.config;

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 SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin();
        http.authorizeRequests()
                .mvcMatchers("/admin/**").hasRole("admin")
                .mvcMatchers("/users/**").hasAnyRole("admin", "staff")
                .anyRequest().authenticated();
    }

}

エンドポイントは以下のものを用意します。

package com.example.spring.security.test.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HomeController {

    @GetMapping("/admin")
    public String admin() {
        return "admin";
    }

    @GetMapping("/users")
    public String users() {
        return "users";
    }

    @GetMapping("/home")
    public String home() {
        return "home";
    }

}

では、これらのコードに対してテストコードを書いていきましょう。

テストコード

書いてみたテストコード全体はこんな感じ。

package com.example.spring.security.test;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
public class AuthorizationTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    public void accessWithNoAuthentication() throws Exception {
        mockMvc.perform(get("/home"))
                .andExpect(status().isFound());
    }

    @Test
    @WithMockUser
    public void accessWithUserHasNotRole() throws Exception {
        mockMvc.perform(get("/home"))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = "admin")
    public void accessWithUserHasAdminRole() throws Exception {
        mockMvc.perform(get("/admin"))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = "staff")
    public void accessWithUserHasStaffRole() throws Exception {
        mockMvc.perform(get("/users"))
                .andExpect(status().isOk());
    }

}

実行しましたがすべて合格しました。

ここで使われているのが @WithMockUser アノテーションになります。このアノテーションが何をしているのかというと、Security Context に Authentication を追加してからテストコードが実行されるようにしてくれます。便利ですね〜。

ちなみに引数で Authentication の属性を指定できるのですが、そのプロパティから分かる通りセットされる Authentication の実態は UsernamePasswordAuthenticationToken クラスです。このクラスはBasic認証やフォーム認証によってセットされるクラスになります。なので上記のテストコードのようにロールの他にもユーザー名やパスワードなどもセットすることができます。ちなみに、セットされていないプロパティは適当な値が入っています。

IntelliJデバッグ実行で、3つ目のテストを実行中に Authentication の実態を見てみたのがこちら。passwordなどは指定していませんが、空気を読んで適当な値をぶっこんでくれてます。

f:id:bau1537:20210218202928p:plain

注意しなくてはいけないのが、これは認証のテストになっているわけではないということですね。あたかも保護されたエンドポイントへアクセスできていますが、AuthenticationProvider など認証に関わるクラスは一切動いてません。そこを誤解してしまうとテストされない範囲が生まれてしまいます。

UserDetailsService をテストする

エンドポイントの認可の設定の他に、よくプロジェクトごとに設定、実装するのは UserDetailsService でしょうか。

Spring Security には UserDetailsService を含めた認可のテストを行うことができます。こうすることで認証処理を含めず、 UserDetailsService と認可処理のテストが実施できます。ただ、UserDetailsService を普通に単体テストいい気がしますがどうなんでしょう?

f:id:bau1537:20210220130606p:plain

先程の Configuration クラスに InMemoryUserDetailsManager を Bean 定義し、テストできるようにします。

package com.example.spring.security.test.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public UserDetailsService userDetailsService() {
        var userDetailsManager = new InMemoryUserDetailsManager();
        userDetailsManager.createUser(
                User.withUsername("alice")
                        .password("12345")
                        .roles("admin")
                        .build()
        );
        userDetailsManager.createUser(
                User.withUsername("bob")
                        .password("12345")
                        .roles("staff")
                        .build()
        );
        return userDetailsManager;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin();
        http.authorizeRequests()
                .mvcMatchers("/admin/**").hasRole("admin")
                .mvcMatchers("/users/**").hasAnyRole("admin", "staff")
                .anyRequest().authenticated();
    }

}

で、 @WithMockUser アノテーションを使い、テストコードを書いていきます。このアノテーションで指定されたユーザーが UserDetailsManager 経由で Authentication としてロードされるイメージですね。

    @Test
    @WithUserDetails("bob")
    public void accessWithBobToNoPermitPath() throws Exception {
       mockMvc.perform(get("/admin"))
               .andExpect(status().isForbidden());
    }

    @Test
    @WithUserDetails("alice")
    public void accessWithBobToPermitPath() throws Exception {
        mockMvc.perform(get("/admin"))
                .andExpect(status().isOk());
    }

もちろんこの場合でも Authentication は UsernamePasswordAuthenticationToken となっています。