Android でメールを送信する

概要

メールクライアントアプリを使わずに、自身が開発したアプリでメールを送信する方法です。Google のサービスを使ってメールを送信します。1つの方法は GmailSMTP を使い、もう1つの方法は Google APIGmail サービスを使います。

目次

確認環境

  • macOS 10.13
  • AndroidStudio 3.2.1
  • Gradle 4.6
  • Kotlin 1.3.0

参考情報

解説

認証方法の比較

GmailSMTP を使う方法と Google APIGmail サービスを使う方法の両方ともに、認証が必要になります。認証方法には3つの方法があります。以下のとおりです。

  • GmailSMTP を使う場合
    • Gmail アカウントのパスワードで認証する
    • アプリパスワードで認証する
      • 2段階認証を有効にしている場合
  • Google APIGmail サービスを使う場合
    • Android の機能で認証して、OAuth2 で認可する

GmailSMTP をパスワード認証で使う(非推奨)

この方法は実装方法が最も簡潔な方法です。しかし、使わない方が良い方法です。なぜなら、ユーザーのセキュリティリスクを高めてしまうためです。詳しくは後述します。

まず、app モジュールの build.gradle に JavaMail for Android への依存を追加します。

dependencies {
    //...
    implementation 'com.sun.mail:android-mail:1.5.5'
    implementation 'com.sun.mail:android-activation:1.5.5'
}

AndroidManifest.xml に INTERNET の使用権限を追加します。

    <uses-permission android:name="android.permission.INTERNET"/>

そして、メール送信を行う処理です。通信は Main (UI) スレッドで行えないため、AsyncTask を使っています。

package com.example.sendmail

import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeMessage
import android.os.AsyncTask
import java.util.*
import javax.mail.*

class SendMail(
    private val fromAddress: String,
    private val password: String,
    private val toAddress: String,
    private val subject: String,
    private val text: String
) : AsyncTask<Unit, Unit, Unit>() {

    override fun doInBackground(vararg params: Unit?) {
        val props = Properties().apply {
            putAll(mapOf(
                "mail.smtp.host" to "smtp.gmail.com",
                "mail.smtp.socketFactory.port" to "465",
                "mail.smtp.socketFactory.class" to "javax.net.ssl.SSLSocketFactory",
                "mail.smtp.auth" to "true",
                "mail.smtp.port" to "465"
            ))
        }

        val session = Session.getDefaultInstance(props, object : Authenticator() {
            override fun getPasswordAuthentication() = PasswordAuthentication(fromAddress, password)
        })

        val message = MimeMessage(session).also {
            it.setFrom(InternetAddress(fromAddress))
            it.addRecipient(Message.RecipientType.TO, InternetAddress(toAddress))
            it.subject = subject
            it.setText(text)
        }

        Transport.send(message)
    }
}

Kotlin の文法に詳しくない方のための補足説明:
クラス名直後の () はコンストラクタの引数です。val と指定されているため、クラスのプロパティでもあります、プロパティは、フィールドと getter/setter を合わせたものです。Java におけるフィールドの定義と this.fromAddress = fromAddress; の様なコンストラクタの引数をフィールドに代入する処理を行っています。Java ではフィールド定義、コンストラクタ定義と分かれているところを Kotlin では1つにまとめて書けます。: AsyncTask で AsyncTask クラスを継承しています。継承すると共に、AsyncTask<...>() として AsyncTask クラスの引数なしコンストラクタを SendMail オブジェクトの生成時に呼び出しています。変数 props には Properties オブジェクトが代入されます。但し、Properties オブジェクトには apply 関数の {} 内の処理が適用されます。object : Authenticator() では Authenticator クラスを継承した無名クラスのオブジェクトを生成しています。MimeMessege(session).alsoMimeMessege(session).apply とほぼ同じなのですが、apply の場合は this.subject = subject となり、also の場合は it.subject = subject になります。apply の場合は subjectthis.subject が同じ変数を指してしまうため、ここでは also を使っています。詳しくは Reference - Kotlin Programming Language を参照してください。


