📌

user関連の処理をgolangとOIDCを用いてクリーンアーキテクチャで作成した

2024/04/14に公開

概略

社内システムにてGoogleアカウントが組織内部である人間のみ利用できるようなアーキテクチャをベイビーエンジニアながら考え実装してみた.

今回はその全体像や仕様などを言語化すると伴に自身の考えをブラッシュアップしていきたいと考えている.

目的

現在社内ではGoogle WorkSpaceを用いて社内ドメインを使用したGoogleアカウントを社員やアルバイトに配布している.

社内の人間のみが使用できるようにGoogle OAuth2.0を使用した認証を用いて社内ドメインにて使用できる人間を制限することが目的である.

大まかな処理方法

今回の仕様の最大のポイントはサインアップ(社内システムのアカウント作成)する際にOIDCを使用してGoogleアカウントにて「指定したドメインであるか」かつ「Googleアカウントにログインすることができるか」を検証し、成功したらデータベースにユーザ情報を作成する.

データベースに登録されたユーザ情報を用いてログインを行い、その際に発行したjwtTokenを用いて認証と認可を行う.

サインアップ処理のシーケンス図

次の図は、サインアップとユーザー設定プロセスのシーケンスを示しています。

サインアップ処理を言語化!

router層にて"http://localhost:8080/auth"にアクセスした際にcontroller層のAuthentication関数を実行する.

func (uc *userController) Authentication(c echo.Context) error {
	// 認証URLを生成しリダイレクト
	url := config.AuthCodeURL("state", oauth2.AccessTypeOffline)
	return c.Redirect(http.StatusFound, url)
}

このコードではgoogleのoauth2.0プロパイダーに誘導する.

誘導された先でGoogleアカウントの検証を行い成功したら(指定したドメインである。かつGoogleアカウントにログインできたら),

googleのoauth2.0プロバイダー側で設定してた"http://localhost:8080/signup"に認証コードを付与してリダイレクトさせる.

router層では("http://localhost:8080/signup"+"認証コード")にてPOSTメソッドを行った際にcontroller層のSignUp関数を実行し次のような処理を行う.

まずはurlに付与されている認証コードからtokenを取得する.

	ctx := context.Background()
	// 認証コードをトークンに交換
	oauth2Token, err := config.Exchange(ctx, c.QueryParam("code"))
	if err != nil {
		return c.String(http.StatusInternalServerError, "Failed to exchange token: "+err.Error())
	}

取得したtokenからid_tokenを取得する.

	// IDトークンの取得
	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
	if !ok {
		return c.String(http.StatusInternalServerError, "No id_token field in oauth2 token.")
	}

その後取得したid_tokenが正しいものか検証する.

	// IDトークンの検証
	idToken, err := provider.Verifier(&oidc.Config{ClientID: clientID}).Verify(ctx, rawIDToken)
	if err != nil {
		return c.String(http.StatusInternalServerError, "Failed to verify ID Token: "+err.Error())
	}

検証が成功していたらGoogleアカウントのプロファイルを取得しそのドメインを検証する.
(Google OAuth2.0プロパイダーにて一度指定ドメインの検証を行っているためサーバサイド側でこの処理は不必要に感じる.)

	// ユーザー情報の取得
	var profile map[string]interface{}
	if err := idToken.Claims(&profile); err != nil {
		return c.String(http.StatusInternalServerError, "Failed to get user profile: "+err.Error())
	}

	// ドメインの検証
	if domain, ok := profile["hd"].(string); !ok || domain != "dev-ryo.com" {
		return c.String(http.StatusUnauthorized, "Unauthorized domain")
	}

ドメインの検証が成功していればprofileからgoogleアカウントのemail情報を取得しクライアントサイドで入力されたpassword, role, classroomを引数にusecase層のSignUp関数を実行する.

	// ユーザー情報の取得
	user := model.User{}
	if err := c.Bind(&user); err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}

	user.Email = profile["email"].(string)

	userRes, err := uc.uu.SignUp(user)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, err.Error())
	}
	return c.JSON(http.StatusCreated, userRes)

usecase層のSignUp関数ではクライアントサイドから受け取ったpasswordをハッシュ化してrepository層のCreateUser関数を実行しデータベースに新規ユーザ情報を作成する.

	hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 10)
	if err != nil {
		return model.UserResponse{}, err
	}
	newUser := model.User{Email: user.Email, Password: string(hash), Role: user.Role, Classroom: user.Classroom}
	if err := uu.ur.CreateUser(&newUser); err != nil {
		return model.UserResponse{}, err
	}

以上が一連のサインアップ処理.
OIDCを含めたGoogle OAuth2.0の処理をクリーンアーキテクチャに完全に落とし込めのたか不安が残るため今後クリーンアーキテクチャについて学習を深め再度リファクタリングする必要がある.

ログイン処理を言語化!

以前認証と認可に関しては以下の記事で作成しているがもう一度言語化をして考えをブラッシュアップする.
https://zenn.dev/ryo1217intern/articles/c0861c29397d67

"http://localhost:8080/login"にPOSTメソッドでアクセスがあった際にrouter層の以下のコードが発火する.

	e.POST("/login", uc.LogIn)

上記のコードが発火するとcontroller層のLogIn関数が実行されPOSTメソッドで送られてきたemailとpasswordをuser構造体にセットしその値を元にusecase層のLogin関数を実行する.

	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())
	}

usecase層のLogin関数ではjwtTokenを生成するのだがPOSTメソッドで受け取ったemailを用いてrepository層のGetUserByEmailを使用しデータベースから登録されているハッシュ化されたパスワードを取得する.

func (uu *userUsecase) Login(user model.User) (string, error) {
	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
	}

