📏

クリーンアーキテクチャなUserの認証と認可の方法

2024/04/08に公開

自己紹介

某中堅?大学理学部情報科学化のryosukeです!

とある長期インターンでSaaSの作成を行っています!

残念ながらひとりで作成中です...

経験もSQLとGAS, Pythonのスクレイピングのみ...

とほほ...

概要

Golangを用いたemailでのユーザ認証と認可の方法を学んだので流れと自分なりの解釈を言語化したいと思い筆を取りました!

また社内システムにて組織限定アカウントのみサインアップを行いユーザの認証と認可を行いたいため、Google OAuth2.0にてどのようにクリーンアーキテクチャに落とし込むかも考えていきたいと思います!

各層について

user関連のそれぞれの層のコードをお見せします!

1.モデル層

// パッケージはディレクトリ毎に分類する
package model

import "time"

//Userの構造体の定義
type User struct {
	//uintは負の値を取らない整数型, jsonの値は基本的に小文字にする, IDは主キーのためprimaryKeyい設定する.
	ID uint `json:"id" gorm:"primaryKey"`

	//任意のEmailはデータベース上に一つで一つでなくてはならないのでuniqueとする.
	Email string `json:"email" gorm:"unique"`

	Password string `json:"password"`

	//作成時とアップデートした時間をtimeライブラリを使用して定義する.
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time .Time `json:"updated_at"`
}

//クライアントに渡すデータの構造体の定義.
type UserResponse struct {
	ID uint `json:"id" gorm:"primaryKey"`
	Email string `json:"email" gorm:"unique"`
}

2.リポジトリ層

package repository

import (
	"go-rest-api/model"

	"gorm.io/gorm"
)

type IUserRepository interface {
	GetUserByEmail(user *model.User, email string) error
	CreateUser(user *model.User) error
}

type userRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) IUserRepository {
	return &userRepository{db}
}

func (ur *userRepository) GetUserByEmail(user *model.User, email string) error {
	if err := ur.db.Where("email=?", email).First(user).Error; err != nil {
		return err
	}
	return nil
}

func (ur *userRepository) CreateUser(user *model.User) error {
	if err := ur.db.Create(user).Error; err != nil {
		return err
	}
	return nil
}

3.ユースケース層

package usecase

import (
	"go-rest-api/model"
	"go-rest-api/repository"
	"go-rest-api/validator"
	"os"
	"time"

	"github.com/golang-jwt/jwt/v4"
	"golang.org/x/crypto/bcrypt"
)

type IUserUsecase interface {
	SignUp(user model.User) (model.UserResponse, error)
	Login(user model.User) (string, error)
}

type userUsecase struct {
	ur repository.IUserRepository
	uv validator.IUserValidator
}

func NewUserUsecase(ur repository.IUserRepository, uv validator.IUserValidator) IUserUsecase {
	return &userUsecase{ur, uv}
}

func (uu *userUsecase) SignUp(user model.User) (model.UserResponse, error) {
	if err := uu.uv.UserValidator(user); err != nil {
		return model.UserResponse{}, err
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 10)
	if err != nil {
		return model.UserResponse{}, err
	}
	newUser := model.User{Email: user.Email, Password: string(hash)}
	if err := uu.ur.CreateUser(&newUser); err != nil {
		return model.UserResponse{}, err
	}
	resUser := model.UserResponse{
		ID:    newUser.ID,
		Email: newUser.Email,
	}
	return resUser, nil
}

func (uu *userUsecase) Login(user model.User) (string, error) {
	if err := uu.uv.UserValidator(user); err != nil {
		return "", err
	}
	storedUser := model.User{}
	if err := uu.ur.GetUserByEmail(&storedUser, user.Email); err != nil {
		return "", err
	}
	err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(user.Password))
	if err != nil {
		return "", err
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id": storedUser.ID,
		"exp":     time.Now().Add(time.Hour * 12).Unix(),
	})
	tokenString, err := token.SignedString([]byte(os.Getenv("SECRET")))
	if err != nil {
		return "", err
	}
	return tokenString, nil
}

4.controller層

package controller

import (
	"go-rest-api/model"
	"go-rest-api/usecase"
	"net/http"
	"os"
	"time"

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

type IUserController interface {
	SignUp(c echo.Context) error
	LogIn(c echo.Context) error
	LogOut(c echo.Context) error
	CsrfToken(c echo.Context) error
}

type userController struct {
	uu usecase.IUserUsecase
}

func NewUserController(uu usecase.IUserUsecase) IUserController {
	return &userController{uu}
}

func (uc *userController) SignUp(c echo.Context) error {
	user := model.User{}
	if err := c.Bind(&user); err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}
	userRes, err := uc.uu.SignUp(user)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, err.Error())
	}
	return c.JSON(http.StatusCreated, userRes)
}

func (uc *userController) LogIn(c echo.Context) error {
	user := model.User{}
	if err := c.Bind(&user); err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}
	tokenString, err := uc.uu.Login(user)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, err.Error())
	}
	cookie := new(http.Cookie)
	cookie.Name = "token"
	cookie.Value = tokenString
	cookie.Expires = time.Now().Add(24 * time.Hour)
	cookie.Path = "/"
	cookie.Domain = os.Getenv("API_DOMAIN")
	cookie.Secure = true
	cookie.HttpOnly = true
	cookie.SameSite = http.SameSiteNoneMode
	c.SetCookie(cookie)
	return c.NoContent(http.StatusOK)
}

