くちたと計算記

プログラミングのことを書きます

Spring Boot で REST API を実装するときに、認証だけ FormData を使うと Spring Security の設定で楽できる

まとめ

  1. UsernamePasswordAuthenticaitonFilter が FormData から認証情報を読み込むので、 それを仕様にしてしまったほうが実装が楽になる。
  2. UsernamePasswordAuthenticaitonFilter を使えないなら、 JSON から認証情報を読み込んでも実装コストとコード量はそんなに増えない。
  3. やっぱり DSL で設定を記述したい。

本文

Spring Security 標準の UsernamePasswordAuthenticationFilter は ログインフォームから入力されたユーザー名とパスワードを処理する仕様になっている。 REST API では認証情報を JSON 形式で送信したくなるが、その場合は JSON 形式のリクエストボディから認証情報を取り出す Filter を実装する必要がある。

class JsonUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        Map<String, String> content;
        try {
            content = objectMapper.readValue(request.getInputStream(), new TypeReference<>() {
            });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        String username = content.getOrDefault(getUsernameParameter(), "");
        String password = content.getOrDefault(getPasswordParameter(), "");
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
                password);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

そのうえ、 HttpSecurity#formLogin で設定している項目を Filter にセッターで設定する必要がある。

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, AuthenticationManager authenticationManager) throws Exception {
        JsonUsernamePasswordAuthenticationFilter filter = new JsonUsernamePasswordAuthenticationFilter();
        filter.setFilterProcessesUrl("/api/login");
        filter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            response.setStatus(HttpServletResponse.SC_OK);
        });
        filter.setAuthenticationFailureHandler((request, response, exception) -> {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        });
        filter.setAuthenticationManager(authenticationManager);
        return httpSecurity
                .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
                .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
                .csrf(csrf -> csrf
                                .ignoringRequestMatchers("/api/login")
                                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                )
                .build();
    }

    @Bean
    public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setPasswordEncoder(passwordEncoder);
        authenticationProvider.setUserDetailsService(userDetailsService);
        return new ProviderManager(authenticationProvider);
    }

    @Bean
    public InMemoryUserDetailsManager inMemoryUserDetailsManager(PasswordEncoder passwordEncoder) {
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        inMemoryUserDetailsManager.createUser(User.builder().username("user").password(passwordEncoder.encode("password")).roles("USER").build());
        return inMemoryUserDetailsManager;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

この方法は以下の理由で嫌だと感じた。

  • UsernamePasswordAuthenticationFilter の実装と HttpSecurity#formLogin のデフォルトの振る舞いを活用できないので、記述量が増える。
  • もともと DSL で設定できていたものをセッターでダラダラ設定するのが嫌。

そもそも認証フィルタをカスタマイズしないと要件を満たせないなら、 JSON から読み込ように設定することによる記述量の増加は気にならないとは思う。 それはそれとして、 DSL による設定のしやすさは捨てがたいので、 認証フィルタをカスタマイズするなら DSL まで実装したいと思った。