0
1

GoでJSON Web トークン (JWT)を用いたログイン認証機能を作成してみる

Last updated at Posted at 2024-08-17

はじめに

JWT (JSON Web Token) は、ユーザー認証を行うための方法です。今回は、Go言語を使って、JWTを用いたログイン認証機能を実装した内容を紹介します。今回の実装では、Echoフレームワークを使用してAPIを構築し、ユーザー認証とトークンの生成・検証を行います。
前回のNext.jsをフロントアプリとしてこの機能を使う想定です。

そもそもJWTって何?

JWT (JSON Web Token) は、Web アプリケーションでユーザー認証や情報の安全なやり取りに使われるトークンです。
トークンは3つの部分から成り立っていて、

  • 情報(クレーム)を格納する部分
  • 署名部分
  • ヘッダー部分

があります。
JWT は署名されているため、改ざんされていないことが確認でき、また、サーバー側でセッション管理をしなくてもトークン自体が認証情報を保持できるのが特徴です。
要は、JWTの"情報"部分に、ユーザーのメールアドレスなどの情報を任意で含められるので、トークンさえあれば、サーバーはいつでもユーザーを認証することが可能になります。
サーバー側でセッションなどを保持している必要がないので、サーバー側の負荷を減らせるし、依存関係も切り離せるので、拡張性の向上も期待できるようです。

ユーザー認証の基本的な流れ

今回のユーザー認証までの基本的な流れは以下の通りです。

1. ユーザーログイン:
ユーザーが、メールアドレスとパスワードを使ってログインを行います。
データベースから該当するユーザーを検索し、テーブルに保存されているパスワードを検証します。
検証が成功したらサーバーはユーザーの情報を元にJWTを発行します。

2. トークンを利用した認証:
クライアントは、サーバーから受け取ったJWTをリクエストヘッダーに含めることで、保護されたページにアクセスできます。
サーバーは、リクエストに含まれるトークンを検証し、トークンからユーザー情報を取得して認証を行います。

3. トークンを利用した認可:
ログイン後、他のAPIを実行する際にのHeaderにJWTを付与。このJWTを検証し、認可されればAPIを叩く処理を実施する。

想定するシナリオ

管理画面を利用する管理者の認証認可を行うためのAPIを作成しました。下記のようなシナリオです。

リクエストで渡されたメールアドレスとパスワードが、データベースの管理者ユーザーテーブルに登録されているレコードと一致した場合は、ログインを許可し、レスポンスに作成したJWTを含めます。
(ログインAPI:/login)

ログイン後、管理画面内で、ページ遷移をするたびに、毎回ユーザーのトークンを検証するようにし、認可されなかった場合は、ログイン画面にリダイレクトする。
(認可用API:/api/me)

APIが叩かれる度にも、サーバー側でトークンの検証を行い、認可されなかったらエラーを出す。
(Go(Echo)の機能を利用する)

また、今回のポイントとしては、

  • フロントアプリで、画面遷移をしたときにトークンを検証する機能を実装している
  • バックエンド側で、APIを叩いた際にもトークンの検証を行うようにしている

というところです。バックエンドとフロントエンドの両方でことあるごとに検証をすることでより堅牢なシステムを作ろうという意図です。

ディレクトリ構成

/app
│
├── main.go           
│
├── /domain
│   ├── /model
│   │   └── adminUser.go           # ドメインモデル
│   └── /repository
│       └── adminUserRepository.go # リポジトリインターフェースの定義
│
├── /infrastructure
│   └── /persistence
│       ├── adminUserRepository.go # データベースアクセス
│       └── dto
│           └── adminUser.go       # DTO (Data Transfer Object)
│
├── /handler
│   ├── router.go                 # ルーティングの設定
│   ├── adminUserHandler.go       # ハンドラの実装
│   ├── /message
│   │   └── adminUser.go          # 各エンドポイントのリクエスト/レスポンスの定義
│   └── /validate
│       ├── message.go            # バリデーションに関連するメッセージ
│       └── validator.go          # バリデーションロジックの実装
│
├── /usecase
│   └── adminUserUsecase.go       # ユースケース(ビジネスロジック)の実装

※/messageや、/validateの内容は今回のテーマと少しずれるので省きます。記事更新の際などに内容を改めて紹介するかもしれないです。

