Kotlin
JWT
Ktor

KtorでJWTを使った認証

この記事では、KtorのAuthを利用してJWTでの認証を実装します。

OAuth書こうかなと思いましたが、こちらの記事が詳しいです。(Ktorタグぼっちじゃなくなった👏👏👏)

KtorでOAuth2認証をしてみる - Qiita

現状、issueにはありますがJWTでの認証はKtorでは用意されていないようです。
なので、トークン生成・検証にJJWTを使用します。

その他に、KtorのJacksonを使用します。

バージョンは以下です。

  • Kotlin: 1.2.10
  • Ktor: 0.9.0
  • JJWT: 0.9.0

実装するエンドポイントは以下です。

  • /login POST
    • Tokenを発行
  • /me GET
    • Tokenが必要

build.gradle

build.gradle
dependencies {
    ...
    // Ktor
    compile "io.ktor:ktor-server-core:0.9.0"
    compile "io.ktor:ktor-server-netty:0.9.0"
    compile "io.ktor:ktor-jackson:0.9.0"
    compile "io.ktor:ktor-auth:0.9.0"

    // JWT
    compile "io.jsonwebtoken:jjwt:0.9.0"
    ....
}

Ktor関連とJJWTを追加します。

全体的にはたぶんこんな感じ。

build.gradle
group 'SAMPLE'
version '1.0-SNAPSHOT'

