無料で使えるプライベート Git リポジトリを共有する

概要

Google Cloud Platform のサービスの一つである Cloud Source Repositories を使って、プライベート Git リポジトリを共有する方法です。Cloud Source Repositories には、リポジトリ数制限の無い無料枠があります。この無料枠を使ってプライベート Git リポジトリを共有します。

目次

確認環境

  • 2018.11.07 現在の Google Cloud Platform

参考情報

解説

Cloud Source Repositories の無料枠

Cloud Source Repositories は、3 つの制限の範囲内では無料になります。

1 つめの制限はユーザー数です。5 ユーザーまでが無料になっています。より厳密に言うと、5 プロジェクトユーザーと書かれています。プロジェクト当たりではなく、あるユーザーが 2 プロジェクトでサービスを使用した場合は 2 プロジェクトユーザーとカウントする様です。ユーザー数のカウントは、ユーザーにアクセス権限を与えた時点で行われるのではなく、リポジトリにアクセスした時点で行われると書かれています。

残りの 2 つは、ストレージ容量の 50GB までという制限と、通信容量の下り 50GB/月 までという制限です。

リポジトリの作成

まずは、管理者権限を持つ Google アカウントでログインし、 Google Cloud Platform のページを開きます。そして、プロジェクトを選択します。(プライベートリポジトリなので諸々が黒塗りになっています。)

プロジェクトが存在しない場合は「新しいプロジェクト」を選択してプロジェクトを作成します。作成したプロジェクトが一覧に表示されますので、プロジェクトを選択します。

プロジェクトが選択された状態で(黒塗り部分)、ツールにある「Source Repositories」を選択します。そこからさらに、「リポジトリ」を選択します。(例では、ピン止めしているので Source Repositories が上にも表示されています。)

リポジトリの一覧が表示される画面になります。この画面で「リポジトリを作成」を選択し、指示に従ってリポジトリを作成します。作成が済むと例の様に一覧に表示されます。(例ではリポジトリを 2 つ作っています。)

アクセス権限の設定

アクセス権限は役割を設定することで行います。役割は複数のアクセス権限を集約しています。

ここでは、リポジトリにアクセスするために 2 つの役割を設定します。1 つはプロジェクトを閲覧する権限を持つ役割です。メンバーがプロジェクトを検索できる様にするために付与します。もう 1 つはリポジトリの操作権限を持つ役割で、読み込みのみか、書き込みも行うかで異なる役割を付与します。

役割の設定は IAM から行います。

IAM の設定画面で「追加」を選択してメンバーを追加します。メンバーに役割を設定して追加すると、一覧に役割が表示されます。

メンバーを追加するときには、プロジェクトに対する役割として閲覧者を選択します。

同様に、ソースに対する役割として Source Repository 読み取りか Source Repository 書き込みを選択します。詳しくは、役割と権限のマトリックス に書かれています。

リポジトリにアクセスする

ここからは追加したメンバーのアカウントでログインして操作します。

まず、プロジェクトを選択します。プロジェクト一覧に表示されない場合には、検索します。プロジェクトに対して閲覧者の役割が与えられていれば表示されます。

プロジェクトが選択された状態で、サービスの一覧から Source Repositories を選択します。そこから、リポジトリを選択します。

リポジトリの一覧が表示されます。一覧の中から、ソースコードを取得したいリポジトリのクローンを選択します。

そうするとリポジトリをクローンするための手順が表示されます。手順に従って行えばクローンされます。デフォルトは Google Cloud SDK を使った手順が表示されますが、Google Cloud SDK をインストールしたくない場合には「手動で生成した認証情報」を選択します。

RecyclerView の各項目で、スライドしてメニューを表示する

概要

以下の動画の様に、RecyclerView の各項目で、スライドしたときにメニューを表示させる方法です。他のライブラリを導入せずに、ItemTouchHelper クラスを使って実現します。

サンプルでは削除ボタンしかありませんが、複数のボタンを設定できます。また、ドラッグ&ドロップにより、項目の入れ替えができます。

目次

確認環境

  • Kotlin 1.2.71
  • AndroidStudio 3.2.1
  • minSdkVersion 16
  • compileSdkVersion 28

参考情報

解説

RecyclerView の設定