各ディレクトリの説明

  • /domain:
    ドメイン層には、アプリケーションのビジネスロジックに関するモデルとリポジトリのインターフェースを定義します。ここでは、AdminUser というユーザーを表すモデルと、ユーザー情報の取得や保存を行うリポジトリを扱います。

  • /infrastructure:
    インフラ層では、データベースアクセスの実装を担当します。persistence フォルダには、リポジトリの具体的な実装と、データの転送を行うDTO (Data Transfer Object) が含まれます。

  • /handler:
    ハンドラ層では、リクエストを受け取って処理を行うエンドポイントを定義します。また、リクエスト・レスポンスのフォーマットを定義する message フォルダと、入力データのバリデーションを行う validate フォルダが含まれています。

  • /usecase:
    ユースケース層には、ビジネスロジックを実行するユースケースを定義します。たとえば、ユーザーのログイン処理やトークンの生成などがここで行われます。

実装内容

main.goから紹介します。
main.go は、Goアプリケーションのエントリーポイントとなるファイルです。ここでアプリケーション全体の設定を行い、サーバーを起動します。
今回実装した内容です。コメントに簡単に説明を載せています。

main.go
package main

import (
	"app/handler"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	// インスタンスを作成
	e := echo.New()

	// ミドルウェアを設定
    // 各リクエストのログを記録 
	e.Use(middleware.Logger())
    // パニック(サーバーのクラッシュなど)から自動的に復旧します。
	e.Use(middleware.Recover())
    // クロスオリジンリソースシェアリングを許可
	e.Use(middleware.CORS())

	// アプリケーション全体のルーティングを設定
	handler.Router(e)

	// サーバーをポート番号8080で起動
	e.Logger.Fatal(e.Start(":8080"))
}

次に、APIのルーティングを設定します。

handler/router.go
package handler

