kagamihogeの日記

kagamihogeの日記です。

spring-securityでログイン画面無しの認証

spring securityで独自の認証を実装する | エンジニアっぽいことを書くブログで紹介されているようにAbstractPreAuthenticatedProcessingFilterが使える。このクラスは、javadocによると「外部の認証システムがヘッダーやcookieなどのリクエスト経由で認証情報を渡し、それからpre-authenticationがその情報を基に、自システム固有のユーザ情報を取得する」といった役割を持つ。

ログイン画面が無い場合のよくある方法としては、認証トークンなどをヘッダーやcookieに載せる。それを受け取って、認証トークンの検証をしたり、DBから追加のユーザ情報を取得したり、といった処理をする。以下はそれをspring-securityで実装する方法について述べる。

ソースコード

build.gradle

plugins {
  id 'org.springframework.boot' version '2.3.2.RELEASE'
  id 'io.spring.dependency-management' version '1.0.9.RELEASE'
  id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}
repositories {
  mavenCentral()
}
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-security'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation('org.springframework.boot:spring-boot-starter-test') {
    exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
  }
  testImplementation 'org.springframework.security:spring-security-test'
}
test {
  useJUnitPlatform()
}

AbstractPreAuthenticatedProcessingFilter

AbstractPreAuthenticatedProcessingFilterはリクエストからprincipalとcredentialを取得するabstractメソッドの実装が必須となる。これらはヘッダーなりcookieなりから取得する。以下はサンプルとして単純にリクエストパラメータから取得している。

   static class TokenPreAuthenticatedProcessingFilter extends AbstractPreAuthenticatedProcessingFilter {
        @Override
        protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
            return request.getParameter("token");
        }

        @Override
        protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
            return "";
        }
    }

上記メソッドで認証情報取得後、基本的には、PreAuthenticatedAuthenticationProviderに委譲する。後述するが、このプロバイダには認証情報が渡されるので、トークンの検証をしたり、自システムのユーザ情報などの取得をする。

また、一旦プロバイダの認証処理が通過すると、フィルタの以上の処理はスキップするようになる。

PreAuthenticatedAuthenticationProviderとAuthenticationUserDetailsService

上述の通り、プロバイダは認証情報を基にユーザ情報などの取得をする。このプロバイダにユーザ情報取得処理を行うAuthenticationUserDetailsServiceを設定する。このインタフェースは、ユーザ情報に相当するUserDetailsを返すloadUserDetails(PreAuthenticatedAuthenticationToken token)メソッドを持つ。

   @Bean
    PreAuthenticatedAuthenticationProvider tokenProvider() {
        PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
        provider.setPreAuthenticatedUserDetailsService((PreAuthenticatedAuthenticationToken token) -> {
            System.out.println("Principal:" + token.getPrincipal());
            System.out.println("Credential:" + token.getCredentials());
            // ここでトークン(token.getPrincipal())を使用し、ユーザ情報をどこかからか取得する。
            
            User user = new User("hoge", "", Collections.emptyList());
            return user;
        });
        return provider;
    }

上記コードでは具体的なユーザ情報取得処理はなんも書いてない。が、本来はDBなり認証サーバなりでユーザ情報を取得する。必要に応じて認可情報も取得してUserに設定する。上記ではspring-securityのorg.springframework.security.core.userdetails.Userにしているが、必要に応じて拡張が必要になるケースもあると思う。

実行

適当なrestcontrollerを用意して実行してみる。

@RestController
public class MyRestController {

    @GetMapping("/hoge")
    public String hoge() {
        System.out.println("/hoge");
        return "hoge";
    }
}
Principal:hogeToken
Credential:
/hoge
/hoge

といった感じでpre-authenticationフィルタは最初のみ通過しているのがわかる。

ソースコード全体

上記をすべてのっけたconfigのコード全体。説明のためbeanをぜんぶ単一クラスにまとめたが、実際には必要に応じてクラスに分割すると思われる。

Spring Security 5.4より前

import java.util.Collections;

import javax.servlet.http.HttpServletRequest;

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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        AbstractPreAuthenticatedProcessingFilter tokenFilter = new TokenPreAuthenticatedProcessingFilter();
        tokenFilter.setAuthenticationManager(authenticationManager());
        //WebSecurityConfigurerAdapterのデフォルトのauthenticationManagerを設定している。
        //このクラスはProviderManagerで、認証処理をAuthenticationProviderのリストに委譲する。
        
        http
            .authorizeRequests()
            .anyRequest()
            .authenticated()
            .and()
            .addFilter(tokenFilter)
        ;
    }
    
    static class TokenPreAuthenticatedProcessingFilter extends AbstractPreAuthenticatedProcessingFilter {
        @Override
        protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
            return request.getParameter("token");
        }

        @Override
        protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
            return "";
        }
    }

    @Bean
    PreAuthenticatedAuthenticationProvider tokenProvider() {
        PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
        provider.setPreAuthenticatedUserDetailsService((PreAuthenticatedAuthenticationToken token) -> {
            System.out.println("Principal:" + token.getPrincipal());
            System.out.println("credential:" + token.getCredentials());
            // ここでトークン(token.getPrincipal())を使用し、ユーザ情報をどこかからか取得する。
            
            User user = new User("hoge", "", Collections.emptyList());
            return user;
        });
        return provider;
    }
}

Spring Security 5.4-6.0以降

spring-security 6.0以降はAuthenticationManagerを直接参照は出来なくなったため、configurerを経由して設定する。filterを追加するのではなく、filterを追加する設定クラスを適用する、というイメージ。詳細は https://stackoverflow.com/questions/71281032/spring-security-exposing-authenticationmanager-without-websecurityconfigureradap を参照。該当リンクにconfigurerを使わないやり方、たとえばAuthenticationManagerを一旦beanとして公開する、といったやり方も書かれている。

import java.util.Collections;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;

import jakarta.servlet.http.HttpServletRequest;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(authz -> 
        authz.anyRequest().authenticated())
      .apply(new TokenAuthenticationConfigurer()); // filter追加ではなくconfiguerの適用
    return http.build();
  }

  static class TokenAuthenticationConfigurer
      extends AbstractHttpConfigurer<TokenAuthenticationConfigurer, HttpSecurity> {
    @Override
    public void configure(HttpSecurity http) throws Exception {
      // authenticationManagerのインスタンスを取得してfilterに設定する
      final AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
      AbstractPreAuthenticatedProcessingFilter tokenFilter = new TokenPreAuthenticatedProcessingFilter();
      tokenFilter.setAuthenticationManager(authenticationManager);
      http.addFilter(tokenFilter);
      super.configure(http);
    }
  }

  static class TokenPreAuthenticatedProcessingFilter extends AbstractPreAuthenticatedProcessingFilter {
    @Override
    protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
      return request.getParameter("token");
    }

    @Override
    protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
      return "";
    }
  }

  @Bean
  PreAuthenticatedAuthenticationProvider tokenProvider() {
    PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
    provider.setPreAuthenticatedUserDetailsService((PreAuthenticatedAuthenticationToken token) -> {
      System.out.println("Principal:" + token.getPrincipal());
      System.out.println("Credential:" + token.getCredentials());
      // ここでトークン(token.getPrincipal())を使用し、ユーザ情報をどこかからか取得する。

      User user = new User("hoge", "", Collections.emptyList());
      return user;
    });
    return provider;
  }
}

参考URL

このエントリでは最低限の認証以外の細かい点は省略している。実際の使用に際しては本家ドキュメントや下記参考URLの参照が必要になると思われる。