🌟

discord.goのOAuth2認証+サーバーサイド付の多機能Botテンプレートを作ってみた

2023/10/31に公開

ご挨拶

皆さんこんにちは!マグロです。
数か月前はPythonばっかり書いていましたが、最近はGoとTypeScriptばっかり書いています。
特にGoに関して結構火が付いたので、前回のdiscordgoの多機能Botに色々追加してみました。

サーバー側

https://github.com/maguro-alternative/discord_go_bot_pro

フロント側

https://github.com/maguro-alternative/discordgo_bot_front

前回のものに加えて、

  • PostgresとSQLiteの接続
  • DiscordOAuth2認証(認証情報をセッションに保存)
  • Reactとの連携

を追加しました。

使いたい場合は上記のリポジトリをクローンして実行してみてください。

また今回はサーバー側はkoyebにデプロイしますが、フロントはvercelkoyebのどちらかにデプロイします。
違いは後ほど説明します。

説明しないもの

  • DiscordBotに関する基礎知識(登録、権限など)
  • OAuth2の説明
  • CORS
  • Reactの基礎

Bot側ディレクトリ構成

以下のようなディレクトリ構成になっています。

$ tree
.
├── bot_handler
│   ├── bot_router                   # Botのハンドラー設定
│   │   ├── command.go               # スラッシュコマンドのハンドラー設定
│   │   └── handler.go               # イベントハンドラの設定
│   ├── message_create.go            # テキストチャンネルに何か発言があった場合
│   └── vc_signal.go                 # ボイスチャンネルのステータス変化があった場合
├── commands
│   ├── disconnect_voice_channel.go  # ボイスチャンネルから切断するスラッシュコマンド
│   ├── ping.go                      # ping確認するスラッシュコマンド
│   └── start_record.go              # 録音を開始するスラッシュコマンド
├── controllers
│   └── discord
│       ├── discord_auth.go          # discordの認証ページに遷移
│       └── discord_callback.go      # 認証時のcallback
├── db
│   ├── table
│   ├── db.go                        # データベース操作
│   └── schema.sql                   # 起動時に実行されるSQL
├── model
│   ├── discord                      # discordに関する型宣言
│   │   └── discord_model.go
│   ├── envconfig                    # 環境変数設定
│   │   └── env.go
│   └── index.go                     # サーバーのホームアクセス時のレスポンス
├── server_handler
│   ├── _test
│   │   └── oauth_check.go           # OAuth2認証時のテスト
│   ├── middleware
│   │   └── cors.go                  # CORS設定
│   ├── router
│   │   └── router.go                # サーバーのルータ設定
│   └── index.go                     # ホーム
├── service
│   ├── discord_login.go             # DiscordOAuth2時の型
│   └── index.go
├── Dockerfile
├── .env
├── go.mod
├── go.sum
└── main.go                         # Bot+サーバーの立ち上げ

Botの構成自体は前回から引き継いでいます。
新しく追加したものを説明します。

main.go

Cookie設定
main.go
	var store = sessions.NewCookieStore([]byte(env.SessionsSecret))
	store.Options = &sessions.Options{
		Path:     "/",
		MaxAge:   86400 * 7,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteNoneMode,
	}
	// ドメインが設定されている場合はセット
	if env.CookieDomain != "" {
		store.Options.Domain = env.CookieDomain
	}

Discordの認証情報を保存するため、Cookieの設定を行います。
env.SessionsSecretを鍵にして暗号化をします。
フロント側が認証情報を読み取れるようにSameSite=NoneSecureHttpOnlyを有効にします。
また、フロントをkoyebにデプロイする場合はCookieDomainを設定します。
Cookieがファーストパーティー製と認識され、対応ブラウザが増えます。
(サーバー側のサブドメインも付けておくと良いです。)
(例:サーバー側のサブドメインがaaaaasasの場合、store.Options.Domain = "aaaaasas.koyeb.app")

詳細はmiddlewaresで説明します。

データベースの使用
main.go
	// DBの接続
	dbPath := env.DatabaseType + "://" + env.DatabaseHost + ":" + env.DatabasePort + "/" + env.DatabaseName + "?" + "user=" + env.DatabaseUser + "&" + "password=" + env.DatabasePassword + "&" + "sslmode=disable"
	db, err := db.NewPostgresDB(dbPath)
	if err != nil {
		fmt.Println(err)
	}

