GoとEchoとGORMでJWTを使ったユーザ認証API with Clean Architecture

  • このエントリーをはてなブックマークに追加

はじめに

この記事は、香川大学工学部(創造工学部)のプログラミングサークル SLPのアドベントカレンダー 2日目の記事です。サークル生やOBのみなさんが繋いでいきますので、そちらもぜひ目を通していただけると嬉しいです。

SLP KBIT Advent Calendar 2019

タイトルにもありますが、最近Clean Architectureの勉強をしたので、Go言語とWebアプリケーションフレームワークのEcho、ORマッパーのGORMで、JWTを使ったユーザ認証APIを作っていきます。色々とまとめて書き過ぎな気もしますが、頑張ります。

JWTとは

JWTはJSON Web Tokenの略です。ヘッダー、ペイロード、署名の3つから構成されています。
ヘッダー部分には、署名を作る際の暗号化方式などを、ペイロード部分には、認証する対象の情報(Claim)を格納します。これらをBase64でエンコードしたものと、それをもとに作成した署名を「.」で繋いだものがJWTとなります。
ヘッダーとペイロード部分はBase64でエンコードしただけなので、簡単に中の情報を見ることができます。入れるデータには気をつけましょう。

今回作成するAPIでは、公開鍵暗号方式の署名を用いた実装をしていきます。そのため、認証に成功すると以下のような情報を含むJWTを返すことにします。
いくつか予約されているClaimがありますが、その中から今回使うものだけ簡単に説明します。予約されているもの以外は任意に指定して利用できます。(例: name)

alg: 暗号化方式
type: Tokenの形式 “JWT”にするのが推奨されているようです

sub: 発行元で一意に識別される情報。ユーザDBのIDやそれを暗号化したものを入れておけば良いと思っています。今回はDBのIDをそのまま入れて使います。
exp: Tokenの有効期限

{
  "alg": "RS256",
  "typ": "JWT"
}
{
  "sub": 1,
  "name": "Hoge",    // ユーザの名前
  "exp": 1234567890,
}

こちらのサイトでは、簡単にJWTの中身を確認できるのでデバックなどで便利です。

jwt.io

Clean Architectureについて

Clean Architectureについては、たくさんのブログや書籍などで説明されており、僕が説明するよりそちらの方が詳しいと思いますので、詳細は省きます。
下記のサイトで示されている図が有名ですが、UIなどを表す一番外の層から、Controller、Use Case、Entitiesと言うようにレイヤーを分け、自身より外の層の実装には依存しないようにすると言うアーキテクチャです。

The Clean Architecture

APIの実装

では、ようやく実装に移ります。なお、初めての実装の際に、こちらの記事を参考にさせていただいたので、ディレクトリ構成などは、ほぼ同じになっていると思います。非常に詳しく書かれていて分かりやすいと思うので、ぜひこちらも読んでみてください。

Qiita - Clean ArchitectureでAPI Serverを構築してみる

上記の記事と全く同じですが、ディレクトリとレイヤーの対応は下記のようになります。

domainEntities
infrastructure
Frameworks & Drivers
interfacesInterface
usecaseUse cases

最終的なディレクトリ構成

├── app.go
├── domain
│   └── user.go
├── go.mod
├── go.sum
├── infrastructure
│   ├── router.go
│   └── sql_handler.go
├── interfaces
│   ├── controllers
│   │   ├── context.go
│   │   └── user_controller.go
│   └── database
│       ├── sql_handler.go
│       └── user_repository.go
├── migrate
│   └── migrate.go
├── rsa
│   ├── id_rsa
│   └── id_rsa.pub.pkcs8
└── usecase
    ├── user_interactor.go
    └── user_repository.go

infrastructure

