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の流れを確認してみる

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

Controller
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
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
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
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 へメソッドを追加する
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 メソッドを追加します。
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 でベストプラクティスとして公開していることを参考にしています。
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