まず、RecyclerView と RecyclerView.Adapter を用意します。注意するポイントだけ記載しておきます。

  • Recycler View を定義した Activity のレイアウト XML
    • RecyclerView の id を content にしている。
  • ViewHolder が保持する View のレイアウト XML
    • background の View (ConstraintLayout) の id を background に、foreground の View (CardView) の id を foreground にしている。
    • ConstraintLayout を使って foreground と background を重ねて表示している。
      • ConstraintLayout を使わなくても重ねて表示できれば他のレイアウトでも構わない。
      • foreground を上に表示するため、foreground をファイル内の下方で定義している。
    • View の高さは background に合わせている。
      • foreground に合わせることもできる。(詳しくはソースコード内のコメントを参照)
      • ConstraintLayout における android:layout_height="0dp" は match_constraint を意味する。
  • Activity のコード
    • Kotlin Android Extensions の View Binding によって、RecyclerView が変数 content に 設定されている。
    • ItemTouchHelper と RecyclerView を結びつけている (ItemMotionHandler は後述)
  • RecyclerView.Adapter のコード
    • ViewHolder から Adapter のプロパティを参照するために、ViewHolder は inner class にしている。
    • ItemMotionHandler (後述) を使うために、LayeredViewHolder を継承している。
      • LayeredViewHolder の抽象プロパティである foreground, background を実装している。
    • ViewHolder では、Kotlin Android Extensions の View Binding によって、変数 textView, buttonDelete などに各 View を設定している。

ItemMotionHandler

今回のサンプルの肝は ItemMotionHandler.kt です。 ItemTouchHelper.Callback を実装したクラスになっています。

移動中の表示処理

特に重要な部分は onChildDraw メソッドのオーバーライドです。各項目を操作(上下移動、左右移動)したときの表示を制御しています。制御の切り分けは、dX の値によって行っています。dX は、移動していない基準の位置からの移動量です。

dX が 0 になるのは、横移動が完了した時と、縦移動をしている最中です。これらの場合には、onDraw に onChildDraw の引数の値をそのまま渡すことで、ItemTouchHelper の標準動作を行っています。

右方向に動かす場合は、dX > 0 になります。この場合、background は移動せず、foreground だけを移動します。そのため、onDraw の x が異なっています。(dY は 0 になるので同じ)。foreground の x は background の横幅を超えない様になっています。

左方向に動かす場合は、dX < 0 になります。左に動かす必要があるのは、メニューが開いている項目だけです。mLockedForeground には、メニューが開いている項目の foregroud の View が設定されています。foreground の View を比較することでメニューが開いている項目かを判定し、移動が不要であればメソッドを終了します。左に移動させる場合には、foreground だけを移動させます。そのため、右方向同様に onDraw の引数が異なっています。x の範囲を background の範囲を超えない様にする点も同様です。

但し、ここで一つ注意があります。左方向に移動したときに dX < 0 となるのは、onSwiped メソッド内で mItemTouchHelper.onChildViewDetachedFromWindow を行っているためです。この処理が無い場合、左方向の移動の場合でも dX > 0 になります。このあたりの理由は、onChildDraw の長いコメントを参照してください。

メニュー表示の解除

mLockedForeground には、メニューが表示されている項目の foreground の View が設定されています。これを使って、メニュー表示の解除を実現しています。

他の項目のメニューを表示する場合、項目を上下方向に移動する場合、メニューを閉じる場合には mLockedForeground に null を設定して、表示を基準の位置に戻します。これらは、unlockForeground メソッドの呼び出しや、onSwiped メソッド内の direction が ItemTouchHelper.START の場合の処理で実現しています。他の項目のメニューを表示する場合と項目を上下方向に移動する場合は、強制的に表示を元に戻すために clearView メソッドを呼び出しています。onSwiped メソッドが呼び出された場合には表示位置が基準の位置に戻っているので、clearView の呼び出しはしていません。

foreground.addOnLayoutChangeListener(mUnlockForeground) は、RecyclerView をスクロールした時に foreground を元に戻すために行っています。設定しないと、foreground がリサイクルされた他の項目でメニューが表示されます。

ドラッグ&ドロップ

ドラッグの開始位置とドロップ位置を通知する仕組みを用意しています。onDrop プロパティにリスナーオブジェクトを設定するとドラッグ&ドロップの機能が有効になり、ドロップした時点でリスナーが呼び出されます。

onMove メソッドで notifyItemMoved メソッドを呼び出すと RecyclerView の項目が入れ替わります。onMove は項目の位置が一つ入れ替わる毎に呼び出されます。ドラッグの開始位置とドロップ位置を取得したい場合には、不都合です。一方、onSelectedChanged メソッドは、上下左右の移動が開始/終了したタイミングで呼び出されます。actionState が 2 の場合は、上下移動の開始を意味しています。上下移動が開始した時点で mMoveFrom を設定し、移動終了 (actionState が 0 になった) タイミングで onDrag のリスナーを呼び出します。左右移動が終了した場合には、mMoveFrom が null になっているため onDrag は呼び出されません。

ItemMotionHandler の生成

ItemMotionHandler のインスタンスは newInstance メソッドで生成します。コンストラクタは使えません。