dbPathはPostgresのURLを示します。
NewPostgresDBでPostgresへの接続を行います。

	// ハンドラーの登録
	botRouter.RegisterHandlers(discord, db)
	...
	// 追加したいコマンドをここに追加
	commandHandler.CommandRegister(commands.PingCommand(db))
	...
	mux := router.NewRouter(
			db,
			store,
			discord,
			env,
	)

上記のように各種ハンドラーにデータベースの引数が追加されています。
Botのハンドラーやhttpサーバーのハンドラー内でデータベースを使用することができます。
詳細はdbで説明します。

controllers

Discordの認証画面へ遷移
discord_auth.go
func (h *DiscordAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// セッションに保存する構造体の型を登録
	// これがない場合、エラーが発生する
	gob.Register(&discordModel.DiscordUser{})
	uuid := uuid.New().String()
	session, err := h.svc.CookieStore.Get(r, h.svc.Env.SessionsSecret)
	if err != nil {
		panic(err)
	}
	session.Values["state"] = uuid
	// セッションに保存
	session.Save(r, w)
	h.svc.CookieStore.Save(r, w, session)
	conf := h.svc.OAuth2Config
	// 1. 認可ページのURL
	url := conf.AuthCodeURL(uuid, oauth2.AccessTypeOffline)
	http.Redirect(w, r, url, http.StatusFound)
}

/discord/authにアクセスした場合に実行されます。
gobを使用することで、goの構造体をcookieに保存することができます。
またCSRF対策として、事前にstateを発行してcookieに保存しておきます。

conf.AuthCodeURLstateをURLに加えて、http.Redirectで認証画面へリダイレクトさせます。

認証時のコールバック
discord_callback.go
	session, err := h.svc.CookieStore.Get(r, h.svc.Env.SessionsSecret)
	if err != nil {
		panic(err)
	}
	state, ok := session.Values["state"].(string)
	if !ok {
		fmt.Println(reflect.TypeOf(session.Values["state"]))
		panic("state is not string")
	}
	// 2. 認可ページからリダイレクトされてきたときに送られてくるstateパラメータ
	if r.URL.Query().Get("state") != state {
		fmt.Println(session.Values["state"])
		session.Values["state"] = ""
		h.svc.CookieStore.Save(r, w, session)
		panic("state is not match")
	}
	session.Values["state"] = ""

認証時に送られてきたstateとcookieに保存されているstateを比較します。
同じだった場合、正しい認証と判断し次へ進みます。

discord_callback.go
	// 1. 認可ページのURL
	code := r.URL.Query().Get("code")
	conf := h.svc.OAuth2Config
	ctx := context.Background()
	// 2. アクセストークンの取得
	token, err := conf.Exchange(ctx, code)
	if err != nil {
		panic(err)
	}
	session.Values["discord_access_token"] = token.AccessToken
	// 3. ユーザー情報の取得
	client := conf.Client(ctx, token)
	resp, err := client.Get("https://discord.com/api/users/@me")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	var user discordModel.DiscordUser
	if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
		panic(err)
	}
	// セッションに保存
	session.Values["discord_user"] = user
	err = session.Save(r, w)
	if err != nil {
		panic(err)
	}
	err = h.svc.CookieStore.Save(r, w, session)
	if err != nil {
		panic(err)
	}
	log.Println(user)
	// 4. ログイン後のページに遷移
	http.Redirect(w, r, h.svc.Env.FrontUrl + "/test-user", http.StatusFound)

codeパラメータを使用し、Discordのアクセストークンを取得します。
加えてユーザー情報も取得し、cookieに保存します。

上記の処理が終わったら、フロントの/test-userへリダイレクトされます。
test-userは、認証したユーザー名が表示されるようになっています。

db

データベース
db.go
//go:embed schema.sql
var schema string // schema.sqlの内容をschemaに代入
...
func NewPostgresDB(path string) (*sqlx.DB, error) {
	// データベースに接続
	db, err := sqlx.Open("postgres", path)
	if err != nil {
		return nil, err
	}

	// テーブルの作成
	if _, err := db.Exec(schema); err != nil {
		return nil, err
	}

	return db, nil
}

データベース(postgres)へ接続を行います。
接続成功時、同階層にあるschema.sqlの内容を実行します。
しかしこのままでは使えません。

