discord.goのOAuth2認証+サーバーサイド付の多機能Botテンプレートを作ってみた
ご挨拶
皆さんこんにちは!マグロです。
数か月前はPythonばっかり書いていましたが、最近はGoとTypeScriptばっかり書いています。
特にGoに関して結構火が付いたので、前回のdiscordgoの多機能Botに色々追加してみました。
サーバー側
フロント側
前回のものに加えて、
- PostgresとSQLiteの接続
- DiscordOAuth2認証(認証情報をセッションに保存)
- Reactとの連携
を追加しました。
使いたい場合は上記のリポジトリをクローンして実行してみてください。
また今回はサーバー側はkoyeb
にデプロイしますが、フロントはvercel
とkoyeb
のどちらかにデプロイします。
違いは後ほど説明します。
説明しないもの
- 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設定
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=None
、Secure
とHttpOnly
を有効にします。
また、フロントをkoyeb
にデプロイする場合はCookieDomain
を設定します。
Cookieがファーストパーティー製と認識され、対応ブラウザが増えます。
(サーバー側のサブドメインも付けておくと良いです。)
(例:サーバー側のサブドメインがaaaaasas
の場合、store.Options.Domain = "aaaaasas.koyeb.app"
)
詳細はmiddlewares
で説明します。
データベースの使用
// 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の認証画面へ遷移
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.AuthCodeURL
でstate
をURLに加えて、http.Redirect
で認証画面へリダイレクトさせます。
認証時のコールバック
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
を比較します。
同じだった場合、正しい認証と判断し次へ進みます。
// 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
データベース
//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
の内容を実行します。
しかしこのままでは使えません。
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ミドルウェア
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にデプロイ
前回の記事を参考にデプロイしてみてください。
フロント側はREADMEを参照しながら別のプロジェクトを立ち上げてデプロイしましょう。
CORSに対応させているので、Vercel
でもかまいません。
ただし、利用の際はブラウザのサードパーティーcookieもしくはサイト越えトラッキングを許可するようにしてください。
KoyebとVercelの違い
その前にcookieのファーストパーティー製とサードパーティー製について説明します。
簡単に言うと、自身のサイトでしか扱えないcookieか、ほかのサイトでも扱えるcookieかの違いです。
認証情報はkoyeb.app
(サーバー)側に保存されます。
フロントがvercel.app
の場合、ドメイン名が違うので、サーバーの認証情報はサードパーティcookieとして扱われます。
CORSに対応させているので、読み取り自体はできますが、一部のブラウザではサードパーティcookieの締め出しをしているため認証情報が読み取れないブラウザが出てきます。(確認できたのはiPhoneのGoogleアプリのブラウザ)
上記を簡単に言うと、
vercelにフロントをデプロイすると、ブラウザの影響で正しくログインできない場合がある
ということです。
実行
フロント側にアクセスすると以下のような画面が表示されます。
Loginを押して認証をします。
以下のようにDiscordのユーザー名が表示されていれば成功です!
終わりに
いかがでしたか。
個人的にはかなりcookie周りに苦戦しました。
サードパーティーcookieの締め出しが厳しく、ローカルで通っても本番が通らないことが相次ぎました。
いまだに理解しきれていない部分も多く、もっと勉強しないとなーと思いました。
本稿では認証情報の保存、表示のみでしたが、応用すればサーバーの設定変更のフォームを立てる、webhookの投稿内容を設定するといった運用もできます。
ちょっぴりモダンなDiscordBotが運用できるので、Webアプリの勉強がてら作ってみるのはどうですか?
Discussion