blog.takurinton.dev

JWTについて学ぶ

2021-01-11

はじめに

どうも,僕です.今日はGolangでJWTを実装したけどちょっとつまづいたことが多かったので記事にしたいと思います.
これ実装したのだいぶ前なので思い出しながら頑張っていこうと思います.

JWTについて

そもそもJWTとはなんなのかについて簡単におさらいをしておきたいと思います.
JWTとは,JSON Web Tokenの略で属性情報(Claim)をJSONの中に丸め込むことで個人を識別することができるようにした仕様のことです.
こいつを使うと署名や暗号化ができURL-safeになるということなのです.
JWTと書いてジョットと読みます.ショットじゃないです.誰が酒カスやねん.

もう少し詳しく

※ 以下で説明する内容の中身は jwt.io で見ることができます.

JWTの形は決まっています.
ピリオドで区切られていて,前から

  • ヘッダー
  • ペイロード
  • 署名

に分かれています.
例えば,JWTを発行するエンドポイントにリクエストを投げて以下のレスポンスが返ってきたとします.

{'token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MTAyNjQyMTksInVzZXIiOiJfdGFrdXJpbnRvbiJ9.BnfryXdt7q4MrA_ojtTz0PdX_qhr7S2ULQb-vekGV48'}

このときのtokenのバリューがJWTになります.一見ランダムな文字列に見えますが実は意味があります.

先ほど言ったように,前半からピリオド区切りになっているので

意味 token
ヘッダー eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
ペイロード eyJleHAiOjE2MTAyNjQyMTksInVzZXIiOiJfdGFrdXJpbnRvbiJ9
署名 BnfryXdt7q4MrA_ojtTz0PdX_qhr7S2ULQb

のような形に分割することができます.
それぞれに意味があるので説明していきます.

ヘッダー

ヘッダーには認証情報の署名検証を行うための重要な情報が格納されています.
文字列の形式にもルールがあり,キーとバリューを表現したJSONをbase64urlの形にエンコードした形になっています.
また,注意しておく点として,base64urlで使用する記号はURIで使用する記号と被っている部分があるので"+"を"-"に,"/"を"_"、"="を""に変換して扱っています.
ここをデコードするとJSONの形式にすることができます.

{
  "typ": "JWT",
  "alg": "HS256"
}

こんな感じでデコードすることができます.typはJWTを使用しているということを,algではHS256というアルゴリズムを使用してトークンを発行していることを表しています.

ペイロード

ペイロードとは属性情報を格納してある部分になっています.
Claimとして保存しています.必須ではないですが,ここに何もないとなんのための認証だよと(僕の経験が浅いだけかもしれない)
ここに格納されている情報はヘッダーと同様にbase64urlを使用してエンコードした文字列なので同じ手法でデコードすることができます.

{
  "exp": 1610269708,
  "user": "_takurinton"
}

このような形で取り出すことができます.

署名

署名はエンコード済みのヘッダーとエンコード済みのペイロードを入力値として指定したアルゴリズムで署名を行ったものをbase64urlエンコードすることによって作成されます.
署名パートの文字列を使用してID Tokenの正当性を確かめることによって認証情報が正しいかどうかを判断することができます.

Golangで実装してみる

以上のことを踏まえた上でGolangでJWTを返すAPIサーバを実装してみようと思います.
想定している完成形は

  • ユーザー登録ができる
  • ユーザー情報を投げたらJWTを返してくれる
  • JWTを添えてリクエストを投げるとユーザー情報を返す

です.

また,DBについては

  • id(pk)
  • username
  • mail
  • password
  • is_active
  • is_admin

のフィールドを持つこととします.
idはオートインクリメント,usernameとpasswordは必須,usernameはユニークなキーとします.
アプリケーションの構成としてはmodel, controller, serviceの3つの層に分けてあり,modelではstructの定義,controllerでは整形,serviceではDBとのフェッチを行うことをそれぞれの役割としています.
また,認証を行う部分をmiddlewareとしてパッケージ化しています.

JWTを扱う関数を定義する

まずはJWTを発行/認証するための関数を定義していきます.
jwt-go を使用していきます.

go get github.com/dgrijalva/jwt-go

これを使用するとJWTをいい感じに生成してくれるようになります.フルスタックなフレームワークとかだと勝手にやってくれるものが多いですが,こいつは部分的に使用するライブラリみたいな感じ.
使用する暗号化アルゴリズムやその他もろもろを自分で定義することができます.
ちょっといい感じにやっていきたいと思います.