最後に SendMail の呼び出しです。適宜、引数の内容は書き換えてください。

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val task = SendMail(
            fromAddress = "your_gmail_account@gmail.com",
            password = "<your password>",
            toAddress = "your_friend_account@example.com",
            subject = "First Practice",
            text = "Hello World!"
        )
        task.execute()
    }
}

以上で完成です。アプリを実行させてみると、残念ながら LogCat に以下のエラーが表示されます。

javax.mail.AuthenticationFailedException: 534-5.7.14

エラーメッセージには https://support.google.com/mail/answer/78754 を見るように書かれています。読んでみると、安全性の低いアプリを許可する必要があることが分かります。許可は https://myaccount.google.com/lesssecureapps で行えます。有効に設定した上で、再びアプリを実行してみましょう。今度はメールが送信され、指定のアドレスに届いているはずです。届いていない場合は、パスワードが間違えていないか確認しましょう。

しかし、安全性の低いアプリの許可を有効にすると、様々な怪しいアプリにも許可を与えることになります。これは危険です。先程のヘルプには、2段階認証を設定し、アプリパスワードを使う方法があると書かれています。この方法は使わずに、アプリパスワードを使う方法を使います。先に進む前に、安全性の低いアプリの許可は無効に戻しておきましょう。

GmailSMTP をアプリパスワードで使う

では、2段階認証を有効にし、アプリパスワードを使ってメールを送信してみます。

https://myaccount.google.com/lesssecureapps のページの左向き矢印を選択すると、ログインとセキュリティの設定ページに移動します。パスワードとログイン方法の項目に、2段階認証プロセスの項目があります。手順に従って有効にしましょう。

手順どおりに進めて、次の画面が表示されたら2段階認証プロセスの設定は完了です。

左向き矢印を選択して、メニュー階層を戻りましょう。2段階認証プロセスを有効にすると、アプリパスワードの項目が追加されます。

アプリパスワードを追加します。メールを選択し、端末は Android が見当たらないのでその他を選択します。端末名の入力では Android とでも入力しておきます。

生成を行うと、黄色い枠のなかにアプリパスワードが表示されます。(黒塗りして隠しています。)

このアプリパスワードをコピーし、Gmail アカウントのパスワードと差し替えます。

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val task = SendMail(
            fromAddress = "your_gmail_account@gmail.com",
            password = "<your application password>", // 差し替え
            toAddress = "your_friend_account@example.com",
            subject = "First Practice",
            text = "Hello World!"
        )
        task.execute()
    }
}

アプリを実行してみましょう。メールが送信され、指定のアドレスに届いているはずです。

Google APIGmail サービスを OAuth2 で使う

アプリパスワードを使う方法では、ユーザーにアプリパスワードの設定を行ってもらう必要があります。限られた環境のみで使うのであれば、手軽な方法ではあります。多くのユーザーが使うことを想定すると、使い勝手の観点から現実的ではありません。そこで、Android の機能で認証を行い、Google APIGmail サービスを OAuth2 で認可する仕組みを使います。

まずは、app モジュールの build.gradle にライブラリへの依存を追加します。

dependencies {
    // ...
    implementation 'com.sun.mail:android-mail:1.5.5'

    implementation('com.google.android.gms:play-services-auth:16.0.1') {
        exclude group: 'com.android.support'
    }
    implementation 'com.google.api-client:google-api-client-android:1.25.0'
    implementation 'com.google.api-client:google-api-client-gson:1.25.0'
    implementation 'com.google.apis:google-api-services-gmail:v1-rev96-1.25.0'
}

android-mail は無くても構いませんが、使うと Message (後述) の構築が楽です。play-services-auth では com.android.support を除外しています。Support Library への依存を別途追加する場合に、バージョンの違いでエラーになるため除外しています。

AndroidManifest.xml には INTERNET の使用権限を追加します。

    <uses-permission android:name="android.permission.INTERNET"/>