import (
	"app/domain/model"
	"app/infrastructure/persistence"
	"app/usecase"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func Router(e *echo.Echo) {
    // 依存性の注入
	adminUserHandler := NewAdminUserHandler(usecase.NewAdminUserUseCase(persistence.NewAdminUserRepository(persistence.NewDb())))

	e.POST("/login", adminUserHandler.Login)

    // "/api"でグルーピング
	api := e.Group("/api")
    // JWTによる認証処理
	api.Use(middleware.JWTWithConfig(model.JWTConfig))
 
	api.GET("/me", adminUserHandler.GetMe)
}

下記のコードで、トークンの検証を行ってます。
/api 直下のエンドポイントは全て、都度検証してくれます
※model.JWTConfigの内容はmodelに書いてます。後で紹介します。

api.Use(middleware.JWTWithConfig(model.JWTConfig))

認証に失敗したら下のようなレスポンスが返ります

{"message":"missing or invalid jwt token"}

次は、ハンドラーを実装します。
ユーザーの認証が成功した場合にJWTを発行し、クライアントに返します。
ユーザーがログインするための処理 (Login メソッド) と、認証済みユーザーの情報を取得するための処理 (GetMe メソッド) が含まれています。

コード内に簡単に説明文を残しています。

handler/adminUserHandler.go
package handler

import (
    "app/domain/model"
	"app/handler/message"
    "app/usecase"
    "net/http"
    "github.com/labstack/echo/v4"
)

// AdminUserHandlerの定義
type AdminUserHandler struct {
	u usecase.AdminUserUseCase
}

// AdminUserHandlerのコンストラクタ
func NewAdminUserHandler(u usecase.AdminUserUseCase) *AdminUserHandler {
	return &AdminUserHandler{u: u}
}

// ログイン処理
func (h *AdminUserHandler) Login(c echo.Context) error {
    // リクエストのバインディングを行う
	req := new(message.LoginRequest)
	if err := c.Bind(req); err != nil {
		return err
	}

    // リクエストのバリデーションを行う(※内容は省略します) 
	if err := message.ValidateLoginRequest(*req); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

    // バリデーションが通ったらリクエストの情報をユースケース層に送る
	t, result, err := h.u.Login(req.Email, req.Password)
	if err != nil {
		return err
	}

    // レスポンスの作成
	res := new(message.GetMeResponse)
	res.Token = *t
	res.AdminUser.Id = result.Id
	res.AdminUser.Name = result.Name
	res.AdminUser.Email = result.Email
	res.AdminUser.CreatedAt = result.CreatedAt
	res.AdminUser.UpdatedAt = result.UpdatedAt
	return c.JSON(http.StatusOK, res)
}

// ログインしているユーザーの情報を、トークンをもとに取得し検証
func (h *AdminUserHandler) GetMe(c echo.Context) error {
    // クライアントから送られてきたJWTトークンを GetAdminUserFromToken で解析し、
    // そのトークンに関連付けられたユーザー情報を取得する
	adminUser, err := model.GetAdminUserFromToken(c)
	if err != nil {
		return err
	}

    // 取得したユーザー情報を基に新しいJWTトークンを再生成します
	token, err := model.MakeJwtToken(*adminUser)
	if err != nil {
		return err
	}

    // レスポンスの作成
	res := new(message.GetMeResponse)
	res.Token = token
	res.AdminUser.Id = adminUser.Id
	res.AdminUser.Name = adminUser.Name
	res.AdminUser.Email = adminUser.Email
	res.AdminUser.CreatedAt = adminUser.CreatedAt
	res.AdminUser.UpdatedAt = adminUser.UpdatedAt
	return c.JSON(http.StatusOK, res)
}

次に、ビジネスロジックを書くユースケースで、ユーザーのメールアドレスとパスワードを検証し、トークンを生成するロジックを実装します。

コード内に簡単に説明文を残しています。

usecase/adminUserUsecase.go
package usecase

import (
	"app/domain/model"
	"app/domain/repository"
	"app/infrastructure/persistence/dto"

	"github.com/labstack/echo/v4"
	"github.com/oklog/ulid/v2"
	"gorm.io/gorm"
)

type AdminUserUseCase struct {
	r repository.AdminUserRepository
}

func NewAdminUserUseCase(r repository.AdminUserRepository) AdminUserUseCase {
	return AdminUserUseCase{r: r}
}

// ログイン処理
func (u *AdminUserUseCase) Login(email string, password string) (*string, *model.AdminUser, error) {
    // リクエストのemailをリポジトリに送り、該当のデータがデータベースにあれば情報を返してもらう
	result, err := u.r.GetAdminUserByEmail(email)
    
    // 該当のレコードが無かったらエラー
	if err == gorm.ErrRecordNotFound {
		return nil, nil, &echo.HTTPError{
			Code:    http.StatusUnauthorized,
			Message: "invalid name or password",
		}
	}
	if err != nil {
		return nil, nil, err
	}

	// リクエストのpasswordと、データベースのパスワードがあっているかを検証
	if nil != model.CompareHashAndPassword(result.PasswordHash, password) {
		return nil, nil, &echo.HTTPError{
			Code:    http.StatusUnauthorized,
			Message: "invalid name or password",
		}
	}

	// ユーザー情報の変換
	parsedId, err := ulid.Parse(result.Id)
	if err != nil {
		return nil, nil, err
	}
	m := model.AdminUser{
		Id:        parsedId,
		Name:      result.Name,
		Email:     result.Email,
		CreatedAt: result.CreatedAt,
		UpdatedAt: result.UpdatedAt,
		DeletedAt: result.DeletedAt,
	}

	// JWTトークンの作成を行う
	t, err := model.MakeJwtToken(m)
	if err != nil {
		return nil, nil, err
	}

	return &t, &m, nil
}

パスワードの検証について:
ユーザーが入力したパスワードを、データベースに保存されているハッシュ化されたパスワードと比較します。CompareHashAndPassword 関数を使って比較し、一致しない場合はエラーを返します。

ユーザー情報の変換について:
データベースから取得したユーザー情報(DTO)を、アプリケーションで使用するモデル形式に変換します。ここでは、IDを ULID 形式に変換し、AdminUser 構造体に情報をつめなおしています。

次に、リポジトリでデータベースからユーザー情報を取得する処理を実装します。
データベースからユーザー情報を取得するためのリポジトリ (AdminUserRepository) の実装です。このリポジトリは、特定のメールアドレスに基づいてユーザー情報を検索し、その結果を返す役割を持っています。

infrastructurer/persistence/adminUserRepository.go
package persistence

import (
	"app/infrastructure/persistence/dto"
    "gorm.io/gorm"
)

type AdminUserRepository struct {
	db *gorm.DB
}

funcNewAdminUserRepository(db *gorm.DB) *AdminUserRepository {
	return &AdminUserRepository{db: db}
}

// このメソッドは、指定されたメールアドレスに基づいてデータベースからユーザー情報を取得するために使用されます。
func (r *AdminUserRepository) GetAdminUserByEmail(email string) (*dto.AdminUser, error) {
	db := r.db.ConnectDigitDb()
	var adminUsers []dto.AdminUser
	if err := db.Where("email = ?", email).First(&adminUsers).Error; err != nil {
		return nil, err
	}
	return &adminUsers[0], nil
}

このあたりのデータベース接続情報の内容は省略します。

type AdminUserRepository struct {
	db *gorm.DB
}

funcNewAdminUserRepository(db *gorm.DB) *AdminUserRepository {
	return &AdminUserRepository{db: db}
}

次に、JWTを作成するロジックなどを記載したモデルです。

まずは、ハッシュ化されたパスワードを検証するロジックなどを記載しているモデルです

domain/model/adminUser.go
package model

import (
	"time"

	"github.com/oklog/ulid/v2"
	"golang.org/x/crypto/bcrypt"
)

type AdminUser struct {
	Id        ulid.ULID
	Name      string
	Email     string
	Password  string
	CreatedAt time.Time
	UpdatedAt time.Time
	DeletedAt *time.Time
}

// 引数に渡されたハッシュ化パスワードと、リクエストできたパスワードの検証
func CompareHashAndPassword(hash, password string) error {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}

bcryptを使ってパスワードが一致しているか検証しています。

続いて、いよいよJWT関連のロジックです。今回特に大事な部分ですね。

domain/model/customJwt.go
package model

import (
	"os"
	"strings"
	"time"

	"github.com/dgrijalva/jwt-go"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	"github.com/oklog/ulid/v2"
)

// SigningKey
var SigningKey = []byte(os.Getenv("JWT_SIGNING_KEY"))

// JWTConfig
var JWTConfig = middleware.JWTConfig{
	Claims:     &JwtCustomClaims{},
	SigningKey: SigningKey,
}

// JwtCustomClaims
type JwtCustomClaims struct {
	Id        ulid.ULID
	Name      string
	Email     string
	CreatedAt time.Time
	UpdatedAt time.Time
	jwt.StandardClaims
}

// MakeJwtToken
func MakeJwtToken(adminUser AdminUser) (string, error) {
	claims := &JwtCustomClaims{
		Id:        adminUser.Id,
		Name:      adminUser.Name,
		Email:     adminUser.Email,
		CreatedAt: adminUser.CreatedAt,
		UpdatedAt: adminUser.UpdatedAt,
		StandardClaims: jwt.StandardClaims{
			ExpiresAt: time.Now().Add(time.Minute * 30).Unix(),
		},
	}
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(SigningKey)
}

// GetAdminUserFromToken
func GetAdminUserFromToken(c echo.Context) (*AdminUser, error) {
	tokenString := c.Request().Header.Get("Authorization")
	tokenString = strings.TrimPrefix(tokenString, "Bearer ")
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		return SigningKey, nil
	})
	if err != nil {
		return nil, err
	}

	claims := token.Claims.(jwt.MapClaims)
	id, err := ulid.Parse(claims["Id"].(string))
	if err != nil {
		return nil, err
	}

	createdAt, err := timeParseJst(claims["CreatedAt"].(string))
	if err != nil {
		return nil, err
	}

	updatedAt, err := timeParseJst(claims["UpdatedAt"].(string))
	if err != nil {
		return nil, err
	}

	return &AdminUser{
		Id:        id,
		Name:      claims["Name"].(string),
		Email:     claims["Email"].(string),
		CreatedAt: createdAt,
		UpdatedAt: updatedAt,
	}, nil
}

