3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

KtorでFirebase Authentication

Last updated at Posted at 2019-02-12

Ktor-auth + Firebase Admin SDK による FirebaseToken認証処理を行うパイプラインのインストールをするコードを雑に書いたのでメモです。

ktor-jwt等と同じような使い方できるように意識しました。

依存関係

  • ktor-auth
  • firebase-admin

コード

import com.google.firebase.auth.*
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.auth.*
import io.ktor.request.*
import io.ktor.response.*

private const val FirebaseAuthKey = "FirebaseAuth"
private const val scheme = "Bearer"


/**
 * FirebaseTokenについての認証処理をインストールする
 *
 * @param name [FirebaseAuthenticationProvider]の名前
 */
fun Authentication.Configuration.firebase(name: String = "", configure: FirebaseAuthenticationProvider.() -> Unit) {
    val provider = FirebaseAuthenticationProviderImpl(name).apply(configure)
    provider.pipeline.intercept(AuthenticationPipeline.RequestAuthentication) { context ->
        val authHeader = call.request.parseAuthorizationHeaderOrNull()
        if (authHeader == null) {
            context.bearerChallenge(AuthenticationFailedCause.NoCredentials)
            return@intercept
        }

        val principal = authHeader.getBlob()?.let { provider.verifyIdToken(call, it) }

        try {
            if (principal != null) context.principal(principal)
            else context.bearerChallenge(AuthenticationFailedCause.InvalidCredentials)
        } catch (t: Throwable) {
            context.error(FirebaseAuthKey, AuthenticationFailedCause.Error(t.message ?: t.javaClass.simpleName))
        }
    }
    register(provider)
}

/**
 * ApplicationRequestからHttpRequestHeaderを取り出す
 */
private fun ApplicationRequest.parseAuthorizationHeaderOrNull() = try {
    parseAuthorizationHeader()
} catch (e: IllegalArgumentException) {
    null
}

/**
 * 認証失敗時のレスポンスを行う
 */
private fun AuthenticationContext.bearerChallenge(
    failedCause: AuthenticationFailedCause
) = challenge(FirebaseAuthKey, failedCause) {
    call.respond(UnauthorizedResponse(HttpAuthHeader.bearerAuthChallenge()))
    it.complete()
}

/**
 * 認証失敗時のレスポンスのヘッダーを組み立てる
 */
private fun HttpAuthHeader.Companion.bearerAuthChallenge() =
    HttpAuthHeader.Parameterized(scheme, mapOf())

/**
 * HttpAuthHeaderから生のトークン文字列を取り出す
 */
private fun HttpAuthHeader.getBlob() = when {
    this is HttpAuthHeader.Single && authScheme.toLowerCase() == scheme.toLowerCase() -> blob
    else -> null
}

/**
 * Firebaseのトークン認証用プロバイダ
 */
interface FirebaseAuthenticationProvider {
    /**
     * Token検証用のFirebaseAuth
     */
    var firebaseAuth: FirebaseAuth

    /**
     * 検証済みのFirebaseTokenを受け取って、それについて検証を行う
     *
     * @param validate 検証に成功すればPrincipalを返す。
     */
    fun validate(validate: suspend ApplicationCall.(FirebaseToken) -> Principal?)

    /**
     * 特定の条件に一致するApplicationCallに認証が必要かどうかを指定する認証フィルタ
     *
     * 指定しない場合は、すべてのApplicationCallに認証が必要になる
     *
     * 指定したいずれかのフィルタがtrueを返す場合に認証がスキップされる
     *
     * @param predicate 認証をスキップする場合の条件
     */
    fun skipWhen(predicate: (ApplicationCall) -> Boolean)
}

/*
AuthenticationProvider#skipWhenが存在するのでFirebaseAuthenticationProvider#skipWhenを呼び出すとそっちを呼びに行く
 */
private class FirebaseAuthenticationProviderImpl(
    name: String
) : AuthenticationProvider(name), FirebaseAuthenticationProvider {
    private var authenticationFunc: suspend ApplicationCall.(FirebaseToken) -> Principal? = { null }

    private var mFirebaseAuth: FirebaseAuth? = null
    override var firebaseAuth: FirebaseAuth
        get() = mFirebaseAuth ?: FirebaseAuth.getInstance()
        set(value) {
            mFirebaseAuth = value
        }

    suspend fun verifyIdToken(call: ApplicationCall, tokenStr: String): Principal? = try {
        val firebaseToken = firebaseAuth.verifyIdToken(tokenStr)
        call.authenticationFunc(firebaseToken)
    } catch (e: FirebaseAuthException) {
        null
    } catch (e: IllegalArgumentException) {
        null
    }

    override fun validate(validate: suspend ApplicationCall.(FirebaseToken) -> Principal?) {
        authenticationFunc = validate
    }
}

使用例

KodeinでいろいろとDIする想定のサンプル。

/**
 * 認証済みのFirebaseTokenを表すクラス
 *
 * @property value 認証済みのFirebaseToken
 */
data class PrincipalToken(val value: FirebaseToken) : Principal

/**
 * ApplicationCallから[PrincipalToken]を取り出す
 *
 * @throws IllegalArgumentException [PrincipalToken]が取り出せなかった場合
 */
fun ApplicationCall.principalToken(): PrincipalToken = requireNotNull(principal())


fun Application.installAuthentication(kodein: Kodein) = install(Authentication) {
    val auth: FirebaseAuth by kodein.instance()
    firebase("firebase") {
        firebaseAuth = auth
        //メールアドレスが確認済みでない場合は失敗にする
        validate { it.takeIf(FirebaseToken::isEmailVerified)?.let(::PrincipalToken) }
    }
}

fun Route.firebaseAuthenticateRoute(kodein: Kodein) = authenticate("firebase") {
    route("/test") {
        get {
            call.respond("hogehoge")
        }
    }
}

fun Application.main() {
    val kodein = createKodein(environment.config)
    installAuthentication(kodein)
    route("/api/v1/") {
        firebaseAuthenticateRoute(kodein)
    }
}

あとは

fun main() {
    embeddedServer(Netty, port = 8080) {
        main()
    }.start()
}

みたいにしてやれば https://localhost:8080/api/v1/test にたいして Authorization: Bearer <FirebaseToken> なヘッダーをつけてGETリクエストを送ればよしなに認証処理を挟んでくれます。

なにかおかしな点などがあれば指摘して頂けれると嬉しいです。

参考

https://ktor.io/servers/features/authentication.html
https://ktor.io/servers/lifecycle.html
https://firebase.google.com/docs/admin/setup?hl=ja

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?