Ginでgin-jwtを使って簡単にAPIに認証機能を実現する

はじめに

前回のエントリ で、API に JWT 認証の仕組みを実現してみました。

それなりのコード量となりましたが、JWT 認証に関わらず認証は多くのサービスで必須といっても良い機能です。

すでにライブラリがありそうだと思って探すとやはりありました。

こちらを使って JWT 認証機能を実現してみます。

gin-jwt ( JWT Middleware for Gin Framework ) とは

前回使ったモジュールである jwt-go を内部で利用しています。

Gin フレームワークの Middleware 機能を利用しており、簡単に導入できます。

また、コントローラロジックと JWT のチェックロジックを切り離して見通しを良くできます。

リクエストフィルタとして JWT のチェックを行うだけでなく、 ログイントークンのリフレッシュ のためのコントローラ関数も組み込みで用意されており、機能が豊富です。

gin-jwt を使って JWT 認証を実装してみる

いつものように使ってみます。

プロジェクトの作成

プロジェクトフォルダの作成、移動、モジュールの初期化を行います。
その後 git-jwt の version2 をインストールします。

$ mkdir gin-jwt

$ cd gin-jwt

$ go mod init genzouw/gin-jwt
go: creating new go.mod: module genzouw/gin-jwt

$ go get github.com/appleboy/gin-jwt/v2

main.go 実装

main.go を作成します。

$ vi main.go

今回提供する API エンドポイントは以下になります。
2つ目、3つ目の API は認証必須であり、TOKEN が必要です。

  • /api/login : 指定された "email" 、"password" が正しければ TOKEN を取得
  • /api/refresh_token : [ 認証必須 ] 有効期限を延長した TOKEN を取得
  • /api/users/me : [ 認証必須 ] ログイン中ユーザー情報を取得

コードは以下のようになります。

JWT 認証がシンプルに実装されていることがわかります。

簡単なコードの解説をしておきます。

  1. newJwtMiddleware() 関数で Middleware の実体を取得
  2. Gin のルーティングに Middleware を設定
  3. その他、存在しないパスにリクエスされた場合を router.NoRoute() ルーティングで拾い、404 レスポンスを返す

JWT の設定(Realm 名、Secret Key、有効期限)などは newJwtMiddleware() 関数に押し込んでいすが必要があれば設定変更が可能です。

package main

import (
    "log"
    "net/http"
    "time"

    jwt "github.com/appleboy/gin-jwt/v2"
    "github.com/gin-gonic/gin"
)

func main() {
    jwtMiddleware, err := newJwtMiddleware()

    if err != nil {
        log.Fatal(err)
        return
    }

    r := gin.Default()

    r.NoRoute(func(c *gin.Context) {
        c.JSON(http.StatusNotFound, gin.H{"code": "PAGE_NOT_FOUND", "message": "Page not found"})
    })

    api := r.Group("/api")
    {
        api.POST("/login", jwtMiddleware.LoginHandler)
        api.GET("/refresh_token", jwtMiddleware.RefreshHandler)

        me := api.Group("/users/me").Use(jwtMiddleware.MiddlewareFunc())
        {
            me.GET("", func(c *gin.Context) {
                userID := userIdInJwt(c)
                // TODO : 一般的にはデータベースやストレージ、SaaSからuserIDを元にユーザー情報を取得する
                c.JSON(http.StatusOK, gin.H{
                    "userID": userID,
                })
            })
        }
    }

    port := "8000"

    if err := http.ListenAndServe(":"+port, r); err != nil {
        log.Fatal(err)
    }
}

func userIdInJwt(c *gin.Context) string {
    claims := jwt.ExtractClaims(c)
    userID := claims[jwt.IdentityKey]
    return userID.(string)
}

func newJwtMiddleware() (*jwt.GinJWTMiddleware, error) {
    jwtMiddleware, err := jwt.New(&jwt.GinJWTMiddleware{
        Realm:      "test zone",
        Key:        []byte("secret key"),
        Timeout:    time.Hour * 24,
        MaxRefresh: time.Hour * 24 * 7,
        SendCookie: false,
        PayloadFunc: func(data interface{}) jwt.MapClaims {
            return jwt.MapClaims{
                jwt.IdentityKey: data,
            }
        },
        Authenticator: func(c *gin.Context) (interface{}, error) {
            var l loginRequest

            if err := c.ShouldBind(&l); err != nil {
                return "", jwt.ErrMissingLoginValues
            }

            if !l.isValid() {
                return "", jwt.ErrFailedAuthentication
            }

            return l.Email, nil
        },
    })

    if err != nil {
        return nil, err
    }

    err = jwtMiddleware.MiddlewareInit()

    if err != nil {
        return nil, err
    }

    return jwtMiddleware, nil
}

type loginRequest struct {
    Email    string `form:"email" json:"email" binding:"required"`
    Password string `form:"password" json:"password" binding:"required"`
}