そして、メール送信を行う処理です。通信は Main (UI) スレッドで行えないため、ここでも AsyncTask を使っています。

package com.example.sendmail

import android.content.Intent
import android.os.AsyncTask
import com.google.api.client.extensions.android.http.AndroidHttp
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException
import com.google.api.client.json.gson.GsonFactory
import com.google.api.client.repackaged.org.apache.commons.codec.binary.Base64
import com.google.api.services.gmail.Gmail
import com.google.api.services.gmail.model.Message
import java.io.ByteArrayOutputStream
import java.util.*
import javax.mail.Session
import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeMessage

class SendMailUsingGmailService(
    private val credential: GoogleAccountCredential,
    private val message: MimeMessage,
    private val onAuthError: (Intent) -> Unit
) : AsyncTask<Unit, Unit, Intent?>() {

    companion object {
        private fun createMimeMessage(
            fromAddress: String,
            toAddress: String,
            subject: String,
            text: String
        ): MimeMessage {
            val session = Session.getDefaultInstance(Properties())
            return MimeMessage(session).also {
                it.setFrom(InternetAddress(fromAddress))
                it.addRecipient(javax.mail.Message.RecipientType.TO, InternetAddress(toAddress))
                it.subject = subject
                it.setText(text)
            }
        }
    }

    constructor(
        credential: GoogleAccountCredential,
        toAddress: String,
        subject: String,
        text: String,
        onAuthError: (Intent) -> Unit
    ) : this(
        credential,
        createMimeMessage(credential.selectedAccountName, toAddress, subject, text),
        onAuthError
    )

    override fun onPostExecute(result: Intent?) {
        result?.also(onAuthError)
    }

    override fun doInBackground(vararg params: Unit?): Intent? {
        return try {
            buildService(credential).send(message)
            null
        } catch (e: UserRecoverableAuthIOException) {
            e.intent
        }
    }

    private fun buildService(credential: GoogleAccountCredential): Gmail {
        val transport = AndroidHttp.newCompatibleTransport()
        return Gmail.Builder(transport, GsonFactory(), credential)
            .setApplicationName("Send Mail")
            .build()
    }

    private fun Gmail.send(mimeMessage: MimeMessage) {
        users()
            .messages()
            .send(credential.selectedAccountName, createMessage(mimeMessage))
            .execute()
    }

    private fun createMessage(mimeMessage: MimeMessage): Message {
        return Message().apply {
            val bytes = ByteArrayOutputStream()
                .also(mimeMessage::writeTo)
                .toByteArray()
            raw = Base64.encodeBase64URLSafeString(bytes)
        }
    }
}

Kotlin の文法に詳しくない方のための補足説明:
クラス名直後の () はプライマリコンストラクタの引数です。SendMailUsingGmailService クラスには2つのコンストラクタがあります。constructor で始まる部分はセカンダリコンストラクタと呼ばれ、this(...) でプライマリコンストラクタを呼び出しています。companion object の {} 内に関数を書くとクラスメソッドとして振る舞います。result?.also(onAuthError) は、result が null では無いときのみ onAuthError(result) を実行する書き方です。? を書かない場合は null チェックを行わずに onAuthError(result) を実行します。try-catch 構文は try, catch のブロック内の最後の値が try-catch 式の値となって、その値が return により関数の戻り値にされます。具体的には、UserRecoverableAuthIOException が発生した場合は e.intent が戻り値となり、成功した場合は null が戻り値になります。fun Gmail.send(...) は拡張関数と呼ばれ、Gmail クラスに send メソッドが追加された様に見せかけることができます。そのため、buildService(credential).send(message) という書き方ができています。buildService の戻り値は Gmail オブジェクトですので、拡張関数である send メソッド(関数)を呼び出せる様になっています。 詳しくは Reference - Kotlin Programming Language を参照してください。


さて、本題に戻って処理の内容です。Google アカウントで認証した結果が GoogleAccountCredential として渡されます。メールのコンテンツは MimeMessage として扱われます。buildService 関数では Gmail サービスを準備し、send 関数でサービスを利用しています。createMessage 関数では MimeMessage をバイト列に変換して、 API に送信するメッセージ本文の raw にそのバイト列を設定しています。