func (uc *userController) LogOut(c echo.Context) error {
	cookie := new(http.Cookie)
	cookie.Name = "token"
	cookie.Value = ""
	cookie.Expires = time.Now()
	cookie.Path = "/"
	cookie.Domain = os.Getenv("API_DOMAIN")
	cookie.Secure = true
	cookie.HttpOnly = true
	cookie.SameSite = http.SameSiteNoneMode
	c.SetCookie(cookie)
	return c.NoContent(http.StatusOK)
}

func (uc *userController) CsrfToken(c echo.Context) error {
	token := c.Get("csrf").(string)
	return c.JSON(http.StatusOK, echo.Map{
		"csrf_token": token,
	})
}

5.router層

package router

import (
	"go-rest-api/controller"
	"net/http"
	"os"

	echojwt "github.com/labstack/echo-jwt/v4"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func NewRouter(uc controller.IUserController, tc controller.ITaskController) *echo.Echo {
	e := echo.New()

	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		AllowOrigins: []string{"http://localhost:3000", os.Getenv("FE_URL")},
		AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
		AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAccessControlAllowHeaders, echo.HeaderXCSRFToken},
		AllowCredentials: true,
		}))

	e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
		CookiePath: "/",
		CookieDomain: os.Getenv("API_DOMAIN"),
		CookieHTTPOnly: true,
		CookieSameSite: http.SameSiteNoneMode,
		// CookieSameSite: http.SameSiteLaxMode,
		CookieMaxAge: 20000,
	}))
	e.POST("/signup", uc.SignUp)
	e.POST("/login", uc.LogIn)
	e.POST("/logout", uc.LogOut)

	e.GET("/csrf", uc.CsrfToken)

	t := e.Group("/tasks")
	t.Use(echojwt.WithConfig(echojwt.Config{
		SigningKey:  []byte(os.Getenv("SECRET")),
		TokenLookup: "cookie:token",
	}))
	t.GET("", tc.GetAllTasks)
	t.GET("/:taskId", tc.GetTaskById)
	t.POST("", tc.CreateTask)
	t.PUT("/:taskId", tc.UpdateTask)
	t.DELETE("/:taskId", tc.DeleteTask)
	return e
}

大まかな解釈

ユースケース層によってユーザのEmailとPasswordをレポジトリ層の関数を利用してデータベースにて永続化し新しいユーザの作成を行なっています。

あくまでサインアップはデータベースに新しいユーザを作成しているだけでjwtTokenなどの生成は行なっていません。

ユースケース層のログインメソッドによって初めてEmailにてデータベースのユーザを特定しハッシュ化されたパスワードと入力されたパスワードを検証し一致していればjwtTokenを生成しています!

jwtTokenは以下の目的で広く使用されています:

認証: ユーザーが一度ログインすると、サーバーはjwtTokenを生成してクライアントに送ります。その後のリクエストでクライアントはこのトークンを送信し、サーバーはそのトークンを検証してユーザーを認証します。
認可: トークンはユーザーが特定のリソースにアクセスする権限を持っているかどうかをサーバーが判断するための情報を含むことができます。サーバーはトークンを検証し、ユーザーがリクエストした操作やアクセスを許可するかどうかを決定します。

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id": storedUser.ID,
		"exp":     time.Now().Add(time.Hour * 12).Unix(),
	})

今回ではユースケース層のLoginメソッドの中でjwtTokenを生成しています!
生成したjwtTokenに秘密鍵を付与し署名を行なって初めてクロスオリジン間でjwt tokenの受け渡しが可能となるわけです!

このjwtTokenをどのように使用するかという点ですが以下のようになります!

controller層にてechoのコンテキストを利用しクッキーにjwtTokenをセットしています!

クライアントサイドからechoのcontextを通してEmailとPasswordを渡される.

コントローラー層にてクライアントサイドにて保存されているCookieにjwtTokenを渡します!

ただ疑問点としてはLoginするとCookie上にjwtTokenが存在することはわかるのですが、jwtTokenが存在する=認可されている と同義なのかという点が気になりました...

この疑問点について調べてみるとjwtTokenが渡されることによってそのユーザが認証されたことは保証されるが認可されたかどうかについては責任外であるそうです...

そのため、今回のソースコードでどの部分で認可を行なっているか調べたいと思います!

URLの特定のリソースにアクセスする際に認可検証を行なっている.

今回で言えば"/"では認可は必要ない...

というのも"/"ではログインやサインアップなどを行うためどのようなユーザでもアクセスできるようにしなければならない!

しかし実際のtasksのデータを取得する際にはログインしたユーザのみアクセスすることができる設計なので"/tasks"のリソースのアクセスには認可検証を行う必要がある.

従って"/tasks"にアクセスする際に認証されたユーザかどうか(cookieに保存されているjwtTokenの値)をチェックして認可を行なっている!

というわけでそのような処理を行なっているコードはrouter層の以下の部分!

	t := e.Group("/tasks")
	t.Use(echojwt.WithConfig(echojwt.Config{
		SigningKey:  []byte(os.Getenv("SECRET")),
		TokenLookup: "cookie:token",
	}))

結論

あくまでサインアップはデータベースに新しいユーザを作成しているだけでjwtTokenなどの生成は行なっていません。

ユースケース層のログインメソッドによって初めてEmailにてデータベースのユーザを特定しハッシュ化されたパスワードと入力されたパスワードを検証し一致していればjwtTokenを生成しています。

このjwtTokenは、ユーザーが特定のリソースにアクセスする権限を持っているかを確認するために使われ、特に"/tasks"のエンドポイントへのアクセスに際しては、このトークンを検証することにより認可が行われ、認証されたユーザーのみがリソースにアクセスできるように設計されています!

今回の記事はここまでとして次回Google OAuth2.0もしくは OpenConnectIDを利用した認証と認可をクリーンアーキテクチャに落とし込む方法を考えていきたいと思います!

Discussion