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 をインストールします。

1
2
3
4
5
6
7
8
$ 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 を作成します。

1
$ 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() 関数に押し込んでいすが必要があれば設定変更が可能です。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
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.com 、test@gmail.com ユーザーしか扱っておらず、このいずれかのユーザーでしかログインに利用できない点に注意が必要です 。

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

動作確認

main.go を起動します。

1
$ go run main.go

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 未認証の状態でのアクセス
$ 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. トークンのリフレッシュ
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# ログイン
$ 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 を実装できました。 便利ですね。