🔥

Go, Ginで Google Loginを実装する

に公開

はじめに

こんにちは、kopherと申します。
今回は Go, Gin, Entを使い Google Loginを実装してみたいと思います。
今回使う方式としては OIDC 認証ではなく OAuth2.0 認可方式を使います。

使用する技術

  • Go
  • Gin
  • Ent
  • golang-jwt/jwt/v5
  • golang.org/x/oauth2

Event Stormingをやってみる

Event Stormingによって出てきた Aggregate としては 臨時メンバーメンバー が出てきました。

今回の記事では会員登録は扱わないです

臨時メンバー

臨時メンバー は 初めてログインするユーザーのために作られます、普通に メンバー を作ってもいいのですが会員登録するまでは本当の メンバー ではないため概念的に分離しております。

Architecture を決める

小さいサービスを作るので簡単な Layered Architecture(3-Tier Architecture) を使用します。

制約条件

  • Frontend側に Client ID と Client Secret を持たせない。

OAuth2.0の流れを確認してみる

https://developers.google.com/identity/protocols/oauth2?hl=ja

実装

Login Handlerを実装する

制約条件によってフロントエンドで直接Googleログイン画面を開くのはできません。
その為、/v1/auth/login を叩いて Google Login する為の URL を返します。
ユーザーにログイン画面を見せるURLをフロントエンドに返すまで実装してみます。

Controller

auth_controller.go

type AuthController interface {
	Login(c *gin.Context)
}

type authController struct {
	authService service.AuthService
}

func NewAuthController(authService service.AuthService) AuthController {
	return &authController{ authService: authService }
}

func (a *authController) Login(c *gin.Context) {
	response := a.authService.Login(c)
	c.JSON(http.StatusOK, response)
}

Service

Googleとやりとりの為のConfig構造体の設定

Google Developer Console からClient IDとClient Secretを取得してください

Client IDとClient SecretがGithub上に上がらないように気をつけてください。

CLIENT_ID=取得した値を貼り付ける
CLIENT_SECRET=取得した値を貼り付ける

OAUTH_REDIRECT_URL=http://localhost:8080/v1/auth/login
OAUTH_SCOPES=https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile
OAUTH_USER_INFO=https://www.googleapis.com/oauth2/v2/userinfo
config.go
var OAuthConfig *OAuth

type OAuth struct {
	config oauth2.Config
}

func NewOAuth() *OAuth {
	scopes := strings.Split(os.Getenv("OAUTH_SCOPES"), ",")
	return &OAuth{
		config: oauth2.Config{
			ClientID:     os.Getenv("CLIENT_ID"),
			ClientSecret: os.Getenv("CLIENT_SECRET"),
			RedirectURL:  os.Getenv("OAUTH_REDIRECT_URL"),
			Scopes:       scopes,
			Endpoint:     google.Endpoint,
		},
	}
}

func init() {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file", err)
	}
	OAuthConfig = NewOAuth()
}

func (o *OAuth) AuthCodeURL(state string) string {
	return o.config.AuthCodeURL(state)
}

AuthService

auth_service.go
type AuthService interface {
	Login(c context.Context) models.LoginResponse
}

type authService struct {
	authRepository repository.AuthRepository
}

func NewAuthService(authRepository repository.AuthRepository) AuthService {
	return &authService{
		authRepository: authRepository,
	}
}

func (a *authService) Login(c context.Context) models.LoginResponse {
	url := config.OAuthConfig.AuthCodeURL("state")
	return models.LoginResponse{
		URL: url,
	}
}

GoogleCallback Handlerの実装

Google Developer Consoleに redirect uriを追加してください。
authorization_codeを取得してユーザー情報を取得するまでを実装します。

Controller

auth_controller.go
type AuthController interface {
	Login(c *gin.Context)
	GoogleCallback(c *gin.Context)
}

...

func (a *authController) GoogleCallback(c *gin.Context) {
	code := c.Query("code") // authorization codeの取得
	accessToken, err := a.authService.GoogleCallback(c, code)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     "access_token",
		Value:    accessToken,
		HttpOnly: true,
		// Secure:   false, // TODO : 本番環境では true にする
		SameSite: http.SameSiteLaxMode,
		Path:     "/",
		MaxAge:   int((time.Minute * 30).Seconds()),
	})

	c.Redirect(http.StatusTemporaryRedirect, "/")
}

Service

OAuth2Config へメソッドを追加する

config.go

func (o *OAuth) GetAccessToken(c context.Context, code string) (*oauth2.Token, error) {
	return o.config.Exchange(c, code)
}