ItemMotionHandler は mItemTouchHelper.onChildViewDetachedFromWindow を行うために ItemTouchHelper のオブジェクトを保持する必要があります。ItemTouchHelper はコンストラクタに ItemTouchHelper.Callback (このサンプルでは ItemMotionHandler) を渡す必要があり、ItemMotionHandler のコンストラクタで ItemTouchHelper のオブジェクトを受け取ることができません。コンストラクタでの設定ができないため ItemTouchHelper を var にしたプロパティに設定しています。しかし、ItemTouchHelper を可変のままで public に公開するのは危ないため、プロパティ mItemTouchHelper は private var とし、newInstance メソッドで ItemTouchHelper と ItemMotionHandler を生成して関連づけています。ItemMotionHandler の生成は newInstance メソッドに限定したいため、ItemMotionHandler のコンストラクタは private にしています。

ItemTouchHelper の onChildViewDetachedFromWindow メソッドを呼び出す方法は、ItemTouchHelper の実装が変更された場合の影響を受けるため危険ではありますが、コード量をできる限り減らして実現する方法を模索した結果としての妥協案です。他にもっと良い実装がある場合には教えてください。

Android の ImageView.ScaleType

概要

ImageView.ScaleType を解説している記事は見かけますが、表で整理された内容を見かけないため整理します。

目次

参考情報

解説

ScaleType 縦横比 配置 はみ出し 隙間 サイズ
CENTER 維持 中央 両方向 元サイズ
CENTER_CROP 維持 中央 拡大のみ
CENTER_INSIDE 維持 中央 両方向 縮小のみ
FIT_CENTER 維持 中央 片方向 拡大/縮小
FIT_END 維持 右下 片方向 拡大/縮小
FIT_START 維持 左上 片方向 拡大/縮小
FIT_XY 無視 中央 拡大/縮小
MATRIX - - - - -

縦横比に注目すると、FIX_XY のみが縦横比を無視して拡大/縮小します。他は、縦横比を維持します。

配置は、基本的に中央寄せです。FIT_END と FIT_START のみが中央以外に寄せます。日本では、END が右下、START が左上です。日本以外では、START が右になる場合もあるので、表の内容は日本の場合です。

CENTER は、元サイズのままであるため、View に収まらない範囲は View からはみ出ますし、View の範囲よりも小さい場合には View の枠と画像の間に隙間ができます。隙間は、縦と横の両方向にできる場合があります。

CENTER_CROP は、はみ出した部分を切り取ります。View の範囲より小さい場合には拡大するため、View の枠と画像の間に隙間ができません。

CENTER_INSIDE は、はみ出さないように縮小します。View の範囲より小さい場合には元サイズのままとなるため、View の枠と画像の間に隙間ができます。隙間は、縦と横の両方向にできる場合があります。

FIT_CENTER は、はみ出さない範囲で拡大/縮小します。縦と横のいずれかの方向が View の枠の幅と同じになるまで拡大します。そのため、隙間は、片方向だけにできます。FIT_END、FIT_START も同様です。

Spring WebFlux で InMemoryWebSessionStore にセッションタイムアウトを設定する

概要

Spring WebFlux を使いながら application.properties で spring.session.timeout を設定した際、設定が効きませんでした。WebFlux の WebSessionStore をカスタマイズして、セッションタイムアウトの間隔を設定する方法を説明します。

目次

確認環境

  • Spring Boot 2.0.3.RELEASE
  • Kotlin 1.2.50

参考情報

解説

設定方法

以下の示す様に WebSessionManager に設定する WebSessionStore を変更します。

@EnableWebFluxSecurity
@EnableConfigurationProperties(SessionProperties::class)
@Configuration
class ExampleConfig {
    // ...

    @Bean
    fun webSessionManager(properties: SessionProperties): WebSessionManager {
        return DefaultWebSessionManager().apply {
            sessionIdResolver = HeaderWebSessionIdResolver().apply {
                headerName = "X-Sample" // セッションIDのヘッダー名を変更する場合には設定
            }
            sessionStore = CustomizableInMemoryWebSessionStore(properties)
        }
    }
}

WebSessionManager としては DefaultWebSessionManager を使います。DefaultWebSessionManager では、WebSessionIdResolver と WebSessionStore を指定します。デフォルトは、CookieWebSessionIdResolverInMemoryWebSessionStore です。

セッションタイムアウトには、WebSessionStore が関係しています。WebSessionStore がセッションの生成と破棄を行っています。デフォルトの InMemoryWebSessionStore では、InMemoryWebSession オブジェクトの maxIdleTime で有効期間の判定を行っています。デフォルトの maxIdleTime は 30 分になっています。

WebSessionStore に application.properties の設定内容を渡すために、EnableConfigurationProperties を使って SessionProperties を Bean として設定しています。これにより、webSessionManager メソッドの引数で SessionProperties Bean を注入できる様になります。SessionProperties は application.properties のうち、spring.session に含まれる設定内容を保持しています。InMemoryWebSessionStore 自体は SessionProperties を受け取らないため、InMemoryWebSessionStore を拡張したクラスのオブジェクトを使います。

