2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go開発環境でJWTの導入

Posted at

はじめに

今回はGo言語で作ったAPIにJWTを実装していきます。
要素検証のみのため簡単な構成で進めます。

JWTとは

JSON Web Tokenの頭文字を取ってJWT(ジョットと読む)と呼びます。
これは情報を安全に送受信するためのオープンスタンダード(RFC 7519)です。短くてコンパクトなJWTは、HTTPヘッダーやクエリストリングで確実に情報を送ったり、受け取ったりすることが可能です。主にユーザー認証や情報交換のために使われます。JWTは「ヘッダー」「ペイロード」「シグネチャー」の3部分で構成されており、それぞれがBase64Urlでエンコードされています。

詳しくはこちら。

検証環境

こちらの記事で構築した環境を利用しました。

前提条件

ログイン機能の実装が完了しており、次のコマンドでログイン用のアカウントが作成されていることを前提としています。

sign up
curl -X POST "http://localhost:8080/signup" -H "Content-Type: application/json" -d '{"user_id": "login_user", "password": "Password_"}'

ディレクトリ構成

最終的にはこのような形になります。検証環境の構成と同じです。

go-gin-gorm
├── tmp
├── .air.toml
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
├── libraries
│       └ crypto.go
└── main.go

実行条件

Golang v1.22.3
Gin v1.10.0
GORM v1.25.10
MySQL 8.0.29
jwt-go v3.2.0

jwt-goの導入

GOの開発環境( Docker起動後に docker compose exec go bash した先 )で次のコマンドを実行します。

$ go get github.com/dgrijalva/jwt-go@v3.2.0

これで準備完了です。

アクセストークンの生成

まずはログイン機能を実装した時の処理を利用してアクセストークンを生成しましょう。
こちらの記事と重複する部分がありますが、 main.go の全文を載せておきます。

main.go
package main

import (
    "github.com/gin-gonic/gin"
    "net/http"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
    "go_gin_gorm/libraries"
    "fmt"
    "time"
    "github.com/dgrijalva/jwt-go"
)

type User struct {
    Id int
    UserId   string
    Password string
}

type JsonRequest struct {
    UserId  string `json:"user_id"`
    Password  string    `json:"password"`
}

func main() {
    engine:= gin.Default()

    dsn := "root@tcp(mysql:3306)/first?charset=utf8mb4&parseTime=True&loc=Local"
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("DB接続失敗");
        return
    }

    // Sign up用ルーティング
    engine.POST("/signup", func(c *gin.Context) {
        var json JsonRequest
        if err := c.ShouldBindJSON(&json); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{
                "error" : err.Error(),
            })
            return
        }
        userId := json.UserId
        pw := json.Password

        // 同一ユーザIDの検証
        user := User{}
        db.Where("user_id = ?", userId).First(&user)
        if user.Id != 0 {
            c.JSON(http.StatusBadRequest, gin.H{
                "message" : "そのUserIdは既に登録されています。",
            })
            return
        }

        // パスワードの暗号化
        encryptPw, err := crypto.PasswordEncrypt(pw)
        if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{
                "message" : "パスワードの暗号化でエラーが発生しました。",
            })
            return
        }
        
        // DBへの登録
        user = User{UserId: userId, Password: encryptPw}
        db.Create(&user)
        
        c.JSON(http.StatusOK, gin.H{
            "message" : "アカウント登録完了",
        })
    })

    // Sign in用ルーティング
    engine.POST("/signin", func(c *gin.Context) {
        var json JsonRequest
        if err := c.ShouldBindJSON(&json); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{
                "error" : err.Error(),
            })
            return
        }
        userId := json.UserId
        pw := json.Password

        user := User{}
        db.Where("user_id = ?", userId).First(&user)
        if user.Id == 0 {
            c.JSON(http.StatusUnauthorized, gin.H{
                "message" : "ユーザーが存在しません。",
            })
            return
        }

        err := crypto.CompareHashAndPassword(user.Password, pw)
        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{
                "message" : "パスワードが一致しません。",
            })
            return
        }

        // JWTに付与する構造体
        var limit time.Duration = time.Hour * 24 // トークンの有効期限を24時間とする

        claims := jwt.MapClaims{
            "user_id": userId,
            "password": pw,
            "exp": time.Now().Add(limit).Unix(), 
        }
        // ヘッダーとペイロード生成
        token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

        // トークンに署名を付与
        accessToken, _ := token.SignedString([]byte("ACCESS_SECRET_KEY"))

        c.JSON(http.StatusOK, gin.H{
            "message" : "ログイン成功",
            "accessToken" : accessToken,
        })
    })
    engine.Run(":8080")
}