db.go
func NewDBHandler(db *sqlx.DB) *DBHandler {
	/*
		データベースで行う処理をまとめた構造体を返す

		引数
			db: *sql.DB型の変数

		戻り値
			*DBHandler型の変数
	*/
	// データベースの接続を確認
	PingDB := func(ctx context.Context) error {
		if err := db.PingContext(ctx); err != nil {
			return err
		}
		return nil
	}
	...
	return &DBHandler{
		Driver:           db,
		DBPing:           PingDB,
		CheckTables:      TablesCheck,
		QueryxContext:    QueryxContext,
		QueryRowxContent: QueryRowxContent,
		GetContent:       GetContent,
		SelectContent:    SelectContent,
		ExecContext:      ExecContext,
		NamedExecContext: NamedExecContext,
	}
}

各ハンドラーでデータベースを扱うため、NewDBHandlerでそれぞれのハンドラーに生成します。
扱いたい操作を共通化できるので、それぞれで新しく宣言する手間が省けます。

middlewares

CORSミドルウェア
cors.go
package middleware

import (
	"net/http"

	"github.com/maguro-alternative/discord_go_bot/model/envconfig"
)

// セッションの確認
func CORS(next http.Handler) http.Handler {
	env, err := envconfig.NewEnv()
	if err != nil {
		panic(err)
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// クロスオリジン用にセット
		w.Header().Set("Access-Control-Allow-Origin", env.FrontUrl)
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		w.Header().Set("Access-Control-Allow-Credentials", "true")
		w.Header().Set("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,UPDATE,OPTIONS")
		w.Header().Set("Content-Type", "application/json")

		// preflight用に200でいったん返す
		if r.Method == "OPTIONS" {
			w.WriteHeader(http.StatusOK)
			return
		}
		next.ServeHTTP(w, r)
	})
}

認証情報をフロントと共有させるため、CORSの設定をします。
各設定の説明は省きます。CORSで各自調べてください。

フロント側ディレクトリ構成

$ tree
.
├── src
│   ├── pages
│   │   └── index.tsx
│   ├── test
│   │   └── test.tsx
│   ├── App.css
│   ├── App.tsx
│   ├── index.css
│   ├── main.tsx
│   └── vite-env.d.tsx
├── .eslintrc.cjs
├── index.html
├── package-lock.json
├── package.json
├── tsconfig.json
├── tsconfig.node.json
├── vercel.json
└── vite.config.ts

シンプルに作っているので説明は省きます。

Koyebにデプロイ

前回の記事を参考にデプロイしてみてください。

https://zenn.dev/maguro_alterna/articles/66c388d6429d63

フロント側はREADMEを参照しながら別のプロジェクトを立ち上げてデプロイしましょう。
CORSに対応させているので、Vercelでもかまいません。
ただし、利用の際はブラウザのサードパーティーcookieもしくはサイト越えトラッキングを許可するようにしてください。

KoyebとVercelの違い

その前にcookieのファーストパーティー製とサードパーティー製について説明します。
簡単に言うと、自身のサイトでしか扱えないcookieか、ほかのサイトでも扱えるcookieかの違いです。
認証情報はkoyeb.app(サーバー)側に保存されます。
フロントがvercel.appの場合、ドメイン名が違うので、サーバーの認証情報はサードパーティcookieとして扱われます。
CORSに対応させているので、読み取り自体はできますが、一部のブラウザではサードパーティcookieの締め出しをしているため認証情報が読み取れないブラウザが出てきます。(確認できたのはiPhoneのGoogleアプリのブラウザ)

上記を簡単に言うと、
vercelにフロントをデプロイすると、ブラウザの影響で正しくログインできない場合がある
ということです。

https://vercel.com/docs/frameworks/vite

実行

フロント側にアクセスすると以下のような画面が表示されます。

Loginを押して認証をします。

以下のようにDiscordのユーザー名が表示されていれば成功です!

終わりに

いかがでしたか。
個人的にはかなりcookie周りに苦戦しました。
サードパーティーcookieの締め出しが厳しく、ローカルで通っても本番が通らないことが相次ぎました。
いまだに理解しきれていない部分も多く、もっと勉強しないとなーと思いました。

本稿では認証情報の保存、表示のみでしたが、応用すればサーバーの設定変更のフォームを立てる、webhookの投稿内容を設定するといった運用もできます。
ちょっぴりモダンなDiscordBotが運用できるので、Webアプリの勉強がてら作ってみるのはどうですか?

Discussion