func timeParseJst(t string) (time.Time, error) {
	var layout = "2006-01-02T15:04:05+09:00"
	jst, _ := time.LoadLocation("Asia/Tokyo")
	timeJst, err := time.ParseInLocation(layout, t, jst)
	return timeJst, err
}

上から順に少し説明します。

var SigningKey = []byte(os.Getenv("JWT_SIGNING_KEY"))

var JWTConfig = middleware.JWTConfig{
	Claims:     &JwtCustomClaims{},
	SigningKey: SigningKey,
}

SigningKey:
JWTを署名するための秘密鍵です。この鍵は、環境変数 JWT_SIGNING_KEY から取得され、JWTの署名や検証に使用されます。
※環境変数 JWT_SIGNING_KEY はcompose.ymlファイル内で定義しています。

JWTConfig:
JWTミドルウェアの設定です。JWTの署名に使用する秘密鍵 (SigningKey) と、トークンに含まれるクレーム (JwtCustomClaims) を設定しています。この設定を使って、トークンの生成と検証が行われます。

type JwtCustomClaims struct {
	Id        ulid.ULID
	Name      string
	Email     string
	CreatedAt time.Time
	UpdatedAt time.Time
	jwt.StandardClaims
}

JwtCustomClaims:
JWTに含まれるカスタムクレームを定義した構造体です。カスタムクレームとは、ユーザーごとにセットできる独自の属性です。今回は、ユーザーのID (Id)、名前 (Name)、メールアドレス (Email)、作成日時 (CreatedAt)、更新日時 (UpdatedAt) などが含まれています。