それでは変更点を見ていきましょう。

import (
...
    "time"
    "github.com/dgrijalva/jwt-go"
)

jwt-go を利用するために宣言します。また、トークンに有効期限を持たせるために time も宣言しています。

// JWTに付与する構造体
var limit time.Duration = time.Hour * 24 // トークンの有効期限を24時間とする

claims := jwt.MapClaims{
    "user_id": userId,
    "password": pw,
    "exp": time.Now().Add(limit).Unix(), 
}
// ヘッダーとペイロード生成
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

// トークンに署名を付与
accessToken, _ := token.SignedString([]byte("ACCESS_SECRET_KEY"))

トークンに含める情報はユーザーIDとパスワード、それと有効期限としています。
有効期限expには現在時刻+limit(time.Duration型)を設定しています。なお、アクセストークンにexpを含めることで、後述するアクセストークンの検証時に、有効期限を自動的に判定をしてくれたりもします。

トークンはHMAC-SHA256で署名しています。署名を生成する際の秘密鍵は ACCESS_SECRET_KEY としていますが、一般的には(当たり前の話ですが)より複雑な推測されにくい文字列を用います。

試しに動作確認をしてみましょう。次のリクエストを実行してみてください。

$ curl -X POST "http://localhost:8080/signin" -H "Content-Type: application/json" -d '{"user_id": "login_user", "password": "Password_"}'

次のようなレスポンスが返ってきたかと思います。

{
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTY4MTE4ODQsInBhc3N3b3JkIjoiUGFzc3dvcmRfIiwidXNlcl9pZCI6ImxvZ2luX3VzZXIifQ.swd9fDHB4VI2bHGflW2oE3MYHotz8oeBdXxKLQbDWYQ",
    "message": "ログイン成功"
}

accessToken の値は実行するたびに異なるはずですが、このような長い文字列になっていることかと思います。

アクセストークンを用いた認証

サインインに成功し、アクセストークンを取得した後は次のように利用します。
なお、アクセストークンはAuthorizationリクエストヘッダで送信し、Bearer認証を利用します。

main.go
package main

import (
    "github.com/gin-gonic/gin"
    "net/http"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
    "go_gin_gorm/libraries"
    "fmt"
    "strings"
    "time"
    "github.com/dgrijalva/jwt-go"
)

...

func main() {

...

    // JWT Test用ルーティング
    engine.GET("/tokenvaid", func(c *gin.Context) {
        // リクエストヘッダのAuthorizationを取得
        authorizationHeader := c.Request.Header.Get("Authorization")
        if authorizationHeader != "" {
            // Authorizationヘッダーのうち、Bearerを検証する
            ary := strings.Split(authorizationHeader, " ")
            if len(ary) == 2 {
                // Bearer値を解析する
                if ary[0] == "Bearer" {
                    token, err := jwt.Parse(ary[1], func(token *jwt.Token) (interface{}, error) {
                        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
                        }
                        
                        return []byte("ACCESS_SECRET_KEY"), nil
                    })
                    
                    // エラーチェック
                    if err != nil {
                        var msg string;
                        // 妥当性チェックエラー判定
                        if ve, ok := err.(*jwt.ValidationError); ok {
                            if ve.Errors&jwt.ValidationErrorExpired != 0 {
                                msg = "アクセストークンの有効期限切れエラー"
                            } else {
                                msg = "有効期限切れ以外の妥当性チェックエラー"
                            }
                        } else {
                            fmt.Printf("妥当性チェック以外のエラー")
                        }
                        c.JSON(http.StatusUnauthorized, gin.H{
                            "message" : msg,
                            "err" : err,
                        })
                        return
                    }

                    // クレームの取得
                    if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
                        userId := string(claims["user_id"].(string))
                        pw := string(claims["password"].(string) )
                        exp := int64(claims["exp"].(float64))

                        // アクセストークンに含まれるユーザー情報を検証する
                        user := User{}
                        db.Where("user_id = ?", userId).First(&user)
                        if user.Id == 0 {
                            c.JSON(http.StatusUnauthorized, gin.H{
                                "message" : "ユーザーが存在しません。",
                            })
                            return
                        }

                        err := crypto.CompareHashAndPassword(user.Password, pw)
                        if err != nil {
                            c.JSON(http.StatusUnauthorized, gin.H{
                                "message" : "パスワードが一致しません。",
                            })
                            return
                        }
                        
                        // expの検証はjwt解析で行われる
                        // 有効期限を検証する
                        // if exp < time.Now().Unix() {
                        //     c.JSON(http.StatusUnauthorized, gin.H{
                        //         "message" : "有効期限切れです。",
                        //     })
                        //     return
                        // }
                        
                        c.JSON(http.StatusOK, gin.H{
                            "message" : "アクセストークンによる認証成功",
                            "user_id" : userId,
                            "exp" : time.Unix(exp, 0),
                        })
                        return
                    } else {
                        // クレーム取得失敗
                        c.JSON(http.StatusUnauthorized, gin.H{
                            "message" : "クレームの取得失敗",
                            "err" : err,
                        })
                        return
                    }
                    return
                }
            }
        } else {
            c.JSON(http.StatusUnauthorized, gin.H{"message" : "Authorizationなし"})
            return;
        }
    })