SendMailUsingGmailService の呼び出しは Google アカウントを使った認証と OAuth2 による認可の手順が必要になるため複雑になります。

package com.example.sendmail

import android.accounts.AccountManager
import android.app.Activity
import android.content.Intent
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.services.gmail.GmailScopes

class MainActivity : AppCompatActivity() {

    companion object {
        private const val REQUEST_ACCOUNT_CHOOSER = 1
        private const val REQUEST_AUTHORIZATION = 2
    }

    private lateinit var mCredential: GoogleAccountCredential

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mCredential = GoogleAccountCredential.usingOAuth2(this, listOf(GmailScopes.GMAIL_SEND))
        startActivityForResult(mCredential.newChooseAccountIntent(), REQUEST_ACCOUNT_CHOOSER)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        when (requestCode) {
            REQUEST_ACCOUNT_CHOOSER -> {
                if (resultCode == Activity.RESULT_OK && data != null) {
                    val accountName = data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
                    Log.i(localClassName, "Account Name: $accountName")
                    if (accountName != null) {
                        mCredential.selectedAccountName = accountName
                    }
                    sendMail()
                }
            }
            REQUEST_AUTHORIZATION -> sendMail()
        }
    }

    private fun sendMail() {
        val task = SendMailUsingGmailService(
            mCredential,
            toAddress = "your_friend_account@example.com",
            subject = "First Practice",
            text = "Hello World!"
        ) {
            startActivityForResult(it, REQUEST_AUTHORIZATION)
        }

        task.execute()
    }
}

Kotlin の文法に詳しくない方のための補足説明:
val は再代入不可な変数、var は再代入可能な変数です。lateinit は後で初期化することを示す印です。lateinit を使わない場合には var mCredential: GoogleAccountCredential? = null とします。変数の型に ? が付く場合には、変数に null の代入を許容することになるので変数を使う時に null チェックが必要になります。変数を使う前に必ず null 以外で初期化され、変数を使う時の null チェックを減らしたい場合に lateinit を使います。SendMailUsingGmailService のコンストラクタの引数には onAuthError が必要です。しかし、一見すると () 内には引数が見当たりません。Kotlin では、最後の引数の型が関数である場合 () の外側に記述できます。{} はブロック(複数の文をまとめる構造)、もしくはラムダ式(関数の一種)を表しており、{ startActivityForResult(...) } は関数の一種ということになります。onAuthError 引数にはこの関数が代入されます。詳しくは Reference - Kotlin Programming Language を参照してください。


GmailScopes.GMAIL_SEND を指定して、送信サービスの認可を得ようとしています。startActivityForResult(..., REQUEST_ACCOUNT_CHOOSER) ではアカウントの選択を起動します。アカウントを選択後 onActivityResult の REQUEST_ACCOUNT_CHOOSER の部分が実行されます。選択されたアカウント名を GoogleAccountCredential に設定しています。これにより GoogleAccountCredential が整ったので、メールの送信処理に移ります。メールを送信しようとすると、サービスへのアクセス許可が与えられていない場合に onAuthError コールバックが呼び出されます。コールバックでは startActivityForResult(it, REQUEST_AUTHORIZATION) を呼び出してアクセス許可を与えるかの選択をユーザーに促します。選択の後、onActivityResult の REQUEST_AUTHORIZATION の部分が実行されます。再びメールを送信し、アクセス許可が与えられていたら onAuthError コールバックは呼び出されずに終了します。

以上で、実装は完了しました。アプリを起動してみます。しかし、ビルドの途中で以下のエラーが表示されるかもしれません。

More than one file was found with OS independent path 'META-INF/DEPENDENCIES'

この場合には、build.gradle に以下を追加すれば解決します。(理由は調べられていません。どなたか教えて頂けると嬉しいです。)