buildscript {
    ext {
        kotlin_version = '1.2.0'
        ktor_version = '0.9.0'
        jjwt_version = '0.9.0'
        logback_version = '1.2.1'
    }

    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

apply plugin: 'java'
apply plugin: 'kotlin'
apply plugin: 'application'

mainClassName = 'io.ktor.server.netty.DevelopmentEngine'

repositories {
    mavenCentral()
    maven { url  "http://dl.bintray.com/kotlin/ktor" }
    maven { url "https://dl.bintray.com/kotlin/kotlinx" }
}

dependencies {
    // Kotlin
    compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"

    // Ktor
    compile "io.ktor:ktor-server-core:$ktor_version"
    compile "io.ktor:ktor-server-netty:$ktor_version"
    compile "io.ktor:ktor-jackson:$ktor_version"
    compile "io.ktor:ktor-auth:$ktor_version"

    // JWT
    compile "io.jsonwebtoken:jjwt:$jjwt_version"

    // Log
    compile "ch.qos.logback:logback-classic:$logback_version"
}

compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

kotlin {
    experimental {
        coroutines "enable"
    }
}

/login POST

まずログイン処理から作って行きます。
JSONでemailpasswordを受け取ってそれらを検証し、正しければユーザー情報とアクセストークンを返します。

Login.kt
import com.fasterxml.jackson.annotation.JsonIgnore
import io.ktor.application.call
import io.ktor.auth.*
import io.ktor.response.respond
import io.ktor.routing.*

fun Route.login() {
        route("/login") {
        authentication {
            // TODO 認証処理を追加する
        }

        post {
            // TODO ユーザー情報の取得とトークンの生成を行う

            call.respond(LoginResponse(user, token))
        }
    }
}

// リクエスト・レスポンス
data class LoginRequest(val email: String, val password: String)
data class LoginResponse(val user: User, val accessToken: String)
// ユーザー情報
data class User(val id: Int, val name: String, val email: String, 
                                @JsonIgnore val encryptedPassword: String)

認証処理の実装

次にauthentication { }の中で実行する認証処理を実装します。
これはAuthの提供するAuthenticationPipelineクラスの拡張関数として実装し、本処理(ここだとpost { }で処理する部分)の実行前に検証が行われるようにします。

やることは以下です。

  1. 本処理の前に割り込む
  2. Request bodyの受け取り
  3. emailでユーザーを探す
  4. passwordが一致するか検証
  5. passwordが一致した場合、本処理に認証情報を渡す
  6. ユーザーが見つからない、またはpasswordが一致しない場合、401 Unauthorizedを返し処理を終了する

本処理の前に割り込む

Ktorではintercept()メソッドを利用することで、各エンドポイントの処理の前後に処理を割り込ませることができます。

認証処理は本処理よりも前に実行される必要があるので、ApplicationCallPipeline.Callかそれ以前に実行されるPipelinePhaseを引数とするinterceptとして実装しなければいけません。

デフォルトのPipelinePhaseと本処理の順番はこうです。(ほかにも見つけた気がしないでもないけど忘れた)

  1. ApplicationCallPipeline.Infrastructure
  2. ApplicationCallPipeline.Call
  3. 本処理
  4. ApplicationCallPipeline.Fallback

authentication {}の中では、AuthenticationPipeline.RequestAuthenticationAuthenticationPipeline.CheckAuthenticationというPipelinePhaseが使用できるようになり、上記の順番に追加されます。

  1. ApplicationCallPipeline.Infrastructure
  2. AuthenticationPipeline.CheckAuthentication 👈
  3. AuthenticationPipeline.RequestAuthentication 👈
  4. ApplicationCallPipeline.Call
  5. 本処理
  6. ApplicationCallPipeline.Fallback

どちらを使うべきかはよくわかりませんが、ログインは「認証のリクエスト」、トークンの検証は「認証の検証」っぽい気がしたので、
ログイン情報の検証: AuthenticationPipeline.RequestAuthentication
トークンの検証: AuthenticationPipeline.CheckAuthentication
を使うことにします。

Login.kt
// ...Route.login etc...

fun AuthenticationPipeline.emailPasswordAuthentication() {
    intercept(AuthenticationPipeline.RequestAuthentication) { context ->
                // TODO 検証処理
    }
}

Request bodyの受け取り

Request bodyはApplicationCall.receive()メソッドで受け取ることができます。
定義は
inline suspend fun <reified T : Any> ApplicationCall.receive(): T
なので呼び出し時に型パラメータを書くか、推論させる必要があります。

Login.kt
// ...Route.login etc...

fun AuthenticationPipeline.emailPasswordAuthentication() {
    intercept(AuthenticationPipeline.RequestAuthentication) { context ->
                // 👇
        val request = call.receive<LoginRequest>()

                // TODO 検証処理
    }
}

ちなみにLocationsではRequest bodyを受け取れないように意図的に実装しているみたいでした。
使い分けを明確にしているのかな。

emailとpasswordの検証

それっぽいメソッドがあると思ってください。

Login.kt
// ...Route.login etc...

fun AuthenticationPipeline.emailPasswordAuthentication() {
    intercept(AuthenticationPipeline.RequestAuthentication) { context ->
        val request = call.receive<LoginRequest>()

                // 👇
        val user = findUserByEmail(request.email)

                // 👇
        if (user != null && isSamePassword(request.password, user.encryptedPassword)) {
                    // TODO 本処理に認証情報を渡す
        } else {
                    // TODO `401 Unauthorized`を返し処理を終了する
        }
    }
}

正常系:本処理に認証情報を渡す

リクエストされたemailpasswordが正しい組み合わせの場合、認証情報(ここではユーザー情報)を本処理や後続のinterceptに渡すために、contextのプロパティに代入します。

認証情報は、Authの提供するprincipalに入れるようです。

Login.kt
// ...Route.login etc...

fun AuthenticationPipeline.emailPasswordAuthentication() {
    intercept(AuthenticationPipeline.RequestAuthentication) { context ->
        val request = call.receive<LoginRequest>()

        val user = findUserByEmail(request.email)

        if (user != null && isSamePassword(request.password, user.encryptedPassword)) {
                    // 👇
            context.principal = user
        } else {
                    // TODO `401 Unauthorized`を返し処理を終了する
        }
    }
}

principalPrincipalインターフェースを実装したクラスしか代入できないので、UserクラスにPrincipalインターフェースを実装します。

Login.kt
...

// ユーザー情報
data class User(val id: Int, val name: String, val email: String, 
                                @JsonIgnore val encryptedPassword: String): Principal // 👈
...

正常系の実装はこれだけです。

異常系:401 Unauthorizedを返し処理を終了する

ここが正直よくわかっていませんが、Authの提供する認証のメソッド(oauthAtLocation()など)はAuthenticationContext.challenge()というメソッドの中でレスポンスを返すようになっていました。
それに倣って実装します。

Login.kt
// ...Route.login etc...

fun AuthenticationPipeline.emailPasswordAuthentication() {
    intercept(AuthenticationPipeline.RequestAuthentication) { context ->
        val request = call.receive<LoginRequest>()

        val user = findUserByEmail(request.email)

        if (user != null && isSamePassword(request.password, user.encryptedPassword)) {
            context.principal = user
        } else {
                    // 👇
            context.challenge("Login", NotAuthenticatedCause.InvalidCredentials) {
                it.success()
                call.respond(HttpStatusCode.Unauthorized, "Invalid credential.")
            }
        }
    }
}

ちなみにelseの中で例外を投げて、上でハンドルする方法でも問題なく動いてるように見えました。

ログインの認証部分は終わりなので、authentication {}内で呼び出します。

Login.kt
fun Route.login() {
        route("/login") {
        authentication {
            // 👇
            emailPasswordAuthentication()
        }

        post {
            // TODO 認証情報の取得とトークンの生成を行う

            call.respond(LoginResponse(user, token))
        }
    }
}
...

本処理の実装

post { }の中を書いて行きます。

認証情報の受け取り

emailPasswordAuthentication()で設定した認証情報を受け取ります。

ApplicationCall.authentication.principal()またはApplicationCall.principal()で受け取ることができます。両者に違いはありません。
ApplicationCall.receive()と同じく、呼び出し時に型パラメータを書くか、推論させる必要があります。

Login.kt
fun Route.login() {
    route("/login") {
        authentication {
            emailPasswordAuthentication()
        }

        post {
            // 👇
            val principal = call.principal<User>() ?: throw IllegalStateException("Principal is null.")

            // TODO トークンの生成を行う
            call.respond(LoginResponse(principal, token))
        }
    }
}
...

トークンの生成

JJWTを使ってトークンを作ります。
とりあえずIDだけ埋め込んだ1時間で切れるトークンです。

Login.kt
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*

... route, pipeline, etc...

fun generateToken(user: User): String {
    val expiration = LocalDateTime.now().plusHours(1).atZone(ZoneId.systemDefault())
    return Jwts.builder()
            .setSubject(user.id.toString())
            .setAudience("Qiita_sample")
            .setHeaderParam("typ", "JWT")
            .setExpiration(Date.from(expiration.toInstant()))
            .signWith(SignatureAlgorithm.HS256, "honmani secret na key")
            .compact()
}

呼び出して終わりです。全体的にはこんな感じになりました。

Login.kt
import com.fasterxml.jackson.annotation.JsonIgnore
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.ktor.application.call
import io.ktor.auth.*
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.*
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*

data class LoginRequest(val email: String, val password: String)
data class LoginResponse(val user: User, val accessToken: String)
data class User(val id: Int, val name: String, val email: String, @JsonIgnore val encryptedPassword: String): Principal

fun Route.login() {
    route("/login") {
        authentication {
            emailPasswordAuthentication()
        }

        post {
            val principal = call.principal<User>() ?: throw IllegalStateException("Principal is null.")

            // 👇
            val token = generateToken(principal)

            call.respond(LoginResponse(principal, token))
        }
    }
}

fun AuthenticationPipeline.emailPasswordAuthentication() {
    intercept(AuthenticationPipeline.RequestAuthentication) { context ->
        val request = call.receive<LoginRequest>()

        val user = findUserByEmail(request.email)

        if (user != null && isSamePassword(request.password, user.encryptedPassword)) {
            context.principal = user
        } else {
            context.challenge("Login", NotAuthenticatedCause.InvalidCredentials) {
                it.success()
                call.respond(HttpStatusCode.Unauthorized, "Invalid credential.")
            }
        }
    }
}

fun generateToken(user: User): String {
    val expiration = LocalDateTime.now().plusHours(1).atZone(ZoneId.systemDefault())
    return Jwts.builder()
            .setSubject(user.id.toString())
            .setAudience("Qiita_sample")
            .setHeaderParam("typ", "JWT")
            .setExpiration(Date.from(expiration.toInstant()))
            .signWith(SignatureAlgorithm.HS256, "honmani secret na key")
            .compact()
}

Route.login()の呼び出し

Application.kt
import io.ktor.http.ContentType
import io.ktor.http.withCharset
import io.ktor.routing.*

fun Application.main() {
    ...
    install(Routing) {
        contentType(ContentType.Application.Json.withCharset(Charsets.UTF_8)) {
            login()
        }
}

contentType() {}の中で呼び出すことで、リクエストのContent-Typeを指定することができます。
application/json以外で/loginにアクセスした場合は、404: Not Foundが返却されます。

また、withCahrset()でUTF-8を指定しないと日本語などが文字化けしてしまうようです。

アクセスすると以下のようなレスポンスが返ってきます。

{
    "user": {
        "id": 1,
        "name": "きしだ",
        "email": "email@example.com"
    },
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiYXVkIjoiUWlpdGFfc2FtcGxlIiwiZXhwIjoxNTEzNTEwMDE3fQ.IpxL1mK-8pI9WISZ5sVTOhFdpI09zTbCkQYVysjqa1Q"
}

/me GET

/me GETは以下のようにします。
get {}の部分は特に見るところがないので、tokenAuthentication()を見て行きます。

Me.kt
import io.ktor.application.call
import io.ktor.auth.*
import io.ktor.response.respond
import io.ktor.routing.*

data class AuthenticationInfo(val id: Int): Principal

fun Route.me() {
    route("/me") {
        authentication {
            tokenAuthentication()
        }

        get {
            val principal = call.principal<AuthenticationInfo>() ?: throw IllegalStateException("Principal is null.")

            val user = getUserDetail(principal.id) // こういうメソッドがあるという前提で…

            call.respond(user)
        }
    }
}

fun AuthenticationPipeline.tokenAuthentication() {
    // TODO 検証
}

トークンによる認証

ログインの時と同様、interceptを利用して認証を行います。

やることは以下です。

  1. 本処理の前に割り込む
  2. Authorization headerからトークンを取得する
  3. トークンを検証する
  4. トークンが有効な場合、本処理に認証情報を渡す
  5. トークンが存在しない、または無効な場合、401 Unauthorizedを返し処理を終了する

本処理の前に割り込む

ログインのところで書いたように、トークンの検証ではAuthenticationPipeline.CheckAuthenticationintercept()の引数に渡すことにします。

Me.kt
fun AuthenticationPipeline.tokenAuthentication() {
    intercept(AuthenticationPipeline.CheckAuthentication) { context ->
        // TODO
    }
}

Authorization headerからトークンを取得する

Authorization headerの取得にはそれ用のメソッド
ApplicationRequest.parseAuthorizationHeader()
がAuthによって提供されています。

Me.kt
fun AuthenticationPipeline.tokenAuthentication() {
    intercept(AuthenticationPipeline.CheckAuthentication) { context ->
        // 👇
        val header = call.request.parseAuthorizationHeader()

        val id = if (header != null && 
                header.authScheme == "Bearer" &&     // 👈 ①
                header is HttpAuthHeader.Single) {      // 👈 ②
             // TODO Tokenの検証
        } else {
            null
        }

        // TODO 正常系・異常系の処理
    }
}

①でスキームのチェックを、②でtoken68かどうかをチェックしています。
HttpAuthHeader.Parameterizedもあります。

トークンを検証する

JJWTを使って検証します。
Int型のIDを埋め込んでいるのでそれを取得します。失敗した時はnullを返します。

Me.kt
fun verifyToken(token: String): Int? {
    val jws = try {
        Jwts.parser().setSigningKey("honmani secret na key").parseClaimsJws(token)
    } catch (ex: Exception) {
        // Parseエラー(=改竄やトークンが完全でない)、期限切れなど
        return null
    }

    return if (jws.body.audience == "Qiita_sample" && jws.body.subject != null) {
        jws.body.subject.toIntOrNull()
    } else {
        null
    }
}

呼び出します。

Me.kt
fun AuthenticationPipeline.tokenAuthentication() {
    intercept(AuthenticationPipeline.CheckAuthentication) { context ->
        val header = call.request.parseAuthorizationHeader()

        val id = if (header != null && 
                header.authScheme == "Bearer" &&
                header is HttpAuthHeader.Single) {
             // 👇
             verifyToken(header.blob)
        } else {
            null
        }

        // TODO 正常系・異常系の処理
    }
}

正常系・異常系の処理をする

ログインとほとんど同じです。IntPrincipalを実装していないの適当なクラスでラップします。

Me.kt
data class AuthenticationInfo(val id: Int): Principal

...

fun AuthenticationPipeline.tokenAuthentication() {
    intercept(AuthenticationPipeline.CheckAuthentication) { context ->
        val header = call.request.parseAuthorizationHeader()

        val id = if (header != null &&
                header.authScheme == "Bearer" &&
                header is HttpAuthHeader.Single) {
            verifyToken(header.blob)
        } else {
            null
        }


        if (id != null) {
            // 👇 正常系
            context.principal = AuthorizedUser(id)
        } else {
            // 👇 異常系
            context.challenge("TokenAuthentication", NotAuthenticatedCause.InvalidCredentials) {
                it.success()
                call.respond(HttpStatusCode.Unauthorized, "Invalid credential.")
            }
        }
    }
}

これで終わりです。

Route.me()の呼び出し

Application.kt
fun Application.main() {
    ...
    install(Routing) {
        contentType(ContentType.Application.Json.withCharset(Charsets.UTF_8)) {
            login()
            me()
        }
}

これで/login POSTで払い出したトークンを利用して、/me GETにアクセスすることができます。


リクエストボディをreceiveした値をアノテーション地獄にならずに良い感じにバリデーションする方法ないかなーと探ってます🤔