はじめにSQLを実行するGORMのインスタンスを作成します。今回はPostgreSQLを使用しています。user名、DB名、パスワードは自身の環境に合わせてください。GoDotEnv(https://github.com/joho/godotenv) などを利用した方が良いですが、今回は省略します。
このディレクトリには、routingなどを行うrouter.goを作成しますが、それは他の実装が終わった後に行います。

// infrastructure/sql_handler.go
package infrastructure

import (
	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/postgres"

	"github.com/hukurou-s/user-auth-api-sample/interfaces/database"
)

func NewSqlHandler() database.SqlHandler {
	db, err := gorm.Open("postgres", "user=hoge dbname=user-auth-sample-db password='poge' sslmode=disable")

	if err != nil {
		panic(err)
	}
	return db
}

domain

ユーザの情報を持つ構造体を作成します。合わせてログイン時に使用するパラメータの構造体も作成しておきます。

// domain/user.go
package domain

import "github.com/jinzhu/gorm"

type User struct {
	gorm.Model
	Name     string `gorm:"size:255;unique;not null" json:"name"`
	Email    string `gorm:"size:255;unique;not null" json:"email"`
	Password string `gorm:"size:255;not null" json:"password"`
}

type LoginParams struct {
	Name     string `json:"name"`
	Password string `json:"password"`
}

interfaces/database

先ほどのGORMのインスタンスを用いて、実際にDBとデータのやりとりを行う層の実装を行います。

// interfaces/database/user_repository.go
package database

import "github.com/hukurou-s/user-auth-api-sample/domain"

type UserRepository struct {
	SqlHandler
}

func (repo *UserRepository) FindByName(name string) (user domain.User, err error) {
	if result := repo.Where("name = ?", name).First(&user); result.Error != nil {
		err = result.Error
		return
	}
	return
}

この時、Interfaceの層はDBの層よりも内側にあるため、GORMに実装されているルールを用いることができません。そこで、interfaces/database/sql_handler.goのファイルを作成し、infrastructure/sql_handler.go に実装されている中で、Interfaceの層で利用する振る舞いを記述します。
今回は以下のようにします。このように、Go言語のinterfaceで外の層の実装を記述することにより、外の層が内側のルールを用いて実装されている形で捉えることができます。このようにして、内側と外側の依存関係を正しく保つことをDIP(依存関係逆転の原則)と言うようです。
通常であれば、ここで記述したinterfaceに合わせてinfrastructure/sql_handler.goに実装を修正していきますが、今回はORマッパーを使っているので、そちらの実装に合わせてinterfaceを記述しました。

// interfaces/database/sql_handler.go
package database

import "github.com/jinzhu/gorm"

type SqlHandler interface {
	Where(interface{}, ...interface{}) *gorm.DB
	First(interface{}, ...interface{}) *gorm.DB
}

usecase

次に、先ほど実装したinterfaces/databaseを用いてデータを取り、controllerから呼び出されるinteractorを実装していきます。

// usecase/user_interactor.go
package usecase

import "github.com/hukurou-s/user-auth-api-sample/domain"

type UserInteractor struct {
	UserRepository UserRepository
}

func (interactor *UserInteractor) UserByName(name string) (user domain.User, err error) {
	user, err = interactor.UserRepository.FindByName(name)
	return
}

interfaces/databaseの層は外側の層なので、先ほどと同様にDIPを用いて依存のルールを保持します。

// usecase/user_repository.go
package usecase

import "github.com/hukurou-s/user-auth-api-sample/domain"

type UserRepository interface {
	FindByName(string) (domain.User, error)
}

interfaces/controllers

ここでは、エンドポイントに対応した関数を実装していきます。今回は、Login()と認証が必要なエンドポイントで使用され、Userの情報をまとめて返すUserProfile()を作成します。
JWT関係の処理には、jwt-goを利用しています。
今回は公開鍵暗号方式を利用するため、Login()で秘密鍵のファイルを読み込んでいます。また、IDとNameをそれぞれclaimとして格納し、有効期限であるexpは現在から72時間後としました。

// interfaces/controllers/user_controller.go
package controllers

import (
	"golang.org/x/crypto/bcrypt"
	"io/ioutil"
	"net/http"
	"time"

	jwt "github.com/dgrijalva/jwt-go"
	"github.com/hukurou-s/user-auth-api-sample/domain"
	"github.com/hukurou-s/user-auth-api-sample/interfaces/database"
	"github.com/hukurou-s/user-auth-api-sample/usecase"
)

type UserController struct {
	Interactor usecase.UserInteractor
}

func NewUserController(sqlHandler database.SqlHandler) *UserController {
	return &UserController{
		Interactor: usecase.UserInteractor{
			UserRepository: &database.UserRepository{
				SqlHandler: sqlHandler,
			},
		},
	}
}

func (controller *UserController) Login(c Context) error {
	// Postされたパラメータを取得
	loginParams := new(domain.LoginParams)
	if err := c.Bind(loginParams); err != nil {
		return c.JSON(http.StatusBadRequest, err)
	}

	user, err := controller.Interactor.UserByName(loginParams.Name)
	if err != nil {
		return c.JSON(http.StatusUnauthorized, err)
	}

	if !compareHashedPassword(user.Password, loginParams.Password) {
		return c.JSON(http.StatusUnauthorized, struct {
			Status string `json:"status"`
		}{
			Status: "fail",
		})

	}

	// 秘密鍵を読み込み
	keyData, err := ioutil.ReadFile("./rsa/id_rsa")
	if err != nil {
		panic(err)
	}
	key, err := jwt.ParseRSAPrivateKeyFromPEM(keyData)
	if err != nil {
		panic(err)
	}

	token := jwt.New(jwt.SigningMethodRS256)
	// claimの設定
	claims := token.Claims.(jwt.MapClaims)
	claims["sub"] = user.ID
	claims["name"] = user.Name
	claims["exp"] = time.Now().Add(time.Hour * 72).Unix()

	t, err := token.SignedString(key)
	if err != nil {
		panic(err)
	}

	return c.JSON(http.StatusOK, struct {
		Status string `json:"status"`
		Token  string `json:"token"`
	}{
		Status: "success",
		Token:  t,
	})
}

func toHashPassword(pass string) string {
	converted, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
	return string(converted)
}

func compareHashedPassword(hash string, pass string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass))
	if err == nil {
		return true
	}
	return false
}