本筋からは逸れますが、WebSessionIdResolver はリクエストとレスポンスにおいてセッションIDの取得と設定を行う処理を持ちます。HeaderWebSessionIdResolver を使えば、セッションIDを設定するヘッダーを変更できます。上記の例では、X-Sample ヘッダーにセッションIDを設定させています。

次に、InMemoryWebSessionStore を拡張した CustomizableInMemoryWebSessionStore です。

class CustomizableInMemoryWebSessionStore(
        private val properties: SessionProperties
) : InMemoryWebSessionStore() {

    override fun createWebSession(): Mono<WebSession> = super
            .createWebSession()
            .map(this::applySessionProperties)

    private fun applySessionProperties(session: WebSession) = session.apply {
        maxIdleTime = properties.timeout
    }
}

WebSession オブジェクトを生成する際に、maxIdleTime を application.properties の spring.session.timeout の値に変更しています。

Spring WebFlux で Spring Security と CORS WebFilter を併せて使う

概要

Spring WebFlux を使う際に、CORS を有効にする方法と認証/認可を有効にする方法を下記の記事で説明しました。しかし、この 2 つの内容を併せて適用すると、CORS が期待通りに機能しません。原因は、WebFilter の実行順にあります。この記事では、WebFilter の並び順を制御して、これらを併せて使う方法について説明します。

nosix.hatenablog.com

nosix.hatenablog.com

目次

確認環境

  • Spring Boot 2.0.3.RELEASE
  • Kotlin 1.2.50

参考情報

解説

問題

CorsFilter は SecurityWebFilterChain よりも前に適用される必要があります。CorsFilter には @Component を付けているため、@Order を付けて適用順を指定できます。@Bean にも @Order を付けることで適用順を指定できますが、@Bean が付けられた securityFilterChain メソッドに @Order を付けても期待通りの順番になりません。

FilterRegistrationBean を使うことで順番を指定する方法もあります。しかし、この方法では javax.servlet.Filter を参照します。WebFlux を使う場合には Servlet を使いません。そのため、WebFlux を使う場合には javax.servlet.Filter を参照できず、FilterRegistrationBean を使った順番の指定はできません。

2 つの WebFilter の適用順をどのように指定すればよいのでしょう?

調査

WebFilter の並び順を決定している部分をデバッガで追いました。並び替えているコードは、DefaultListableBeanFactory.java#L1204 です。さらに動きを追っていくと、OrderComparator.java で並び順を決めていることがわかります。

さらに、OrderComparator が CorsFilter と SecurityWebFilterChain を比較している動作を調べると、WebFluxSecurityConfiguration クラスの springSecurityWebFilterChainFilter メソッドで生成された Bean の Order が -100 に設定されていることが分かります。WebFluxSecurityConfiguration.java を読むと確かに Order が -100 に設定されています。

また、WebFluxSecurityConfiguration.java を読むと、SecurityWebFilterChain が WebFilterChainProxy に包含されていることが分かります。SecurityWebFilterChain を生成するメソッドに @Order を付けても無駄な理由はこれでした。

解決方法

WebFilterChainProxy Bean の Order が -100 になっていますので、CorsFilter Bean の Order を -100 よりも小さくすれば CorsFilter が先に適用されます。

@Component
@Order(-200)
class CorsFilter : WebFilter {
    // ...
}

Spring WebFlux で Spring Security の認証と認可を使う

概要

Spring Security を使う場合、Spring WebFlux と Spring MVC では仕組みが異なっています。このため、Spring MVC と併せて使う場合のカスタマイズ方法と Spring WebFlux と併せて使う場合のカスタマイズ方法も異なります。Spring Security を Spring WebFlux と併せて使う場合のカスタマイズ方法を少しだけ調べました。なお、コードは Kotlin で書かれています。

目次

確認環境

  • Spring Boot 2.0.3
  • Kotlin 1.2.50

参考情報

解説

仕組み

まずは Spring Security 5.0 解剖速報 の内容を見るのが良いと思います。Form-based Authentication (認証) に関わるクラスとインターフェースが整理されています。また、設定の方法も記載されており、それを参考にして書いた設定例が以下です。

@Configuration
@EnableWebFluxSecurity
class ExampleSecurityConfig {

    @Bean
    fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()

    /**
     * インメモリでユーザー情報を保持する場合の設定
     */
    @Bean
    fun userDetailsService(passwordEncoder: PasswordEncoder): ReactiveUserDetailsService {
        return MapReactiveUserDetailsService(
                User.withUsername("user")
                        .passwordEncoder(passwordEncoder::encode)
                        .password("password")
                        .roles("USER")
                        .build(),
                User.withUsername("admin")
                        .passwordEncoder(passwordEncoder::encode)
                        .password("admin")
                        .roles("USER", "ADMIN")
                        .build()
        )
    }