jwt.StandardClaims はJWTの標準クレーム(例えば有効期限など)を含む埋め込み構造体です。
標準クレームはJWTの仕様で定義された共通のフィールドです。

また、クレームとは、JWT内に含まれる情報のことを指します。

func MakeJwtToken(adminUser AdminUser) (string, error) {
	claims := &JwtCustomClaims{
		Id:        adminUser.Id,
		Name:      adminUser.Name,
		Email:     adminUser.Email,
		CreatedAt: adminUser.CreatedAt,
		UpdatedAt: adminUser.UpdatedAt,
		StandardClaims: jwt.StandardClaims{
			ExpiresAt: time.Now().Add(time.Minute * 30).Unix(),
		},
	}
 
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(SigningKey)
}

この関数は、指定されたユーザー情報 (adminUser) を基にJWTを生成し、そのトークンを文字列として返します。

まず、クレームの作成を行います。
この関数に渡されたユーザー情報(ID、名前、メールアドレスなど)を JwtCustomClaims 構造体にセットし、トークンの有効期限を現在時刻から30分後に設定します。

次に、トークンの生成と署名
jwt.NewWithClaims でクレームと署名アルゴリズム (HS256) を指定してJWTを生成し、SignedString(SigningKey) でトークンに署名を付けます。こうして生成されたトークンを返します。

StandardClaimsについて
StandardClaims は、JWTに含めることができる標準的なクレーム(情報)を定義する構造体です。
定義を見に行ったらこんな感じになっていました。

type StandardClaims struct {
	Audience  string `json:"aud,omitempty"`
	ExpiresAt int64  `json:"exp,omitempty"`
	Id        string `json:"jti,omitempty"`
	IssuedAt  int64  `json:"iat,omitempty"`
	Issuer    string `json:"iss,omitempty"`
	NotBefore int64  `json:"nbf,omitempty"`
	Subject   string `json:"sub,omitempty"`
}

これの「ExpiresAt」を僕は使用しています。

要は、ここでやっていることはこのトークンの有効期限は30分ですよ、という情報を書いています。

// GetAdminUserFromToken
func GetAdminUserFromToken(c echo.Context) (*AdminUser, error) {
	tokenString := c.Request().Header.Get("Authorization")
	tokenString = strings.TrimPrefix(tokenString, "Bearer ")
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		return SigningKey, nil
	})
	if err != nil {
		return nil, err
	}

	claims := token.Claims.(jwt.MapClaims)
	id, err := ulid.Parse(claims["Id"].(string))
	if err != nil {
		return nil, err
	}

	createdAt, err := timeParseJst(claims["CreatedAt"].(string))
	if err != nil {
		return nil, err
	}

	updatedAt, err := timeParseJst(claims["UpdatedAt"].(string))
	if err != nil {
		return nil, err
	}

	return &AdminUser{
		Id:        id,
		Name:      claims["Name"].(string),
		Email:     claims["Email"].(string),
		CreatedAt: createdAt,
		UpdatedAt: updatedAt,
	}, nil
}

ここでは、取得してきたトークンを解析して、ユーザーのID (Id)、作成日時 (CreatedAt)、更新日時 (UpdatedAt) などを取り出し、それを AdminUser 構造体にセットしています。

func timeParseJst(t string) (time.Time, error) {
	var layout = "2006-01-02T15:04:05+09:00"
	jst, _ := time.LoadLocation("Asia/Tokyo")
	timeJst, err := time.ParseInLocation(layout, t, jst)
	return timeJst, err
}

この関数は、指定された日時文字列を日本標準時(JST)の型に変換しています。


以上までで、今回実装したログイン認証機能のコア部分の記述紹介です。

まとめ

今回、JWTを用いたGo言語での認証機能を実装してみました。
ここからさらにフロントとつなぎこみに行こうと思います。

かなりざっくりだったので、細かい説明なども後々書いていこうと思います。

参照

0
1
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
0
1