EchoはFrameworkですので、Controllerよりも外にあります。そのため、これまでと同様にcontext.goを作成していきます。

// interfaces/controllers/context.go
package controllers

type Context interface {
	Bind(interface{}) error
	JSON(int, interface{}) error
	Get(string) interface{}
}

infrastructure

routingの設定をします。
JWTの認証のため、公開鍵のファイルを読み込む必要があります。この時、公開鍵の形式がPKCS#1もしくは、PKCS#8と言うものでないといけません。

ssh-keygen -f id_rsa.pub -e -m pkcs8 > id_rsa.pub.pkcs8

こんな感じのコマンドでPKCS#8の形式のファイルは生成できます。(具体的にどう言う違いがあるのか理解していないので、また勉強しておきたい...)

また、EchoのGroup()を用いることで、認証の必要なエンドポイントをまとめて設定できます。

package infrastructure

import (
	"io/ioutil"

	jwt "github.com/dgrijalva/jwt-go"
	"github.com/labstack/echo"
	"github.com/labstack/echo/middleware"

	"github.com/hukurou-s/user-auth-api-sample/interfaces/controllers"
)

var Echo *echo.Echo

func init() {
	e := echo.New()

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.Use(middleware.CORS())

	controller := controllers.NewUserController(NewSqlHandler())

	e.POST("/login", func(c echo.Context) error { return controller.Login(c) })

	// 公開鍵を読み込む
	pubKeyData, err := ioutil.ReadFile("./rsa/id_rsa.pub.pkcs8")
	if err != nil {
		panic(err)
	}

	pubKey, err := jwt.ParseRSAPublicKeyFromPEM(pubKeyData)
	if err != nil {
		panic(err)
	}

        // /user以下ではjwtによる認証が必要になる
	u := e.Group("/user")
	u.Use(middleware.JWTWithConfig(middleware.JWTConfig{
		SigningKey:    pubKey,
		SigningMethod: "RS256",
	}))
	u.GET("/profile", func(c echo.Context) error { return controller.UserProfile(c) })

	Echo = e
}

仕上げ

最後に、先ほどのrouter.goをmainになるapp.goから呼び出せば完了です。

package main

import infra "github.com/hukurou-s/user-auth-api-sample/infrastructure"

func main() {
	infra.Echo.Logger.Fatal(infra.Echo.Start(":1323"))
}

動作確認

サンプルデータの作成

今回のAPIでは、ユーザを登録する部分を省いたため、ログインできません。そのため、GORMのAutoMigrateでテーブルを作るついでに、テストデータを作りましょう。例のごとく、DB名やユーザ名、パスワードは自身の環境に合わせてください。

package main

import (
	"fmt"
	"golang.org/x/crypto/bcrypt"

	"github.com/hukurou-s/user-auth-api-sample/domain"

	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/postgres"
)

func main() {
	db, err := gorm.Open("postgres", "user=hoge dbname=user-auth-sample-db password='poge' sslmode=disable")
	defer db.Close()

	if err != nil {
		fmt.Println(err)
		return
	} else {
		fmt.Println("Successful connection")
	}

	db.AutoMigrate(&domain.User{})

	sampleUser := domain.User{
		Name:     "Hoge",
		Email:    "hoge@example.com",
		Password: toHashPassword("pogepoge"),
	}
	db.Create(&sampleUser)
}


func toHashPassword(pass string) string {
	converted, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
	return string(converted)
}

リクエストを飛ばして確認する

ログインのリクエスト

passwordを間違えるとTokenは返ってこない

$ curl -X POST -H "Content-Type: application/json" -d '{"name":"Hoge", "password":"piyopiyo"}' localhost:1323/login

{"status":"fail"}

正しい情報を送信するとTokenが返ってくる

$ curl -X POST -H "Content-Type: application/json" -d '{"name":"Hoge", "password":"pogepoge"}' localhost:1323/login