    @Bean
    fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
        http.authorizeExchange()
                .pathMatchers("/login").permitAll()
                .pathMatchers("/admin").hasRole("ADMIN")
                .anyExchange().authenticated()
        http.formLogin()
                .loginPage("/login")
        return http.build()
    }

withDefaultPasswordEncoder メソッドは非推奨になっているため、withUsername に変えています。withDefaultPasswordEncoder メソッドの実装を見ると PasswordEncoderFactories.createDelegatingPasswordEncoder() によってエンコーダーを取得しているので、同様にしています。

springSecurityFilterChain では WebFilter の登録を行っています。ServerHttpSecurity クラスの build メソッドを実行すると、AuthorizeExchangeSpec, FormLoginSpec, LogoutSpec, CsrfSpec などのクラスの configure メソッドが実行され、各 configure メソッドでは ServerHttpSecurity クラスの addFilterAt メソッドを実行して WebFilter を登録します。これらは、ServerHttpSecurity クラスのコード で確認できます。

例えば、authorizeExchange メソッドを実行すると、AuthorizeExchangeSpec オブジェクトが生成されます。AuthorizeExchangeSpec クラスの pathMatchers, anyExchange メソッドで設定を変更し、ServerHttpSecurity クラスの build メソッドを実行すると AuthorizeExchangeSpec クラスの configure メソッドが実行され、設定に応じた AuthorizationWebFilter が登録されます。

登録される WebFilter を整理すると以下の通りになっています。(一部です。詳しくは ServerHttpSecurity クラスのコード参照。)

No. 登録される WebFilter WebFilter を登録する Spec クラス Spec オブジェクトを生成するタイミング
1 SecurityContextServerWebExchange WebFilter - -
2 ExceptionTranslationWebFilter - -
3 AuthorizationWebFilter AuthorizeExchangeSpec authorizeExchange メソッド実行時
4 CsrfWebFilter CsrfSpec ServerHttpSecurity 初期化時
5 ServerRequestCacheWebFilter RequestCacheSpec ServerHttpSecurity 初期化時
6 AuthenticationWebFilter HttpBasicSpec httpBasic メソッド実行時
7 AuthenticationWebFilter FormLoginSpec formLogin メソッド実行時
8 HttpHeaderWriterWebFilter HeaderSpec ServerHttpSecurity 初期化時
9 LogoutWebFilter LogoutSpec logout メソッド実行時

(1) SecurityContextServerWebExchangeWebFilter は ServerHttpSecurity クラスの build メソッド実行時に登録されます。(2) ExceptionTranslationWebFilter は AuthorizeExchangeSpec オブジェクトが生成されている場合のみ登録されます。(3-9) これら以外の WebFilter は、各 Spec クラスの configure メソッドで登録されます。

上で示したとおり、各 Spec クラスのオブジェクトは、ServerHttpSecurity が初期化される時に生成される場合とメソッドを呼び出した時に生成される場合に分かれます。各 Spec オブジェクトは、各 Spec の disable メソッドで無効にできます。無効にした場合には、基本的には WebFilter が登録されません。但し、ServerRequestCacheWebFilter だけは例外で、無効にしても WebFilter 自体は登録されます。(機能としては無効になります。)AuthorizationWebFilter, AuthenticationWebFilter, LogoutWebFilter はデフォルトで無効であり、WebFilter は登録されません。CsrfWebFilter, ServerRequestCacheWebFilter, HttpHeaderWriterWebFilter はデフォルトで有効となるため、無効にしたい場合には以下のとおりにします。

http.csrf().disable()
http.headers().disable()
http.requestCache().disable()

各 Spec を使わずに、ServerHttpSecurity に直接 WebFilter を登録することもできます。

http.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION)

2 つめの引数は SecurityWebFiltersOrder、つまり WebFilter の適用順です。指定できる値としては以下が用意されています。各 WebFilter との対応も併せて示します。(WebFilter は一部のみ。)

SecurityWebFiltersOrder WebFilter
FIRST
HTTP_HEADERS_WRITER HttpHeaderWriterWebFilter
CSRF CsrfWebFilter
REACTOR_CONTEXT
HTTP_BASIC AuthenticationWebFilter
FORM_LOGIN AuthenticationWebFilter
AUTHENTICATION
LOGIN_PAGE_GENERATING
LOGOUT_PAGE_GENERATING
SECURITY_CONTEXT_SERVER_WEB_EXCHANGE SecurityContextServerWebExchange WebFilter
SERVER_REQUEST_CACHE ServerRequestCacheWebFilter
LOGOUT LogoutWebFilter
EXCEPTION_TRANSLATION ExceptionTranslationWebFilter
AUTHORIZATION AuthorizationWebFilter
LAST

カスタマイズ

様々な WebFilter を ServerHttpSecurity に登録することで認証や認可が行われています。カスタマイズするためには、これらの WebFilter の登録を有効/無効にしたり、WebFilter の挙動を変更すればよさそうです。実際に、カスタマイズを行っているサンプルでは、そのようにしています。