// middleware/auth.go

package middleware

import (
    "fmt"
    "net/http"
    "portfolio/service"
    "strings"

    "github.com/dgrijalva/jwt-go"
    "github.com/gin-gonic/gin"
)

// リクエストのbodyからtokenを取得する
func getToken(c *gin.Context) (string, error) {
    err := fmt.Errorf("Error: %s", "invalid authrization")

    authHeader := c.GetHeader("Authrization")
    if authHeader == "" {
        return "", err
    }

    token := strings.Split(authHeader, " ")  // この取り出し方相当雑な気がするので誰かコメントください.
    if token[0] != "Bearer" {
        return "", err
    }

    return token[1], nil
}

// tokenをパースして確認する
func parseToken(tokenString string) (parsedToken *jwt.Token, err error) {
    parsedToken, err = jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
                // 複合化するためのキー(任意の文字列,今回は環境変数に格納してる)を指定    
                // 下で出てくる暗号化する際のkeyと同じ文字列である必要がある    
        return []byte(SECRET_KEY), nil
    })

    if err != nil {
        return
    }
    if !parsedToken.Valid {
        return
    }
    return
}

// ユーザーをチェックする
func checkUser(token *jwt.Token) (isAuth bool) {
    user := service.User{}
    // 下で出てきますがmap型でtokenを作成してるのでここもmap型で受け取る必要があります
    _username := token.Claims.(jwt.MapClaims)["user"]
    username := fmt.Sprintf("%s", _username)  // 文字列に変換
    _, err := user.Me(username)  // serviceで定義します.暫しお待ちを
    if err != nil {
        return
    }

    isAuth = true
    return
}

// 上で定義した関数を使用して認証情報を持っているユーザーかどうかを判断する
func AuthMiddlewere(c *gin.Context) {
    token, err := getToken(c)
    if err != nil {
        c.JSONP(http.StatusUnauthorized, gin.H{})
    }

    parsedToken, err := parseToken(token)
    if err != nil {
        c.JSONP(http.StatusUnauthorized, gin.H{})
    }

    isAuthUser := checkUser(parsedToken)
    if !isAuthUser {
        c.JSONP(http.StatusUnauthorized, gin.H{})
    }
}

こんな感じです.
getTokenはリクエストからトークンを取得してトークンだけを抽出してくれるように定義した関数です. parseTokenはそのトークンをパースする関数です.さらにcheckUserで認証の情報が正しいかどうかを確認します.ここではUserの情報からトークンにマッチするユーザーネームを取得して自分の情報として返してくれます.ここでパースできなかったりパースした時間が有効期限切れだったりユーザーネームがマッチングしなかったりするとエラーを返して認証していないユーザーということでリクエストはエラーになるという感じです.

さらにトークンを作成する関数も追加しておきます.

// middleware/auth.go

func createToken(username string) (string, error) {
    token := jwt.New(jwt.GetSigningMethod("HS256")) // JWT
    token.Claims = jwt.MapClaims{
        "user": username,
        "exp":  time.Now().Add(time.Hour * 1).Unix(),
    }

    var secretKey = SECRET_KEY
    tokenString, err := token.SignedString([]byte(secretKey))
    if err != nil {
        return "", err
    }
    return tokenString, nil
}

こんな感じです. createTokenではJWTを発行することができます.
jwt.MapClaimsを使用するとmapで認証情報を付与することができます.最初ここにpasswordを指定していてここにパスワードあったら意味ねえやん!ってなったのが懐かしいです.また.expではトークンの有効期限を指定していて,1時間で切れるようにしています.無限にしてもよかったんですけど安全じゃなくなっちゃうのでやめときました.とは言っても1時間で切れるということはブログ書いてる途中とかに飛ぶ可能性もあるのでそこは注意しないといけないですよね.フロントエンドから10秒ごとに自動保存のリクエスト投げるとかやっておく必要ありそう.

また, ここ で定義されているようにmapのバリューはなんでもいけるみたいなのでbyte型でも大丈夫かなみたいな人も安心して使用することができます(ハッシュ化したパスワード入れようとしてた人の顔)

SignedStringではsecretKeyで指定したキー(任意の文字列)を使用して暗号化することができます.
ここまできてエラーがなければJWTを返すようにしています.