{"status":"success","token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzU0ODgwNzUsIm5hbWUiOiJIb2dlIiwic3ViIjoxfQ.c2y8NhMyKfYOvflyVazP-3IuicebHFZMtZF62CNspFvFqTCtJrz5MzQ_bMzokpGJqjh5kC45rFimDMjq1zT4FJY4fVsIvTMcNYeDQ0Q6_5SDHD7r2TQqC1WT5u4Ws_p-jwVr4RMOHlOFQejtPv6s5tpLHbYzfudgnyTZnatBqRkst0w7jWTo2mub5XBoPfgMwaubebuEwuTlr-CpG0owOuIjFZnC94VKK5AydLNEX_RglwpowZeblNYyqnBBKwH251btQZq3CHbutROshwA_gC5je3HCUfKwx_NrVFL7P0xrQyt2aPdxuP0Q9cJmV_3doIgd38to_gqplTGFyD3zVU9LxLsRRf0IKvOL6PsHu4OdevBKOYTvIz5NTgeoHV4_fiVJeaVBQKyrxUGG1Xwlnay0OfmacxSkZqQpfjdx_-EiOTknFf6Lsk3FO4BdoDvLT6zClpi4W-7L_KpE3BFjr6mfS88ivl7fVdfNvF1ti3s4LtNQNCQsQn1NiSoe_xkxyXutqjjZ1erNSw0gOaP-kwo2w_M2i0qlQBlQ-jINg6rSgmZP2G4WMFa9b9pFbqWy1fWTeLes__nQNEWppIzkUDCm27-BEJNdITryJyo0lM6JxMS7WEao5NBF-cJcvUZWx1bE_5bB1_jRkAofjxIN_zOQdMIdjZ86XmnbzAmg4Uw"}

認証が必要なエンドポイントにリクエスト

headerなしや、間違ったjwtをを送信するとmessageが返ってくる

$ curl -H 'Content-Type: application/json' localhost:1323/user/profile

{"message":"missing or malformed jwt"}

$ curl -H "Authorization: Bearer MISSING.JWT.TOKEN" -H 'Content-Type: application/json' localhost:1323/user/profile

{"message":"invalid or expired jwt"}

Authorizationのheaderに正しいJWTをつけて送信すると、ユーザ情報が返ってくる (パスワードが返ってくるってやばそう)

curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzU0ODgwNzUsIm5hbWUiOiJIb2dlIiwic3ViIjoxfQ.c2y8NhMyKfYOvflyVazP-3IuicebHFZMtZF62CNspFvFqTCtJrz5MzQ_bMzokpGJqjh5kC45rFimDMjq1zT4FJY4fVsIvTMcNYeDQ0Q6_5SDHD7r2TQqC1WT5u4Ws_p-jwVr4RMOHlOFQejtPv6s5tpLHbYzfudgnyTZnatBqRkst0w7jWTo2mub5XBoPfgMwaubebuEwuTlr-CpG0owOuIjFZnC94VKK5AydLNEX_RglwpowZeblNYyqnBBKwH251btQZq3CHbutROshwA_gC5je3HCUfKwx_NrVFL7P0xrQyt2aPdxuP0Q9cJmV_3doIgd38to_gqplTGFyD3zVU9LxLsRRf0IKvOL6PsHu4OdevBKOYTvIz5NTgeoHV4_fiVJeaVBQKyrxUGG1Xwlnay0OfmacxSkZqQpfjdx_-EiOTknFf6Lsk3FO4BdoDvLT6zClpi4W-7L_KpE3BFjr6mfS88ivl7fVdfNvF1ti3s4LtNQNCQsQn1NiSoe_xkxyXutqjjZ1erNSw0gOaP-kwo2w_M2i0qlQBlQ-jINg6rSgmZP2G4WMFa9b9pFbqWy1fWTeLes__nQNEWppIzkUDCm27-BEJNdITryJyo0lM6JxMS7WEao5NBF-cJcvUZWx1bE_5bB1_jRkAofjxIN_zOQdMIdjZ86XmnbzAmg4Uw" -H 'Content-Type: application/json' localhost:1323/user/profile

{"ID":1,"CreatedAt":"2019-12-02T01:34:46.005091+09:00","UpdatedAt":"2019-12-02T01:34:46.005091+09:00","DeletedAt":null,"name":"Hoge","email":"hoge@example.com","password":"$2a$10$Dp2fgfQC6fczBB0GvY45QuPfE.6/U2aCzTZQF4Madx52fKqIHwmrm"}

まとめ

色々と要素を詰め込みすぎて、かなりの分量になってしまいました。
「ここおかしいぞ」とか「ここ直した方がいいぞ」等々ありましたら、指摘していただけると嬉しいです。TwitterにリプorDMが一番反応が早いと思います。
今回のコードをこちらのGitHubのリポジトリにおいてあります。もしよかったら参考にしてみてください。

GitHub:user-auth-api-sample

Twitter:@indeaneagleowl

最後まで読んでくださり、ありがとうございました。

  • このエントリーをはてなブックマークに追加