...

    engine.Run(":8080")
}

ざっくりと説明をすると、アクセストークンを解析して、その中身(ユーザーIDとパスワード)を用いてユーザーテーブルに存在確認を行っています。
問題がなければメッセージとユーザーIDと有効期限をjsonに詰めて返却しています。

では、それぞれ見ていきましょう。

import (
...
    "strings"
...
)

リクエストヘッダを解析する際に利用しています。

// リクエストヘッダのAuthorizationを取得
authorizationHeader := c.Request.Header.Get("Authorization")
if authorizationHeader != "" {
    // Authorizationヘッダーのうち、Bearerを検証する
    ary := strings.Split(authorizationHeader, " ")
    if len(ary) == 2 {
        // Bearer値を解析する
        if ary[0] == "Bearer" {
...
        }
...
    }
...
}

Authorizationリクエストヘッダを取得し、Bearer認証スキームでアクセストークンを取得します。
少々くどいやり方ですが、別の認証タイプが送られてくるとややこしくなるのでこのようにしています。

token, err := jwt.Parse(ary[1], func(token *jwt.Token) (interface{}, error) {
    if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
        return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
    }
    
    return []byte("ACCESS_SECRET_KEY"), nil
})

アクセストークンを解析します。解析には署名をしたときに用いた秘密鍵を使います。

// エラーチェック
if err != nil {
    var msg string;
    // 妥当性チェックエラー判定
    if ve, ok := err.(*jwt.ValidationError); ok {
...
    }
...
}

ここではエラー内容を精査しています。必要に応じて条件式を追加してエラー制御を行います。

if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
    userId := string(claims["user_id"].(string))
    pw := string(claims["password"].(string) )
    exp := int64(claims["exp"].(float64))
}

解析した情報をマッピングします。ここまでくればアクセストークンを作成したときに含めた情報を取得することができます。

後はログイン時の動作と同じようにアクセストークンから取得したユーザーIDとパスワードを用いてユーザー認証を行い、
問題がなければjsonに情報を詰めて返却するだけです。

アクセストークンを用いた処理も実際に動かしてみましょう。

$ curl -X GET "http://localhost:8080/tokenvaid" -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTY4MTE4ODQsInBhc3N3b3JkIjoiUGFzc3dvcmRfIiwidXNlcl9pZCI6ImxvZ2luX3VzZXIifQ.swd9fDHB4VI2bHGflW2oE3MYHotz8oeBdXxKLQbDWYQ'
{
    "exp": "2024-05-27T12:11:24Z",
    "message": "アクセストークンによる認証成功",
    "user_id": "login_user"
}

まとめ

いかがだったでしょうか?
jwt-goを利用することで比較的簡単にJWTを実装できたかと思います。
ただまぁなんというか、リクエストヘッダを取得したりトークンを解析したりするところは使いづらさを感じましたね…最低限のチェック程度で済ませるなら良いかも。

ところで、そろそろmain.goだけでいろいろと検証するのは難しくなってきましたので、何か考えねば。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?