トークン周りの実装は終わったので上で定義した関数を使用して下で諸々の実装をしていきます.

モデル

まずはモデルのストラクトを作成していきます.

// model/user.go 

package model

import "time"

type User struct {
    Id          int64     `gorm:"primary_key" json:"id"`
    Username    string    `json:"username"`
    Password    []byte    `json:"password"`
    Email       string    `json:"email"`
    IsActive    bool      `json:"is_active"`
    DateJoined  time.Time `json:"date_joined"`
}

type LoginResponse struct {
    Token string `json:"token"`
}

type LoginRequest struct {
    Username string `json:"username"`
    Password string `json:"password"`
}

こんな感じです.上記で説明した通りになっています.いい感じ.
LoginResponseはトークンを返すための,LoginRequestはログインの際の情報を受け取るためのストラクトになっています.

ルーティングする

今回はフレームワークとしてginを使用して書いていきたいと思います.
まずはエンドポイントを作成していきたいと思います.

// main.go
package main

import (
    "net/http"
        "portfolio/controller"
    "portfolio/middleware"
    "github.com/gin-gonic/gin"
)

func main() *gin.Engine {
    r := gin.Default()
    // var c *gin.Context
    config := cors.DefaultConfig()
    config.AllowOrigins = []string{"*"}
    r.Use(cors.New(config))

    userrouter := r.Group("/admin/user") {
    userrouter.POST("/register", controller.CreateUser)
    userrouter.POST("/login", controller.Login)
    }

    http.ListenAndServe(":8080")
}

こんな感じに簡単にルーティングをしていきます.

コントローラ

次にコントローラを定義していきます.

// controller/user.go 

package controller

import (
    "net/http"
    "portfolio/model"
    "portfolio/service"

    "github.com/gin-gonic/gin"
)

func GetUser(c *gin.Context) {
    u := service.User{}

    user, err := u.GetUser()
    if err != nil {
        c.JSONP(http.StatusInternalServerError, gin.H{})
    }

    c.JSONP(http.StatusOK, gin.H{
        "user": user,
    })
}

func CreateUser(c *gin.Context) {
    u := service.User{}

    var login model.LoginRequest
    c.BindJSON(&login)

    if err := u.CreateUser(login.Username, login.Password); err != nil {
        c.JSONP(http.StatusInternalServerError, gin.H{"message": "internal server error"})
    } else {
        c.JSONP(http.StatusCreated, gin.H{
            "username": login.Username,
        })
    }
}

func Login(c *gin.Context) {
    username := c.PostForm("username")
    password := c.PostForm("password")

    // var login model.LoginRequest
    // c.BindJSON(&login)

    token, err := service.Login(username, password)
    if err != nil {
        c.JSONP(http.StatusInternalServerError, gin.H{"message": err})
    } else {
        c.JSONP(http.StatusCreated, model.LoginResponse{Token: token})
    }
}

こんな感じで定義しています.
CreateUserではフォームの値を受け取ってそれを使用してserviceのCreateUserを使用してユーザーをDBに書き込んでいます.
Loginも同じような形になっていて,DBのフェッチに関してはservice側で書き込む形になっています.

サービス

次にサービスを定義していきます.
コントローラまでは通常の実装でしたが,ここではJWTを発行したりそれを使用して認証を行ったりします.めんどくさい部分の実装です.
先に全体像を示します.

// service/user.go

package service

import (
    "log"
    "portfolio/model"
    "time"

    "github.com/dgrijalva/jwt-go"
    "golang.org/x/crypto/bcrypt"
)

type User struct{}

func (u User) Me(username string) (me model.User, err error) {
    db, err := DBConn()
    if err != nil {
        return
    }
    defer db.Close()

    if err = db.Select("username, is_active").Table("auth_user").Where("username = ?", username).Find(&me).Error; err != nil {
        return
    }

    return
}

func (u User) GetUser() (users []model.User, err error) {
    db, err := DBConn()
    if err != nil {
        return
    }
    defer db.Close()

    if err = db.Select("username, is_active, is_superuser").Table("auth_user").Find(&users).Error; err != nil {
        return
    }

    return
}

