🙌

Go でJWT認証を実装

2024/09/25に公開

はじめに

GoでJWT認証の実装方法を紹介します。

ライブラリ

使用ライブラリは雨下の通りです。

  • GORM (Postgres driver): v1.5.9
     ORMライブラリで、PostgreSQLデータベースに対するオブジェクト操作を簡素化する。

  • Gin: v1.10.0
     Go言語の高性能なWebフレームワークで、APIやWebアプリケーションの開発に利用する。

  • bcrypt: v0.27.0
     ユーザーのパスワードを安全に保存するために、パスワードのハッシュ化を行う。

  • JWT (JSON Web Tokens): v5.2.1
     認証で使用するJWTトークンの生成、解析、検証を行うライブラリ。

  • Godotenv: v1.5.1
     .envファイルから環境変数をロードし、開発環境での設定管理を容易にする。

  • CompileDaemon: v1.4.0
     ファイル変更を監視し、自動でGoアプリケーションのビルドと再起動を行う開発支援ツール。

実装

今回、実装するAPIは下記の3つ

  • サインアップ
    ユーザ名(メールアドレス)とパスワードを保存する。

  • ログイン
    ログイン処理。IDと有効期限をJWTトークンとして生成し、クッキーに保存

  • 認証
    クッキーからトークンを取得し、ユーザ情報を解析

コード

パッケージ構成は下記の通り

main.go
package main

import (
	"fmt"

	"example.com/go-jwt/controllers"
	"example.com/go-jwt/initializers"
	"example.com/go-jwt/middleware"
	"github.com/gin-gonic/gin"
)

func init() {
	initializers.LoadEnvVariables()
	initializers.ConnectToDB()
	initializers.SyncDatabase()
}

APIを定義。Validateメソッドを呼び出す前にRequireAuthを呼び出して認証を行わせる
func main() {
	r := gin.Default()

	r.POST("/signup", controllers.SignUp)
	r.POST("/login", controllers.Login)
	r.GET("/validate", middleware.RequireAuth, controllers.Validate)
	r.Run()
	fmt.Println("Hello, World!")
}


コントローラーと言いつつ、ログイン、サインアップの実装内容を記述

usersControllers.go
package controllers

import (
	"net/http"
	"os"
	"time"

	"example.com/go-jwt/initializers"
	"example.com/go-jwt/models"
	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
)

func SignUp(c *gin.Context) {
	var body struct {
		Username string
		Password string
	}

	if c.Bind(&body) != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "パラメータが存在しません。",
		})
		return
	}

	hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), 10)

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "パスワードのハッシュ化に失敗しました。",
		})
		return
	}

	user := models.User{Username: body.Username, Password: string(hash)}

	result := initializers.DB.Create(&user)

	if result.Error != nil {
		c.JSON(500, gin.H{
			"error": "ユーザーの作成に失敗しました。",
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"message": "ユーザーの作成に成功しました。",
	})

}

func Login(c *gin.Context) {
	var body struct {
		Username string
		Password string
	}

	if c.Bind(&body) != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "パラメータが存在しません。",
		})

	}

	var user models.User

	initializers.DB.First(&user, "username = ?", body.Username)

	if user.ID == 0 {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "ユーザーが存在しません。",
		})
	}

	err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(body.Password))

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "パスワードが間違っています。",
		})
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"sub": user.ID,
		// 有効期限を1日に設定
		"exp": time.Now().Add(time.Hour * 24).Unix(),
	})

	tokenString, err := token.SignedString([]byte(os.Getenv("SECRET")))

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "トークンの作成に失敗しました。",
		})
	}

	c.SetSameSite(http.SameSiteLaxMode)
	// クッキーにトークンをセット(キー, 値, 有効期限, パス, ドメイン, https, httponly)
	c.SetCookie("Authorization", tokenString, 3600, "/", "", false, true)

	c.JSON(http.StatusOK, gin.H{
		"token": tokenString,
	})

}

func Validate(c *gin.Context) {
	user, _ := c.Get("user")

	c.JSON(http.StatusOK, gin.H{
		"message": "ログイン済み:" + user.(models.User).Username,
	})

}

認証の実装。トークンを解析して、IDと有効期銀の確認。
コンテキストに取得したユーザ情報をセットして次の後続メソッドに情報を渡す

requireAuth.go
package middleware

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

	"example.com/go-jwt/initializers"
	"example.com/go-jwt/models"
	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
)

func RequireAuth(c *gin.Context) {
	tokenString, err := c.Cookie("Authorization")
	if err != nil {
		c.AbortWithStatus(http.StatusUnauthorized)
	}

	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		// .envからシークレットキーを取得
		secret := []byte(os.Getenv("SECRET"))
		return secret, nil
	})

	if err != nil {
		log.Fatal(err)
	} else if claims, ok := token.Claims.(jwt.MapClaims); ok {

		if time.Now().Unix() > int64(claims["exp"].(float64)) {
			// トークンが期限を過ぎている
			c.AbortWithStatus(http.StatusUnauthorized)
		}

		// "sub"に入れておいたIDをもとにユーザー情報を取得
		var user models.User
		initializers.DB.First(&user, claims["sub"])

		if user.ID == 0 {
			// ユーザーが存在しない
			c.AbortWithStatus(http.StatusUnauthorized)
		}
		// ユーザー情報をコンテキストにセット
		c.Set("user", user)
		c.Next()
	} else {
		log.Fatal("jwtのパースに失敗しました")
	}
}

検証

ログインメソッドの呼び出し。
また、ログインするとトークンがクッキーにセットされる(次の認証APIを参考)

認証メソッドの呼び出し。
パラメータには何も渡していないが、クッキーに保存されているJWTトークンをもとにユーザ情報を取得できていることが確認できる。

トークンの中身はこんな感じ

Discussion