android {
    // ...
    packagingOptions {
        exclude 'META-INF/DEPENDENCIES'
    }
}

アプリが起動するとアカウント選択が表示され、いずれかのアカウントを選択します。

そうすると、エラーが発生します。LogCat に以下のエラーが表示されます。

com.google.android.gms.auth.GoogleAuthException: UNREGISTERED_ON_API_CONSOLE

解消するためには Developer Console でサービスを有効化しておく必要があります。

Developer Console でサービスを有効化

まず、Google Cloud Platform から Console を開きます。既存のプロジェクトでサービスを有効化しても良いですが、ここでは新しいプロジェクトを作成します。プロジェクト名は何でも良いので、ここでは SendMail にしておきます。

プロジェクト (SendMail) が選択されていることを確認した上で、API とサービスのメニューを開きます。ここから「API とサービスの有効化」を選択します

Gmail を検索し、Gmail API を選択します。

Gmail API を有効にします。

次に、Gmail API の認証情報を設定します。認証情報メニューを選択し、「認証情報を作成」を選択します。

使用する APIGmail APIAPI を呼び出す場所は Android、アクセスするデータの種類はユーザーデータです。

「必要な認証情報」を選択すると次のステップに進みます。

クライアント ID を作成するにあたっての名前は後で見て分かる名前を付けます。署名証明書フィンガープリントは Terminal で keytool コマンドを使って取得します。

書かれているとおりのコマンドを実行しますが、path-to-debug-or-production-keystore は自分で埋める必要があります。debug-keystore を使う場合は以下のとおりに実行します。(macOS の場合です。)

$ keytool -exportcert -keystore ~/.android/debug.keystore -list -v

パスワードを求められますが、何も入力せずに Enter で先に進めます。表示された情報のなかから、SHA1 のフィンガープリントをコピーします。Android アプリを識別するための情報(指紋)です。配布するアプリの場合には、自分で作成した production-keystore ファイルのパスを指定します。今回は実験用なので debug-keystore を使っています。

パッケージ名は AndroidManifest.xml で指定したパッケージ名を記入します。しかし、build.gradle の applicationId で異なるパッケージ名を設定している場合には applicationId を記入する必要があるようです。私は試していませんが StackOverflow でみかけました。なお、1つの Android アプリを複数のプロジェクトには設定できない様です。異なるアカウントの異なるプロジェクトに同一アプリを設定しようとしたところ、設定できませんでした。

記入を終えて「OAuth クライアント ID を作成」を選択すると次のステップに進みます。

次は、ユーザーに表示される同意画面の設定です。ユーザーに見せるサポート用のメールアドレス、サービス名を設定します。今回は API を使うサービスが Android のアプリだけなので Android アプリの名前を設定しています。サービス名が別途存在する場合には、そちらを設定すれば良いでしょう。

「次へ」を選択すると最後のステップに進みます。Android アプリで使う場合には、認証情報をダウンロードしなくて構いません。完了を選択します。

次に、OAuth 同意画面を設定します。アプリケーション名、サポートメールは先程設定した内容が表示されます。

下にスクロールすると Google API のスコープの設定が現れます。ユーザーにアクセスを許可する範囲の設定です。「スコープを追加」を選択し、gmail.send を探して追加します。

更に下にスクロールすると保存ボタンがあります。個人的な利用の範囲であれば保存で構いません。しかし、一般に公開したい場合には「確認のため送信」を選択する必要があります。公開の前に Google による確認が行われます。今回は省略していますが、プライバシーポリシーの設定などの他の項目も適切に設定した上で確認を依頼しましょう。

以上でサービスの有効化は完了しました。アプリを起動して試してみましょう。Google アカウントの選択までは既に行えていました。

アカウントを選択するとエラーではなく、同意画面が表示されます。

許可を選択することで同意したことになり、gmail.send (GmailScopes.GMAIL_SEND) が有効になります。これで、Gmail サービスを使ったメール送信が行える様になります。

おつかれさまでした。

無料で使えるプライベート 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 の登録と併せて行われていました。