📌

【Go】go-blueprintを使ってOAuth2を爆速で実装する

2024/11/11に公開

はじめに

GoでOAuth2認証の実装を行います。
今回は例としてgo-blueprintを使用し、Google認証を行う流れを紹介します。

ライブラリについて

go-blueprint

https://github.com/Melkeydev/go-blueprint
go-blueprintはプロジェクトテンプレートや生成ツールです。
DBの接続設定、GoプロジェクトのDocker設定、GithubActionsを使用したCI/CDワークフローのセットアップなどを自動的に行なってくれます。

goth

https://github.com/markbates/goth
gothは、Go言語でOAuth 認証を簡単に実装するためのライブラリです。Google、Facebook、Apple、Githubなどの主要なソーシャルログインプロバイダに対応しており、ユーザー認証フローを簡素化することができます。

事前に

Google Cloudで、あらかじめ作成しておいたプロジェクトを開きます。
その後、クライアントID、クライアントシークレットを発行してください。
また、リダイレクトするコールバックURIも許可するために登録しておきます。

サーバ側の実装

まず下記のコマンドを打ってインストールとプロジェクト作成を行います。

go install github.com/melkeydev/go-blueprint@latest
go-blueprint create  

任意のプロジェクト名で今回はフレームワークはGinを選択します。

プロジェクトのテンプレートがされるはずです。
その後、各パッケージをインストールします。

go get github.com/markbates/goth  
go get github.com/gorilla/sessions

次にプロジェクト内にinternal/auth/auth.goを作成します。
 
auth.goではGoogle OAuth 2.0認証を実装するためのセットアップを行う関数を定義しています。この関数を呼び出すことで、アプリケーションが Google認証を通じたユーザー認証を提供できるようになります。
今回はGoogle認証を行うのでgoth.UseProviders(google.New(...))により、Google認証用のプロバイダを設定します。このプロバイダは、Googleの認証エンドポイントとコールバックURLを指定します。

auth.go
package auth

import (
	"log"
	"os"

	"github.com/gorilla/sessions"
	"github.com/joho/godotenv"
	"github.com/markbates/goth"
	"github.com/markbates/goth/gothic"
	"github.com/markbates/goth/providers/google"
)

const (
	MaxAge = 86400 * 30
	IsProd = false
)

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

	googleClientId := os.Getenv("GOOGLE_CLIENT_ID")
	googleClientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
	sessionSecret := os.Getenv("SESSION_SECRET")

	if sessionSecret == "" {
		log.Fatal("SESSION_SECRET is not set")
	}

	store := sessions.NewCookieStore([]byte(sessionSecret))
	store.MaxAge(MaxAge)

	store.Options.Path = "/"
	store.Options.HttpOnly = true
	store.Options.Secure = IsProd

	gothic.Store = store

	goth.UseProviders(
		google.New(googleClientId, googleClientSecret, "http://localhost:3000/auth/google/callback"),
	)
}

auth.NewAuth() を呼び出すことで、Google認証の設定と、セッションの管理に必要な準備が整います。これにより、後でユーザーがGoogleアカウントを使って認証を行い、セッションにその情報を保存できるようになります。

main.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os/signal"
	"syscall"
	"time"

	"go-oauth/internal/auth"
	"go-oauth/internal/server"
)

func gracefulShutdown(apiServer *http.Server, done chan bool) {
	// Create context that listens for the interrupt signal from the OS.
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	// Listen for the interrupt signal.
	<-ctx.Done()

	log.Println("shutting down gracefully, press Ctrl+C again to force")

	// The context is used to inform the server it has 5 seconds to finish
	// the request it is currently handling
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := apiServer.Shutdown(ctx); err != nil {
		log.Printf("Server forced to shutdown with error: %v", err)
	}

	log.Println("Server exiting")

	// Notify the main goroutine that the shutdown is complete
	done <- true
}

func main() {
	auth.NewAuth()
	server := server.NewServer()

	// Create a done channel to signal when the shutdown is complete
	done := make(chan bool, 1)

	// Run graceful shutdown in a separate goroutine
	go gracefulShutdown(server, done)

	err := server.ListenAndServe()
	if err != nil && err != http.ErrServerClosed {
		panic(fmt.Sprintf("http server error: %s", err))
	}

	// Wait for the graceful shutdown to complete
	<-done
	log.Println("Graceful shutdown complete.")
}