func (u User) CreateUser(username, password string) (err error) {
    db, err := DBConn()
    if err != nil {
        return
    }
    defer db.Close()

    _pw := []byte(password)
    hashed, _ := bcrypt.GenerateFromPassword(_pw, 10)
    if err = bcrypt.CompareHashAndPassword(hashed, _pw); err != nil {
        return
    }

    t := time.Now()
    user := model.User{Username: username, Password: hashed, IsActive: true, IsStaff: true, IsSuperuser: true, DateJoined: t}
    if err := db.Table("auth_user").Create(&user).Error; err != nil {
        return err
    }

    return
}


func Login(username, password string) (token string, err error) {
    db, err := DBConn()
    if err != nil {
        return
    }
    defer db.Close()

    var u model.User
    if err = db.Table("auth_user").Select("password").Where("username = ?", username).Find(&u).Error; err != nil {
        return
    }

    if err = bcrypt.CompareHashAndPassword(u.Password, []byte(password)); err != nil {
        log.Println(err)
        return
    }

    token, err = middleware.createToken(username)
    if err != nil {
        return
    }
    return token, nil
}

こんな感じになっています.
tokenの発行や認証を行っている部分です.
先に示したmodelではパスワードはbyte型になっていました.生のパスワードをDBに突っ込むのはあり得ないのでアプリケーション側でいい感じに実装する必要があります.そういえばSpotifyのパスワードって生のパスワードが流出したんだっけ.どんな管理方法してるんですかね.てかDjangoならいい感じにハッシュ化してくれるはずなんだけど

パスワードのハッシュ化には bcrypt というGoの標準のライブラリを使用しました.

createUser

まず,createUserではDBにハッシュ化したパスワードを挿入するためにパスワードをハッシュ化必要があります.ハッシュ化するためには GenerateFromPassword を使用します.

_pw := []byte(password)
hashed, _ := bcrypt.GenerateFromPassword(_pw, 10)

こんな感じで実装しています.passwordは文字列できていますがGenerateFromPasswordの引数はbyte型なので変換してから突っ込みます.また,第二引数にはハッシュ化するためのコストが入ります.コストとはストレッチングの回数を決める為の数値のことを表しています.コストに関してはQiitaの go bcryptのコードを読む がとても分かりやすかったです.

Login

次にLoginではパスワードを確認する必要があるので戻す必要があります.そのような時にはCompareHashAndPassword を使用します.

if err = bcrypt.CompareHashAndPassword(u.Password, []byte(password)); err != nil {
    log.Println(err)
    return
}

こんな感じで実装をしています.CompareHashAndPasswordでは引数としてとったパスワードとハッシュ化したパスワードが一致しているかどうかを表してくれます.一致していなかったらエラーを返してここでreturnしてしまい,何事もなかったら次の処理(今回で言うとトークン発行)に進むことで安全に認証情報を管理することができます.

動作確認

ここまでくれば大体の実装は完了しています.しかしまだ動作確認をしていないのでリクエストを投げてみたいと思います.ちょっと見せられない部分もあるのでログインしたらトークンを発行してくれる所の確認だけしたいと思います.
自分はリクエストを投げるときはpythonのrequestsが便利なのでそれを使います.curlよりも簡単だと思ってます.(知らんけど)

import requests, json

# request body
data = {'username': 'takurinton', 'password': 'password'} 
r = requests.post('http://localhost:8080/admin/user/login', data=data)

print(r.json())

これを実行すると

{'token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MTAyNjk3MDgsInVzZXIiOiJfdGFrdXJpbnRvbiJ9.jlZOiRav9CIrcxpbK6GjNJOCBkYuQYb4fjHrpJW1L6U'}

いい感じにトークンを取得することができました!

[GIN] 2021/01/11 - 10`:36:59 | 201 |  343.679354ms |  ::1 | POST     "/admin/user/login"

ログも良さそう!

このトークンを使用してリクエストに制限をかけたりユーザーを識別したりすることができます.便利〜〜〜

まとめ

ここまで長々と書いてきましたが,他のフレームワークならやってくれる部分を手動実装しないといけないのが楽しかったなあと感じました.また,JWTの暗号化アルゴリズムについては触れていませんがそこにも興味が湧いたのでそこもどんどん調べていきたいと思います.
また,最近ではfirebaseなどの外部に任せることも多いですが(僕もそうすることが多い),こうやって自分で実装することによって気づくことができる部分も多く発見することができたのでこれからこのように普段何気なく実装している部分の中身を調べて自分で実装してみるという機会をどんどん増やしていきたいと思います.
まとめではなくて感想になってしまいましたがここまで読んでいただきありがとうございました.