WebFilter の登録を無効にするには、既に述べたとおりに disable メソッドを使うか、Spec オブジェクトを生成するメソッドを実行しないようにします。有効にするには、その反対を行います。WebFilter の挙動を変更する方法としては、Spec の設定を変える方法と WebFilter を新規作成して登録する方法が考えられます。

Spec の設定を変える

例として、FormLoginSpec の設定を変更します。

@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    http.formLogin()
            .loginPage("/login")
            .requiresAuthenticationMatcher(ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login"))
            .authenticationEntryPoint(EntryPoint())
            .authenticationFailureHandler(FailureHandler())
            .authenticationSuccessHandler(SuccessHandler())
    return http.build()
}

formLogin メソッドは、FormLoginSpec オブジェクトを生成します。そして、生成されたオブジェクトの requiresAuthenticationMatcher, authenticationEntryPoint などのメソッドを使えば設定を変更できます。FormLoginSpec での基本的な設定項目は以下の 6 つです。デフォルトは、formLogin メソッドだけを実行した場合の設定です。

  • requiresAuthenticationMatcher
    • この Matcher でマッチした場合に認証処理を行う
    • デフォルトでは、/login に POST メソッドでリクエストした場合に認証処理を行う
  • authenticationEntryPoint
    • 認可で拒否された場合に実行される処理
    • デフォルトでは、RedirectServerAuthenticationEntryPoint("/login") であり、/login にリダイレクトする
    • AuthorizeExchangeSpec オブジェクトが生成されている場合、ここで設定した処理が ExceptionTranslationWebFilter に設定される
  • authenticationFailureHandler
    • 認証に失敗した場合に実行される処理
    • デフォルトでは、RedirectServerAuthenticationFailureHandler("/login?error") であり、/login にリダイレクトする
  • authenticationSuccessHandler
    • 認証に成功した場合に実行される処理
    • デフォルトでは、RedirectServerAuthenticationSuccessHandler("/") であり、/ にリダイレクトする
  • authenticationManager
  • securityContextRepository

loginPage メソッドは、これらの項目の一部をまとめて設定します。変数 loginPage は loginPage メソッドの引数です。

  • requiresAuthenticationMatcher
    • ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, loginPage)
  • authenticationEntryPoint
    • RedirectServerAuthenticationEntryPoint(loginPage)
  • authenticationFailureHandler
    • RedirectServerAuthenticationFailureHandler(loginPage + "?error")

authenticationEntryPoint が設定されていない場合には、変数 loginPage の値を "/login" として loginPage メソッドが実行されます。そのため、デフォルトが "/login" になっています。

AuthenticationWebFilter は、これら 6 項目の設定に基づいて生成されます。しかし、AuthenticationWebFilter のコードを確認すると、上記とは一部が異なる以下の 6 項目を設定します。

No. 設定項目 設定方法
1 AuthenticationManager ServerHttpSecurity
コンストラク
2 SecurityContextRepository ServerHttpSecurity ::
securityContextRepository
3 RequiresAuthenticationMatcher FormLoginSpec ::
requiresAuthenticationMatcher
4 ServerAuthenticationFailureHandler FormLoginSpec ::
authenticationFailureHandler
5 ServerAuthenticationSuccessHandler FormLoginSpec ::
authenticationSuccessHandler
6 AuthenticationConverter 設定不可

authenticationEntryPoint がなく、代わりに AuthenticationConverter があります。authenticationEntryPoint は ExceptionTranslationWebFilter に設定されるため、AuthenticationWebFilter の設定項目には含まれません。

(3-5) RequiresAuthenticationMatcher, ServerAuthenticationFailureHandler, ServerAuthenticationSuccessHandler は formLogin メソッドを使って設定した内容が適用されます。(1-2) また、AuthenticationManager, SecurityContextRepository は ServerHttpSecurity のコンストラクタやメソッドで設定可能です。(6) しかし、AuthenticationConverter は設定を変更できません。

AuthenticationConverter は認証に使用する username と password を抽出する処理を持つオブジェクトを設定します。formLogin の場合には、ServerFormLoginAuthenticationConverter オブジェクトが設定されており、FormData から username と password を抽出する処理になっています。AuthenticationConverter の設定を変えられないため、例えば、request body に JSON 形式で username と password を指定するといった事ができません。この例の方法を使いたい場合には、WebFilter を新規作成する必要があります。これは後ほど説明します。

WebFilter を新規作成して登録する

Spec の設定を変える方法では、AuthenticationConverter を変更できなかったり、各設定項目が複雑に絡み合ってカスタマイズが困難になるといった問題があります。この問題を解決するための一つの方法としては、自作 WebFilter を登録する方法があります。例えば、以下のとおりです。

@Configuration
@EnableWebFluxSecurity
class ExampleSecurityConfig {
    // ...

    private val loginPath = "/login"