getAuthCallbackFunctionは、ユーザーが認証後に呼び出されるOAuthのコールバック処理です。
今回の実装する使用はGothicを用いてユーザー認証を完了し、認証済みユーザー情報をセッションに保存します。正常に処理が終わったら、フロントエンドのユーザ画面(http://localhost:5173/user)にリダイレクトするという仕様になります。
getAuthCallbackFunctionは認証情報を解析し、それをもとにユーザ情報をレスポンスで返すシンプルなAPIです。

route.go
package server

import (
	"net/http"
	"os"

	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"github.com/gorilla/sessions"
	"github.com/markbates/goth"
	"github.com/markbates/goth/gothic"
)

var sessionSecret = os.Getenv("SESSION_SECRET")
var store = gothic.Store.(*sessions.CookieStore)

func (s *Server) RegisterRoutes() http.Handler {
	r := gin.Default()

	r.Use(cors.New(cors.Config{
		// クライアントのURL
		AllowOrigins:     []string{"http://localhost:5173"},
		AllowMethods:     []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		AllowHeaders:     []string{"Origin", "Content-Type", "Accept"},
		ExposeHeaders:    []string{"Content-Length"},
		AllowCredentials: true,
	}))

	r.GET("/auth/:provider/callback", s.getAuthCallbackFunction)

	r.GET("/logout/:provider", func(c *gin.Context) {
		gothic.Logout(c.Writer, c.Request)
		c.Redirect(http.StatusTemporaryRedirect, "/")
	})
	r.GET("/", s.HelloWorldHandler)

	r.GET("/auth/:provider", func(c *gin.Context) {
		q := c.Request.URL.Query()
		q.Add("provider", c.Param("provider"))
		c.Request.URL.RawQuery = q.Encode()
		gothic.BeginAuthHandler(c.Writer, c.Request)
	})
	r.GET("/getUser", func(c *gin.Context) {
		s.getUserAfterAuthorization(c)
	})
	return r
}

func (s *Server) HelloWorldHandler(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{"message": "Hello World"})
}


func (s *Server) getAuthCallbackFunction(c *gin.Context) {
	user, err := gothic.CompleteUserAuth(c.Writer, c.Request)
	if err != nil {
		c.String(http.StatusInternalServerError, err.Error())
		return
	}

	var store = gothic.Store.(*sessions.CookieStore)
	session, err := store.Get(c.Request, sessionSecret)
	if err != nil {
		c.String(http.StatusInternalServerError, err.Error())
		return
	}

	session.Values["user_data"] = user
	if err := session.Save(c.Request, c.Writer); err != nil {
		c.String(http.StatusInternalServerError, err.Error())
		return
	}
	c.Redirect(http.StatusFound, "http://localhost:5173/user")
}

func (s *Server) getUserAfterAuthorization(c *gin.Context) {
	session, err := store.Get(c.Request, sessionSecret)
	if err != nil {
		c.String(http.StatusInternalServerError, "Failed to get session")
		return
	}

	userData, ok := session.Values["user_data"].(goth.User)
	if !ok {
		c.String(http.StatusUnauthorized, "User not authenticated")
		return
	}

	response := map[string]string{
		"email": userData.Email,
		"name":  userData.Name,
		"access_token":  userData.AccessToken,
	}
	c.JSON(http.StatusOK, response)
}

クライアント側の実装(おまけ)

テストする上で、Reactでクライアント側も作成しました。
※Goの記事を書くのが目的だったため、このへんはざっくりテキトーになりますがお許しください

"/"(ログイン画面)
import '../App.css'

const Home: React.FC = () => {

  const handleLogin = () => {
    window.location.href  = "http://localhost:3000/auth/google";
  }

  return (
    <>
      <div className="card">
        <button onClick={handleLogin}>
          Googleでログイン
        </button>
      </div>
    </>
  )
}

export default Home

認証成功後にユーザ画面にリダイレクトされます。
クッキー情報をバックエンドに送信します。
バックエンド側で認証情報を解析してユーザ情報をレスポンスで返してくれるので、それを画面に表示します。
今回はサンプルでメールアドレスと名前を表示しています。

"/user"(ユーザ画面)
import React, { useEffect, useState } from 'react';

type UserData = {
  email: string;
  name: string;
  accessToken: string;
  // 必要に応じて他のフィールドも定義
};

const UserPage: React.FC = () => {
  const [userData, setUserData] = useState<UserData>();

  useEffect(() => {
    const fetchUserData = async () => {
        const response = await fetch('http://localhost:3000/getUser', {
          credentials: 'include', // クッキーを含めてセッション情報を取得
        });

        if (!response.ok) {
          throw new Error('Failed to fetch user data');
        }

        const data = await response.json();
        setUserData(data);
    };

    fetchUserData();
  }, []);


  if (!userData) {
      return <div>Loading...</div>;
  }

  return (
      <div>
          <h1>Email: {userData.email}</h1>
          <h1>Name: {userData.name}</h1>
      </div>
  );
};

export default UserPage;

感想

OAuth2認証を学ぶ上でgo-blueprintを活用しGoogle認証を実装してみました。
今回は自分で実装したものは、NewAuthgetAuthCallbackFunctiongetUserAfterAuthorizationぐらいなので面倒な初期設定は省くことができ、非常に便利でした。
今回は最初の認証部分を実装しましたが、今後はリフレッシュトークンを活用し、アクセストークンの再取得するロジックにも挑戦していきたいです。

Discussion