パスワードの検証が成功したらjwtTokenを生成する処理を行う.
jwtTokenではuser_idとtokenの有効期限をクレイムズ(主張)の内容としてSECRETキーを署名として設定し生成する.

	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

controller層のLogIn関数に戻り生成したjwtTokenを受け取りcookieにセットする.

	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)

以上がログインの処理の流れになります.

また特定のリソースにアクセスする際にcookieにセットされたjwtTokenを用いて検証を行い、成功したらリソースへのアクセスを許可する.

ソースコード

モデル層

package model

import "time"

// User defines the structure for user data in the system.
type User struct {
    ID   uint   `json:"id" gorm:"primaryKey"` // UserID is the unique identifier for the user and is the primary key in the database.
    Email    string `json:"email" gorm:"unique"`  // Email is a unique identifier for the user and is used for login.
    Password string `json:"password"`             // Password is the user's password for authentication, stored securely.

    Role      string `json:"role"`           // Role represents the user's role within the system (e.g., admin, student, teacher).
    Classroom string `json:"classroom"`   // Classroom links the user to a specific classroom entity.

    CreatedAt time.Time `json:"created_at"` // CreatedAt is the timestamp of when the user was created in the system.
    UpdatedAt time.Time `json:"updated_at"` // UpdatedAt is the timestamp of the last update to the user's data.
}

// UserResponse defines the structure for how user data will be sent to the client.
type UserResponse struct {
    ID        uint   `json:"id" gorm:"primaryKey"`              // ID of the user, included in the response for identification.
    Email     string `json:"email"`           // Email of the user, included in the response for identification.
    Role      string `json:"role"`            // Role of the user, included in the response to define user permissions.
    Classroom string `json:"classroom_id"`    // Classroom ID to identify which classroom the user belongs to.
}

リポジトリー層

package repository

import (
	"ServerSide/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
}

ユースケース層

package usecase

import (
	"ServerSide/model"
	"ServerSide/repository"
	"os"
	"time"

	"github.com/golang-jwt/jwt"
	"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
}

func NewUserUsecase(ur repository.IUserRepository) IUserUsecase {
	return &userUsecase{ur}
}

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

func (uu *userUsecase) Login(user model.User) (string, error) {
	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
}

コントローラー層

package controller

import (
	"ServerSide/model"
	"ServerSide/usecase"
	"context"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/coreos/go-oidc"
	"github.com/joho/godotenv"
	"github.com/labstack/echo/v4"
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
)

type IUserController interface {
	Authentication(c echo.Context) error
	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}
}

// グローバル変数の定義
var (
	clientID     string
	clientSecret string
	redirectURL  = "http://localhost:8080/signup"
	provider     *oidc.Provider
	config       *oauth2.Config
)

func init() {
	// .envファイルから環境変数をロード
	if err := godotenv.Load(); err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	// 環境変数からクライアントIDとクライアントシークレットを取得
	clientID = os.Getenv("GOOGLE_CLIENT_ID")
	clientSecret = os.Getenv("GOOGLE_CLIENT_SECRET")

	// GoogleのOpenID Connectプロバイダーを初期化
	var err error
	provider, err = oidc.NewProvider(context.Background(), "https://accounts.google.com")
	if err != nil {
		log.Fatalf("failed to get provider: %v", err)
	}

	// OAuth 2.0クライアント設定の初期化
	config = &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		RedirectURL:  redirectURL,
		Endpoint:     google.Endpoint,
		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
	}
}

func (uc *userController) Authentication(c echo.Context) error {
	// 認証URLを生成しリダイレクト
	url := config.AuthCodeURL("state", oauth2.AccessTypeOffline)
	return c.Redirect(http.StatusFound, url)
}


func (uc *userController) SignUp(c echo.Context) error {
	ctx := context.Background()
	// 認証コードをトークンに交換
	oauth2Token, err := config.Exchange(ctx, c.QueryParam("code"))
	if err != nil {
		return c.String(http.StatusInternalServerError, "Failed to exchange token: "+err.Error())
	}

	// IDトークンの取得
	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
	if !ok {
		return c.String(http.StatusInternalServerError, "No id_token field in oauth2 token.")
	}

	// IDトークンの検証
	idToken, err := provider.Verifier(&oidc.Config{ClientID: clientID}).Verify(ctx, rawIDToken)
	if err != nil {
		return c.String(http.StatusInternalServerError, "Failed to verify ID Token: "+err.Error())
	}

	// ユーザー情報の取得
	var profile map[string]interface{}
	if err := idToken.Claims(&profile); err != nil {
		return c.String(http.StatusInternalServerError, "Failed to get user profile: "+err.Error())
	}

	// ドメインの検証
	if domain, ok := profile["hd"].(string); !ok || domain != "dev-ryo.com" {
		return c.String(http.StatusUnauthorized, "Unauthorized domain")
	}

	// ユーザー情報の取得
	user := model.User{}
	if err := c.Bind(&user); err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}

	user.Email = profile["email"].(string)

	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,
	})
}

ルーター層

package router

import (
	"ServerSide/controller"

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

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

	e.GET("/auth", uc.Authentication)
	e.POST("/signup", uc.SignUp)
	e.POST("/login", uc.LogIn)
	e.POST("/logout", uc.LogOut)

	return e
}

メインファイル

package main

import (
	"ServerSide/controller"
	"ServerSide/db"
	"ServerSide/repository"
	"ServerSide/router"
	"ServerSide/usecase"
)

func main() {
	db := db.NewDB()
	userReposiotry := repository.NewUserRepository(db)
	userUsecase := usecase.NewUserUsecase(userReposiotry)
	userController := controller.NewUserController(userUsecase)
	e := router.NewRouter(userController)
	e.Logger.Fatal(e.Start(":8080"))
}

Discussion