func (l loginRequest) isValid() bool {
    // TODO : 一般的にはデータベースやストレージ、SaaSから取得する
    passwords := map[string]string{
        "admin@gmail.com": "admin",
        "test@gmail.com":  "test",
    }

    return passwords[l.Email] == l.Password
}

驚くほどシンプルに実装できました。

注意

今回はユーザーの登録機能は実装していません。
ユーザー情報もコード上にベタがきされた admin@mail.comtest@gmail.com ユーザーしか扱っておらず、このいずれかのユーザーでしかログインに利用できない点に注意が必要です 。

多くの場合、データベースに化膿されているユーザー情報と突き合わせすることになるでしょう

動作確認

main.go を起動します。

$ go run main.go

今までは動作確認のためにcurl コマンドを使っていましたが、最近は http コマンドを使っています。

今回もhttp コマンドを使おうと思います。
もちろんcurl コマンドでも問題ありませんし、ブラウザでも構いません。

異常ケースから試してみます。

# 未認証の状態でのアクセス
$ http --json GET localhost:8000/api/refresh_token
HTTP/1.1 401 Unauthorized
Content-Length: 45
Content-Type: application/json; charset=utf-8
Date: Thu, 30 Mar 2023 09:45:09 GMT
Www-Authenticate: JWT realm=test zone

{
    "code": 401,
    "message": "auth header is empty"
}

# 未認証の状態でのアクセス
$ http --json GET localhost:8000/api/users/me
HTTP/1.1 401 Unauthorized
Content-Length: 45
Content-Type: application/json; charset=utf-8
Date: Thu, 30 Mar 2023 09:45:21 GMT
Www-Authenticate: JWT realm=test zone

{
    "code": 401,
    "message": "auth header is empty"
}

# 存在しないパスへのアクセス
$ http --json GET localhost:8000/hello
HTTP/1.1 404 Not Found
Content-Length: 52
Content-Type: application/json; charset=utf-8
Date: Thu, 30 Mar 2023 09:45:30 GMT

{
    "code": "PAGE_NOT_FOUND",
    "message": "Page not found"
}

# パスワード誤り
$ http --json POST localhost:8000/api/login email=test@gmail.com password=DUMMY
HTTP/1.1 401 Unauthorized
Content-Length: 55
Content-Type: application/json; charset=utf-8
Date: Thu, 30 Mar 2023 09:50:51 GMT
Www-Authenticate: JWT realm=test zone

{
    "code": 401,
    "message": "incorrect Username or Password"
}

正常ケースを試してみます。

  1. ログインし、トークンを取得
  2. ログインユーザー情報の取得
  3. トークンのリフレッシュ
# ログイン
$ http --json POST localhost:8000/api/login email=test@gmail.com password=test
HTTP/1.1 200 OK
Content-Length: 232
Content-Type: application/json; charset=utf-8
Date: Thu, 30 Mar 2023 09:51:11 GMT

{
    "code": 200,
    "expire": "2023-03-31T18:51:11+09:00",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODA3NzQ2NzEsImlkZW50aXR5IjoidGVzdEBnbWFpbC5jb20iLCJvcmlnX2lhdCI6MTY4MDE2OTg3MX0.Ggjzpb7LYMX1AUYWAN0SeWSXSg8PXqg10fa1tZ57sSc"
}

# ログインユーザー情報の取得
$ http --json GET localhost:8000/api/users/me --auth-type=bearer --auth="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODA3NzQ2NzEsImlkZW50aXR5IjoidGVzdEBnbWFpbC5jb20iLCJvcmlnX2lhdCI6MTY4MDE2OTg3MX0.Ggjzpb7LYMX1AUYWAN0SeWSXSg8PXqg10fa1tZ57sSc"
HTTP/1.1 200 OK
Content-Length: 27
Content-Type: application/json; charset=utf-8
Date: Thu, 30 Mar 2023 09:51:40 GMT

{
    "userID": "test@gmail.com"
}

# トークンリフレッシュ
$ http --json GET localhost:8000/api/refresh_token --auth-type=bearer --auth="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODA3NzQ2NzEsImlkZW50aXR5IjoidGVzdEBnbWFpbC5jb20iLCJvcmlnX2lhdCI6MTY4MDE2OTg3MX0.Ggjzpb7LYMX1AUYWAN0SeWSXSg8PXqg10fa1tZ57sSc"
HTTP/1.1 200 OK
Content-Length: 232
Content-Type: application/json; charset=utf-8
Date: Thu, 30 Mar 2023 09:51:51 GMT

{
    "code": 200,
    "expire": "2023-03-31T18:51:51+09:00",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODA3NzQ3MTEsImlkZW50aXR5IjoidGVzdEBnbWFpbC5jb20iLCJvcmlnX2lhdCI6MTY4MDE2OTkxMX0.hfXHxGoP4GRe5QL6JxfQ27RiKBFzY3wN163rVh9V3Kw"
}

正しく動作しています。
簡単に実装できて満足です。

ひとこと

Go 言語で、Gin フレームワーク上に JWT 認証の API を実装できました。
便利ですね。

Posted by genzouw