    @Bean
    fun securityFilterChain(
            http: ServerHttpSecurity,
            authenticationManager: ReactiveAuthenticationManager,
            serverCodecConfigurer: ServerCodecConfigurer
    ): SecurityWebFilterChain {
        // ...

        // 認証(authentication)の設定
        val authenticationFilter = authenticationWebFilter(
                authenticationManager,
                serverCodecConfigurer,
                ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, loginPath)
        )
        http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)

        // 認可(authorization)で拒否した場合の応答
        http.exceptionHandling()
                .authenticationEntryPoint(EntryPoint())

        return http.build()
    }

    // @Bean は付けない (WebFilter が 2 重で登録されてしまう)
    fun authenticationWebFilter(
            authenticationManager: ReactiveAuthenticationManager,
            serverCodecConfigurer: ServerCodecConfigurer,
            loginPath: ServerWebExchangeMatcher
    ): WebFilter {
        return AuthenticationWebFilter(authenticationManager).apply {
            // 認証処理を行うリクエスト
            setRequiresAuthenticationMatcher(loginPath)
            // 認証処理における認証情報を抽出方法
            setAuthenticationConverter(JsonBodyAuthenticationConverter(serverCodecConfigurer.readers))
            // 認証成功/失敗時の処理
            setAuthenticationSuccessHandler(SuccessHandler())
            setAuthenticationFailureHandler(FailureHandler())
            // セキュリティコンテキストの保存方法
            setSecurityContextRepository(WebSessionServerSecurityContextRepository())
        }
    }

    // authenticationManager をカスタマイズしない場合には不要
    @Bean
    fun authenticationManager(
            userDetailsService: UserDetailsService,
            passwordEncoder: PasswordEncoder
    ): ReactiveAuthenticationManager {
        return UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService).apply {
            setPasswordEncoder(passwordEncoder) // 設定しない場合は DelegatingPasswordEncoder になる
        }
    }

    // ステータスコード FORBIDDEN、本文は空でレスポンスを返す
    inner class EntryPoint : ServerAuthenticationEntryPoint {
        override fun commence(exchange: ServerWebExchange, e: AuthenticationException): Mono<Void> {
            return Mono.fromRunnable {
                exchange.response.statusCode = HttpStatus.FORBIDDEN
            }
        }
    }

    // ステータスコード OK、本文は空でレスポンスを返す
    inner class SuccessHandler : ServerAuthenticationSuccessHandler {
        override fun onAuthenticationSuccess(
                webFilterExchange: WebFilterExchange,
                authentication: Authentication
        ): Mono<Void> = Mono.fromRunnable {
            webFilterExchange.exchange.response.statusCode = HttpStatus.OK
        }
    }

    // ステータスコード FORBIDDEN、本文は空でレスポンスを返す
    inner class FailureHandler : ServerAuthenticationFailureHandler {
        override fun onAuthenticationFailure(
                webFilterExchange: WebFilterExchange,
                exception: AuthenticationException
        ): Mono<Void> = Mono.fromRunnable {
            webFilterExchange.exchange.response.statusCode = HttpStatus.FORBIDDEN
        }
    }

    // 想定するリクエストの本文は { "mail_address": "user@example.com", "password": "123456" } といった JSON
    inner class JsonBodyAuthenticationConverter(
            val messageReaders: List<HttpMessageReader<*>>
    ) : Function<ServerWebExchange, Mono<Authentication>> {

        override fun apply(exchange: ServerWebExchange): Mono<Authentication> {
            return BodyExtractors.toMono(AuthenticationInfo::class.java)
                    .extract(exchange.request, object : BodyExtractor.Context {
                        override fun messageReaders(): List<HttpMessageReader<*>> = messageReaders
                        override fun serverResponse(): Optional<ServerHttpResponse> = Optional.of(exchange.response)
                        override fun hints(): Map<String, Any> = mapOf()
                    })
                    .map { it.toToken() }
        }
    }

    // 認証リクエスト本文の JSON
    data class AuthenticationInfo(
            @JsonProperty("mail_address")
            val mailAddress: String,
            val password: String
    ) {
        fun toToken() = UsernamePasswordAuthenticationToken(mailAddress, password)
    }

最も重要な部分は

http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)

です。この 1 文を実行するために、様々な処理を行っています。登録している AuthenticationWebFilter は "/login"

{ "mail_address": "user@example.com", "password": "123456" }

の様な JSON を POST メソッドで送信して認証します。認証に成功した場合はステータスコード OK を返し、失敗した場合は FORBIDDEN を返します。認可に失敗した場合は FORBIDDEN が返されます。

登録する AuthenticationWebFilter オブジェクトは authenticationManager メソッドで生成しています。authenticationManager メソッドに @Bean アノテーションを付けると SecurityFilterChain 中に 1 つ、後に 1 つ登録され、2 重登録されてしまいます。