func (o *OAuth) GetMember(c context.Context, token *oauth2.Token) (*domain.Member, error) {
	client := o.config.Client(c, token)
	// TODO : 環境変数に定義する
	resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
	if err != nil {
		log.Println("Failed to get user info from google", err)
		return nil, err
	}
	defer resp.Body.Close()

	var userInfo map[string]any
	err = json.NewDecoder(resp.Body).Decode(&userInfo)
	if err != nil {
		log.Println("Failed to decode user info from google", err)
		return nil, err
	}

	log.Println("userInfo", userInfo)
	return &domain.Member{
		ID:       userInfo["sub"].(string),
		Email:    userInfo["email"].(string),
		Picture:  userInfo["picture"].(string),
		Nickname: userInfo["name"].(string),
	}, nil
}

GoogleCallback メソッドを追加します。

auth_service.go
type AuthService interface {
	Login(c context.Context) models.LoginResponse
	GoogleCallback(c *gin.Context, code string) (string, error)
}

...

func (a *authService) GoogleCallback(c *gin.Context, code string) (string, error) {
	token, err := config.OAuthConfig.GetAccessToken(c, code)
	if err != nil {
		return "", err
	}

	member, err := config.OAuthConfig.GetMember(c, token)
	if err != nil {
		return "", err
	}

    // 臨時ユーザーの作成
	if _, err := a.authRepository.GetTransientMemberByID(c, member.ID); err != nil {
		if ent.IsNotFound(err) {
			_, err := a.authRepository.CreateTransientMember(c, &domain.TransientMember{
				ID:       member.ID,
				Email:    member.Email,
				Picture:  member.Picture,
				Nickname: member.Nickname,
			})
			if err != nil {
				return "", err
			}
		}
	}

    // Web Application 側の Access Token を作る
	accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"iss": "team-recruitment",
		"sub": member.ID,
		"exp": time.Now().Add(time.Minute * 30).Unix(),
	}).SignedString(config.JWTConfig.GetSecretKey())
	if err != nil {
		log.Printf("error creating access token: %v", err)
		return "", err
	}

	return accessToken, nil
}

Repository

TransactionManager に関しては ent でベストプラクティスとして公開していることを参考にしています。
https://entgo.io/docs/transactions/#best-practices

auth_repository.go

type AuthRepository interface {
	CreateTransientMember(c context.Context, member *domain.TransientMember) (*domain.TransientMember, error)
	GetTransientMemberByID(c context.Context, id string) (*domain.TransientMember, error)
}

type authRepository struct {
	client *ent.Client
	tx     *TransactionManager
}

func NewAuthRepository(client *ent.Client) AuthRepository {
	return &authRepository{
		client: client,
		tx:     NewTransactionManager(client),
	}
}

func (a *authRepository) CreateTransientMember(c context.Context, member *domain.TransientMember) (*domain.TransientMember, error) {
	var result *domain.TransientMember
	err := a.tx.WithTx(c, func(tx *ent.Tx) error {
		transientMember, err := tx.TransientMember.Create().
			SetTransientMemberID(member.ID).
			SetEmail(member.Email).
			SetPicture(member.Picture).
			SetNickname(member.Nickname).
			Save(c)
		if err != nil {
			return err
		}

		result = &domain.TransientMember{
			ID:       transientMember.TransientMemberID,
			Email:    transientMember.Email,
			Picture:  transientMember.Picture,
			Nickname: transientMember.Nickname,
		}
		return nil
	})
	if err != nil {
		return nil, err
	}

	return result, nil
}

func (a *authRepository) GetTransientMemberByID(c context.Context, id string) (*domain.TransientMember, error) {
	var result *domain.TransientMember
	if err := a.tx.WithTx(c, func(tx *ent.Tx) error {
		member, err := tx.TransientMember.Query().Where(transientmember.TransientMemberID(id)).First(c)
		if err != nil {
			return err
		}

		result = &domain.TransientMember{
			ID:       member.TransientMemberID,
			Email:    member.Email,
			Picture:  member.Picture,
			Nickname: member.Nickname,
		}

		return nil
	}); err != nil {
		log.Printf("error getting transient member by id: %v", err)
		return nil, err
	}
	return result, nil
}

結論

結構複雑ですが OAuth2.0認可フローをより理解できることになり良かったと思いました。
Google Login 以外でも Apple Login、Github Loginも追加して共通するロジックを抽象化してみるのも面白そうです!

ご読みいただきありがとうございました。🙏

Discussion