この記事では、KtorのAuthを利用してJWTでの認証を実装します。
OAuth書こうかなと思いましたが、こちらの記事が詳しいです。(Ktorタグぼっちじゃなくなった👏👏👏)
現状、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
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を追加します。
全体的にはたぶんこんな感じ。
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でemail
とpassword
を受け取ってそれらを検証し、正しければユーザー情報とアクセストークンを返します。
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 { }
で処理する部分)の実行前に検証が行われるようにします。
やることは以下です。
- 本処理の前に割り込む
- Request bodyの受け取り
-
email
でユーザーを探す -
password
が一致するか検証 -
password
が一致した場合、本処理に認証情報を渡す - ユーザーが見つからない、または
password
が一致しない場合、401 Unauthorized
を返し処理を終了する
本処理の前に割り込む
Ktorではintercept()
メソッドを利用することで、各エンドポイントの処理の前後に処理を割り込ませることができます。
認証処理は本処理よりも前に実行される必要があるので、ApplicationCallPipeline.Call
かそれ以前に実行されるPipelinePhase
を引数とするintercept
として実装しなければいけません。
デフォルトのPipelinePhase
と本処理の順番はこうです。(ほかにも見つけた気がしないでもないけど忘れた)
ApplicationCallPipeline.Infrastructure
ApplicationCallPipeline.Call
- 本処理
ApplicationCallPipeline.Fallback
authentication {}
の中では、AuthenticationPipeline.RequestAuthentication
とAuthenticationPipeline.CheckAuthentication
というPipelinePhase
が使用できるようになり、上記の順番に追加されます。
ApplicationCallPipeline.Infrastructure
-
AuthenticationPipeline.CheckAuthentication
👈 -
AuthenticationPipeline.RequestAuthentication
👈 ApplicationCallPipeline.Call
- 本処理
ApplicationCallPipeline.Fallback
どちらを使うべきかはよくわかりませんが、ログインは「認証のリクエスト」、トークンの検証は「認証の検証」っぽい気がしたので、
ログイン情報の検証: AuthenticationPipeline.RequestAuthentication
トークンの検証: AuthenticationPipeline.CheckAuthentication
を使うことにします。
// ...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
なので呼び出し時に型パラメータを書くか、推論させる必要があります。
// ...Route.login etc...
fun AuthenticationPipeline.emailPasswordAuthentication() {
intercept(AuthenticationPipeline.RequestAuthentication) { context ->
// 👇
val request = call.receive<LoginRequest>()
// TODO 検証処理
}
}
ちなみにLocationsではRequest bodyを受け取れないように意図的に実装しているみたいでした。
使い分けを明確にしているのかな。
emailとpasswordの検証
それっぽいメソッドがあると思ってください。
// ...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`を返し処理を終了する
}
}
}
正常系:本処理に認証情報を渡す
リクエストされたemail
とpassword
が正しい組み合わせの場合、認証情報(ここではユーザー情報)を本処理や後続のintercept
に渡すために、context
のプロパティに代入します。
認証情報は、Authの提供するprincipal
に入れるようです。
// ...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`を返し処理を終了する
}
}
}
principal
はPrincipal
インターフェースを実装したクラスしか代入できないので、User
クラスにPrincipal
インターフェースを実装します。
...
// ユーザー情報
data class User(val id: Int, val name: String, val email: String,
@JsonIgnore val encryptedPassword: String): Principal // 👈
...
正常系の実装はこれだけです。
異常系:401 Unauthorized
を返し処理を終了する
ここが正直よくわかっていませんが、Authの提供する認証のメソッド(oauthAtLocation()
など)はAuthenticationContext.challenge()
というメソッドの中でレスポンスを返すようになっていました。
それに倣って実装します。
// ...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 {}
内で呼び出します。
fun Route.login() {
route("/login") {
authentication {
// 👇
emailPasswordAuthentication()
}
post {
// TODO 認証情報の取得とトークンの生成を行う
call.respond(LoginResponse(user, token))
}
}
}
...
本処理の実装
post { }
の中を書いて行きます。
認証情報の受け取り
emailPasswordAuthentication()
で設定した認証情報を受け取ります。
ApplicationCall.authentication.principal()
またはApplicationCall.principal()
で受け取ることができます。両者に違いはありません。
ApplicationCall.receive()
と同じく、呼び出し時に型パラメータを書くか、推論させる必要があります。
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時間で切れるトークンです。
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()
}
呼び出して終わりです。全体的にはこんな感じになりました。
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()
の呼び出し
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()
を見て行きます。
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
を利用して認証を行います。
やることは以下です。
- 本処理の前に割り込む
- Authorization headerからトークンを取得する
- トークンを検証する
- トークンが有効な場合、本処理に認証情報を渡す
- トークンが存在しない、または無効な場合、
401 Unauthorized
を返し処理を終了する
本処理の前に割り込む
ログインのところで書いたように、トークンの検証ではAuthenticationPipeline.CheckAuthentication
をintercept()
の引数に渡すことにします。
fun AuthenticationPipeline.tokenAuthentication() {
intercept(AuthenticationPipeline.CheckAuthentication) { context ->
// TODO
}
}
Authorization headerからトークンを取得する
Authorization headerの取得にはそれ用のメソッド
ApplicationRequest.parseAuthorizationHeader()
がAuthによって提供されています。
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
を返します。
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
}
}
呼び出します。
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 正常系・異常系の処理
}
}
正常系・異常系の処理をする
ログインとほとんど同じです。Int
はPrincipal
を実装していないの適当なクラスでラップします。
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()
の呼び出し
fun Application.main() {
...
install(Routing) {
contentType(ContentType.Application.Json.withCharset(Charsets.UTF_8)) {
login()
me()
}
}
これで/login
POSTで払い出したトークンを利用して、/me
GETにアクセスすることができます。
リクエストボディをreceive
した値をアノテーション地獄にならずに良い感じにバリデーションする方法ないかなーと探ってます🤔