Kotlin + Spring Boot アプリケーションにJWTを組み込む

#はじめに
　JWT (https://jwt.io/) JSONベースのアクセストークンの仕様です。主に認証で使われ、トークンの改竄検出もできます。今回はトークンの生成や、トークンが正当であるかの検証、トークンをデコードして必要な情報を取ってくる操作をKotlin + Spring Bootで行ってみたいと思います。
　使用するJWTのライブラリはauth0(https://github.com/auth0/java-jwt) のものを用いました。
#雛形作成
Spring Initializr で雛形を作成します。
今回は以下の画像のように雛形を作成しました。
<img width="1680" alt="スクリーンショット 2020-04-27 0.10.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564587/01356e90-bbe7-644e-ce95-d3e884831d8f.png">

一応`gradle.build`も上げておきますね。

```gradle.bulk.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "2.2.6.RELEASE"
	id("io.spring.dependency-management") version "1.0.9.RELEASE"
	kotlin("jvm") version "1.3.71"
	kotlin("plugin.spring") version "1.3.71"
	kotlin("plugin.jpa") version "1.3.71"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_1_8

repositories {
	mavenCentral()
}

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	runtimeOnly("mysql:mysql-connector-java")
	testImplementation("org.springframework.boot:spring-boot-starter-test") {
		exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
	}
   //jwt
    implementation("com.auth0:java-jwt:3.10.2")
   
}

tasks.withType<Test> {
	useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs = listOf("-Xjsr305=strict")
		jvmTarget = "1.8"
	}
}
```

#アプリケーションの仕様

```sql:user.sql
create table if not exists user(
       id int(11) auto_increment not null primary key,
       name varchar(30) not null,
       personal_num int(10) not null,
       password varchar(50) not null
);       
```

　上記のuserテーブルに対し、ユーザの新規登録、ログイン、ユーザの情報照会を行うアプリケーションを作りたいと思います。 
- POST `/user/register`(パスワードとユーザ名を受け付けて新規登録,個人番号は適当に発行&トークンを発行して返す)
- POST `/user/login`(パスワードとユーザ名を受け付けてトークンを発行して返す)
- GET `/user/get?token=xxx`(トークンを受け付けてユーザ名と個人番号を返す)

　トークンには有効期限を設定しておき、有効期限が過ぎると、そのトークンで`/user/get`をアクセスしても何も見れないものとします。有効期限が切れた場合は再度`/user/login`にアクセスしてトークンを発効しなおしますことでまた見られるようになります。

#Controllerの処理を交えながらJWTの解説
DBとか使っていますが、今回の趣旨とはあんまり関係ないのでDB周りの解説は省略します。
Controllerの全容は以下の通りになります。

```UserController.kt
package com.example.jwt.controller

import com.example.jwt.entity.User
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.*
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm.HMAC256
import com.auth0.jwt.exceptions.JWTCreationException
import com.example.jwt.repository.UserRepository
import com.example.jwt.request.UserRequest
import java.util.Calendar;
import java.util.Date;

@Controller
class UserController{
    val privateKey = "secret"
    @Autowired
    lateinit var userRepository : UserRepository
    
    //ユーザの新規登録
    @PostMapping("/user/register")
    @ResponseBody
    fun registerUser(
            @ModelAttribute userRequest : UserRequest
    ) : String {
        var targetUser : User
        if(!userRepository.existsByName(userRequest.name)) {
            var personalNumber = (Math.random() * Math.pow(10.0,7.0)).toInt()
            targetUser = User(userRequest.id, userRequest.name, personalNumber, userRequest.password)
            userRepository.save(targetUser)
        }else{
            return "Duplicated User!"
        }
        return createToken(targetUser)
    }

    //ユーザ情報の照会
    @GetMapping("/user/get")
    @ResponseBody
    fun getUserInformation(
            @RequestParam token : String
    ) : String {
        authenticateToken(token)
        val userName = getUserNameFromToken(token)
        val personalNumber = userRepository.findByName(userName).personalNumber
        return "Your name is $userName, Your number is $personalNumber"
    }

    //ログイン
    @PostMapping("/user/login")
    @ResponseBody
    fun loginUser(
            @ModelAttribute userRequest : UserRequest
    ) : String {
        var targetUser = userRepository.findByName(userRequest.name)
        if(targetUser.password == userRequest.password){
            return createToken(targetUser)
        }
        return "Invalid user name or password!"
    }

    //以下 utils
    fun authenticateToken(token : String){
        try {
            val algorithm: com.auth0.jwt.algorithms.Algorithm = HMAC256(privateKey)
            val verifier: JWTVerifier = JWT.require(algorithm)
                    .withIssuer("auth0")
                    .build()
            verifier.verify(token)
        } catch(e : JWTCreationException) {
            println("Invalid Token!")
        }
    }
    fun createToken(user : User) : String{
        lateinit var token : String
        try {
            val algorithm: com.auth0.jwt.algorithms.Algorithm = HMAC256(privateKey)
            val calendar = Calendar.getInstance()
            calendar.setTime(Date())
            //期限は30秒
            calendar.add(Calendar.SECOND, 30)
            val expireTime = calendar.time
            token = JWT.create()
                    .withIssuer("auth0")
                    .withClaim("name", user.name)
                    .withExpiresAt(expireTime)
                    .sign(algorithm)

        } catch (e : JWTCreationException) {
            //Invalid Signing
        }
        return token
    }

    fun getUserNameFromToken(token : String) : String{
        val decodedToken = JWT.decode(token)
        return decodedToken.getClaim("name").asString()
    }
}

```
##ユーザの新規登録
まず、ユーザの新規登録部分はこの部分です。

```kotlin
 //ユーザの新規登録
    @PostMapping("/user/register")
    @ResponseBody
    fun registerUser(
            @ModelAttribute userRequest : UserRequest
    ) : String {
        var targetUser : User
        if(!userRepository.existsByName(userRequest.name)) {
            var personalNumber = (Math.random() * Math.pow(10.0,7.0)).toInt()
            targetUser = User(userRequest.id, userRequest.name, personalNumber, userRequest.password)
            userRepository.save(targetUser)
        }else{
            return "Duplicated User!"
        }
        return createToken(targetUser)
    }

    fun createToken(user : User) : String{
        lateinit var token : String
        try {
            val algorithm: com.auth0.jwt.algorithms.Algorithm = HMAC256(privateKey)
            val calendar = Calendar.getInstance()
            calendar.setTime(Date())
            //期限は30秒
            calendar.add(Calendar.SECOND, 30)
            val expireTime = calendar.time
            token = JWT.create()
                    .withIssuer("hogehoge")
                    .withClaim("name", user.name)
                    .withExpiresAt(expireTime)
                    .sign(algorithm)
        } catch (e : JWTCreationException) {
            //Invalid Signing 
        }
        return token
    }
```
　コントローラの処理としてはユーザ名とパスワードを入力として、DBとユーザ名の重複がなければ、ユーザ情報のDB登録を行います。その際個人番号はランダムな値を設定します。うまく行けば、最後にトークンを発行してレスポンスとしてトークンの文字列を返しています。
　`createToken`関数が実際にトークンを発行する関数です。
　`val algorithm: com.auth0.jwt.algorithms.Algorithm = HMAC256(privateKey)`でトークンの署名アルゴリズムはHMAC-SHA256を使用して署名することを宣言しています。
署名に用いる秘密鍵ですが、ここでは簡略化のため`val privateKey = "secret"`を用いています。本来はランダムに生成した文字列を環境変数などに格納しておき、環境変数から秘密鍵の指定を行うべきだと思います。
　次に現在時刻から30秒後の時刻を`expireTime`に格納する処理が続きます。
　その後tokenを生成していきます。`withIssur()` にはtokenの発行者の識別しを格納するのが一般的？です。格納する値の型としては文字列なので適当に入れておきます。`withClaim()`はtokenに追加で付加する情報を入れておきます。任意のキーと値を設定できます。ここでは、このトークンで識別できるユーザの名前を入れておきます。`withExpiresAt()` はtokenの有効期限を格納します。ここでは30秒としておきます。`sign`では署名のアルゴリズムを設定します。

正常にトークンの生成が行われなかった場合は例外がスローされます。

##ユーザの情報照会
情報照会部分はこの部分です。

```kotlin
//ユーザ情報の照会
    @GetMapping("/user/get")
    @ResponseBody
    fun getUserInformation(
            @RequestParam token : String
    ) : String {
        authenticateToken(token)
        val userName = getUserNameFromToken(token)
        val personalNumber = userRepository.findByName(userName).personalNumber
        return "Your name is $userName, Your number is $personalNumber"
    }

    fun authenticateToken(token : String){
        try {
            val algorithm: com.auth0.jwt.algorithms.Algorithm = HMAC256(privateKey)
            val verifier: JWTVerifier = JWT.require(algorithm)
                    .withIssuer("auth0")
                    .build()
            verifier.verify(token)
        } catch(e : JWTCreationException) {
            //Invalid Token
        }
    }

    fun getUserNameFromToken(token : String) : String{
        val decodedToken = JWT.decode(token)
        return decodedToken.getClaim("name").asString()
    }
```
　ここではリクエストパラメータに入っているトークンの妥当性を検証し、OKならDBから情報を取得して文字列をレスポンスとして返しています。ただし、ユーザ名に関しては、トークンから引き出してこれるので、わざわざDBにアクセスしなくてもトークンをデコードすることによって取得します。
　トークンの妥当性の確認は`authenticateToken`で行っています。事前に設定しておいた秘密鍵を用いて改竄がないかどうか検出します。またそのトークンが有効期限内であるかも確認します。もし改竄や有効期限切れを検出したら、例外をスローします。

##ログイン
```kotlin
  @PostMapping("/user/login")
    @ResponseBody
    fun loginUser(
            @ModelAttribute userRequest : UserRequest
    ) : String {
        var targetUser = userRepository.findByName(userRequest.name)
        if(targetUser.password == userRequest.password){
            return createToken(targetUser)
        }
        return "Invalid user name or password!"
    }
```
最後にログインですが、これは入力されたユーザ名とパスワードが一致すれば、トークンを発効して、レスポンスとしてトークンを返します。そうでなければユーザ名かパスワードが間違っている旨をレスポンスとして返します。
#動作確認
それでは動作を確認していきます。

```sh
./gradlew bootRun
```

まずはユーザの新規登録から

```sh
curl -X POST localhost:8080/user/register -d name=user1 -d password=pass
%>　eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJhdXRoMCIsIm5hbWUiOiJ1c2VyMiIsImV4cCI6MTU4NzkxOTgyMH0.aDF8FK-fGgl4SKBnxSLQPuH854rJwFL1qNHjlQBtkzs
```
このようなトークンが返されます。
このトークンを認証して、ユーザの情報照会を行います。トークンの有効期限は30秒なので急ぎましょう(笑

```sh
curl localhost:8080/user/get\?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJhdXRoMCIsIm5hbWUiOiJ1c2VyMiIsImV4cCI6MTU4NzkxOTgyMH0.aDF8FK-fGgl4SKBnxSLQPuH854rJwFL1qNHjlQBtkzs
%> Your name is user1, Your number is 16362
```
きちんと情報が取れていますね！
トークン発効後30秒が経過すると

```sh
curl localhost:8080/user/get\?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJhdXRoMCIsIm5hbWUiOiJ1c2VyMiIsImV4cCI6MTU4NzkxOTgyMH0.aDF8FK-fGgl4SKBnxSLQPuH854rJwFL1qNHjlQBtkzs
%> {"timestamp":"2020-04-26T16:56:53.710+0000","status":400,"error":"Bad Request","message":"Required String parameter 'token' is not present","path":"/user/get"}%
```
またトークンを一部書き換えると

```sh
{"timestamp":"2020-04-26T16:59:33.626+0000","status":500,"error":"Internal Server Error","message":"Last encoded character (before the paddings if any) is a valid base 64 alphabet but not a possible value","path":"/user/get"}
```
トークンを再発行してそれを用いて情報照会をすると

```sh
curl localhost:8080/user/login -d name=user1 -d password=pass > token
curl localhost:8080/user/get\?token=`cat token`
%> Your name is user1, Your number is 16362
```
きちんと行けてますね！
ユーザ名だけ改竄してきちんと弾かれるかやってみたかったのですが、やり方わかりませんでした。すみません。。。

#最後に
Spring BootのでJWTの基礎的な使い方を組み込んだアプリケーションを作ってみました。
まだまだJWTについては上辺だけの理解しかできていないのでこれからもう少し勉強したいと思います。
改竄の方法はやり方いい方法ありましたら教えていただけますと幸いです。