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の参照が必要になると思われる。

Spring Batch 4.2でメトリクスをPrometheus Pushgatewayにおくる

https://docs.spring.io/spring-batch/docs/current/reference/html/monitoring-and-metrics.html を試す。

Spring Batch 4.2はMicrometerベースのメトリクスを自動的に収集する。なので、プロパティでそのメトリクスの送信設定をすれば、データがそちらに送られる。

ここではPrometheus Pushgatewayにメトリクスを送信する。

Prometheus Pushgatewayの準備

dockerで起動する。

docker run -d -p 9091:9091 prom/pushgateway

起動するとhttp://localhost:9091/#でpushgatewayの画面が見れる。

コード

build.gradle。spring-batchに加えて、actuator, prometheus, pushgatewayクライアントの依存性を入れる。

plugins {
  id 'org.springframework.boot' version '2.3.0.RELEASE'
  id 'io.spring.dependency-management' version '1.0.9.RELEASE'
  id 'java'
}

configurations {
  developmentOnly
  runtimeClasspath {
    extendsFrom developmentOnly
  }
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-batch'
  implementation 'org.springframework.boot:spring-boot-starter-actuator'
  implementation 'io.micrometer:micrometer-registry-prometheus'
  implementation 'io.prometheus:simpleclient_pushgateway'

  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation('org.springframework.boot:spring-boot-starter-test') {
    exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
  }
  testImplementation 'org.springframework.batch:spring-batch-test'
  implementation 'com.h2database:h2'
}

test {
  useJUnitPlatform()
}

src/main/resources/application.propertiesでpushgateway関連の設定をする。とりあえず動いた、というレベルなので設定の詳細までは良く調べていない。

management.metrics.export.prometheus.pushgateway.enabled=true
management.metrics.export.prometheus.pushgateway.grouping-key.app_name=demo-batch-hoge
management.metrics.export.prometheus.pushgateway.job=demo-batch
management.metrics.export.prometheus.pushgateway.shutdown-operation=push

適当にspring-batchのアプリを作る。

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@EnableBatchProcessing
@SpringBootApplication
public class Main {

  @Bean
  public Job job(JobBuilderFactory jobs, @Qualifier("myjobstep1") Step s1) {
    return jobs.get("demo-batch-job").incrementer(new RunIdIncrementer()).start(s1).build();
  }

  @Bean(name = "myjobstep1")
  public Step step1(StepBuilderFactory steps) {
    return steps.get("myjobstep1").tasklet((contribution, chunkContext) -> {
      System.out.println("hoge");
      return RepeatStatus.FINISHED;
    }).build();
  }

  public static void main(String[] args) {
    SpringApplication.run(Main.class, args);
  }
}

実行後にhttp://localhost:9091/#を見ると以下のようなメトリクスが確認できる。

f:id:kagamihoge:20200528161608j:plain

参考文献

Spring Cloud OpenFeignさわる

https://spring.io/projects/spring-cloud-openfeign をさわる。

FeignというRESTクライアントとspringをいい感じに連携する、という代物らしい。

使ってみる

Spring Initializrでプロジェクトを作る。dependencyにopenfeignをいれる。あと、今回はレスポンスがxmlなのでそのための依存性も追加する。

plugins {
  id 'org.springframework.boot' version '2.2.7.RELEASE'
  id 'io.spring.dependency-management' version '1.0.9.RELEASE'
  id 'java'
}

sourceCompatibility = '11'
configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}
repositories {
  mavenCentral()
}
ext {
  set('springCloudVersion', "Hoxton.SR4")
}
dependencies {
  implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
  implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml'
  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation('org.springframework.boot:spring-boot-starter-test') {
    exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
  }
}
dependencyManagement {
  imports {
    mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
  }
}
test {
  useJUnitPlatform()
}

Feignのインタフェースとかmainとかを作る。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@SpringBootApplication
@EnableFeignClients
public class Application implements CommandLineRunner {
  @Autowired
  NicoThumbinfoClient nicoThumbinfoClient;

  @FeignClient(value = "zip", url = "${niconico.url}", configuration = CustomConfigration.class)
  static interface NicoThumbinfoClient {
    @RequestMapping(method = RequestMethod.GET, value = "/api/getthumbinfo/{sm}")
    public NicovideoThumbResponse get(@PathVariable("sm") String sm);
  }

  @Override
  public void run(String... args) throws Exception {
    System.out.println(nicoThumbinfoClient.get("sm9"));
  }

  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }

}

Feignは、上記のように、interfaceでクライアントを定義する。valueは適当なID、urlは後述のプロパティファイルで定義(直書きでも良い)、configurationxmlのデコード設定でこれも後述。@RequestMappingとか@PathVariableとかのノリは今までのspringと変わらない。

挙動確認のAPIニコニコ動画APIを使わせて頂いた。

src/main/resources/application.propertiesはこんな感じ。

niconico.url=ext.nicovideo.jp

以下はxmlをデコードするため設定クラス。

import org.springframework.beans.factory.ObjectFactory;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.cloud.openfeign.support.ResponseEntityDecoder;
import org.springframework.cloud.openfeign.support.SpringDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter;

import feign.codec.Decoder;

@Configuration
public class CustomConfigration {
  @Bean
  public Decoder feignDecoder() {
    MappingJackson2XmlHttpMessageConverter c = new MappingJackson2XmlHttpMessageConverter();
    ObjectFactory<HttpMessageConverters> objectFactory = () -> new HttpMessageConverters(c);
    return new ResponseEntityDecoder(new SpringDecoder(objectFactory));
  }
}

これも今までのspringと同じで適当なconverterを追加してやればよい。

最後に、結果格納用のクラス。

import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;

import lombok.Data;

@Data
public class NicovideoThumbResponse {
  Thumb thumb;

  @Data
  public static class Thumb {
    @JacksonXmlProperty(localName = "video_id")
    String videoId;
    String title;
  }
}

これを実行すると、以下のように表示される。

NicovideoThumbResponse(thumb=NicovideoThumbResponse.Thumb(videoId=sm9, title=新・豪血寺一族 -煩悩解放 - レッツゴー!陰陽師))