authenticationManager メソッドは無くても構いませんが、カスタマイズする場合の例として記述してあります。passwordEncoder を Bean として設定している場合には注意が必要です。UserDetailsRepositoryReactiveAuthenticationManager に passwordEncoder を設定しておく必要があります。設定しない場合には、DelegatingPasswordEncoder が使われます。

また、AuthenticationWebFilter だけではなく、authenticationEntryPoint の設定をしておきます。authenticationEntryPoint には、認可に失敗した場合のレスポンスを設定しています。formLogin メソッドを使う場合には、AuthenticationWebFilter の登録と併せて行われていました。

Spring WebFlux で開発した REST API を Swagger Editor から実行するために CORS を有効にする

概要

Swagger Editor の Try it out から REST API を実行できる様にするために、Spring の設定を変更して CORS を有効にする方法を説明します。但し、Spring MVC ではなく、Spring WebFlux を使う場合の方法です。コードは Kotlin で書かれています。

目次

確認環境

  • Spring Boot 2.0.3
  • Kotlin 1.2.50

参考情報

解説

Swagger Editor で発生するエラー

Swagger Editor の Try it out から REST API を実行すると CORS に関するエラーが発生します。ブラウザの画面の Swagger Editor には、以下のエラーが表示されます。(読みやすくなるよう、途中に改行を入れています。)

Possible cross-origin (CORS) issue?
The URL origin (http://localhost:8080) does not match the page (http://editor.swagger.io).
Check the server returns the correct 'Access-Control-Allow-*' headers.

Spring のデフォルト設定では CORS は無効になっており、swagger.io のドメイン(オリジン)で動作している Web アプリケーションから、REST API を実行するドメイン(オリジン)へのリソース要求は拒否されます。

CORS を有効にする方法

WebFilter を実装したクラスを作成

CORS を有効にするには以下のとおりにします。

import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.web.cors.reactive.CorsUtils
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono

@Component
class CorsFilter : WebFilter {

    private val allowedOrigin = "http://editor.swagger.io"
    private val allowedMethods = "GET, PUT, POST, DELETE, OPTIONS"
    private val allowedHeaders = "Content-Type"
    private val maxAge = "3600"

    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        val request = exchange.request
        if (CorsUtils.isCorsRequest(request)) {
            val response = exchange.response
            response.headers.apply {
                add("Access-Control-Allow-Origin", allowedOrigin)
                add("Access-Control-Allow-Methods", allowedMethods)
                add("Access-Control-Allow-Headers", allowedHeaders)
                add("Access-Control-Max-Age", maxAge)
            }
            if (request.method == HttpMethod.OPTIONS) {
                response.statusCode = HttpStatus.OK
                return Mono.empty<Void>()
            }
        }
        return chain.filter(exchange)
    }
}

上記のコードでは WebFilter インターフェースを実装したクラスを作成して、Bean として登録しています。filter メソッドの処理では、request に Origin ヘッダーが設定されているとき (CorsUtils.isCorsRequest(request) が true のとき)、response header に Access-Control-* ヘッダーを追加しています。更に、プリフライト要求の場合には OPTIONS メソッドでの request が送られるため、その場合には response body を空のままで応答します。単純要求の場合には Access-Control-* ヘッダーを追加して、次のフィルタに処理を委譲します。CORS 要求でない場合にも、次のフィルタに処理を委譲します。フィルタが委譲を繰り返し、いずれ応答します。

この WebFilter によって、異なるオリジンから Origin ヘッダーを付加して送信された要求に対して、Access-Control-* ヘッダーを付加した応答が返されます。

Configure で Bean を登録

別の方法として、以下の方法でも設定できます。

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.web.cors.reactive.CorsUtils
import org.springframework.web.server.WebFilter
import reactor.core.publisher.Mono

@Configuration
class CorsConfiguration {

    private final val allowedOrigin = "http://editor.swagger.io"
    private final val allowedMethods = "GET, PUT, POST, DELETE, OPTIONS"
    private final val allowedHeaders = "Content-Type"
    private final val maxAge = "3600"

    @Bean
    fun corsFilter(): WebFilter = WebFilter { exchange, chain ->
        val request = exchange.request
        if (CorsUtils.isCorsRequest(request)) {
            val response = exchange.response
            response.headers.apply {
                add("Access-Control-Allow-Origin", allowedOrigin)
                add("Access-Control-Allow-Methods", allowedMethods)
                add("Access-Control-Allow-Headers", allowedHeaders)
                add("Access-Control-Max-Age", maxAge)
            }
            if (request.method == HttpMethod.OPTIONS) {
                response.statusCode = HttpStatus.OK
                return@WebFilter Mono.empty<Void>()
            }
        }
        return@WebFilter chain.filter(exchange)
    }
}

WebFilter の適用順をカスタマイズしたい場合には、こちらの方法よりも WebFilter を実装したクラスを作成した方が簡単に対応できます。