0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go言語で開発したアプリ(Web、デスクトップ)からOAuth2認証でメールを送信する方法の紹介

Posted at

はじめに

Microsoftからの「Exchange OnlineにおけるSMTP AUTHのサポート終了」というお知らせ

をみて自身で開発しているアプリからのメール通知をOAuth2認証に対応しなければならないと困っている人がいると思います。私もTWSNMP FC

や TWSNMP FK

というGo言語で開発したソフトにメール通知機能があり、これらのソフトをOAuth2認証に対応した時のポイントを紹介しようと思います。

OAuth2認証対応のポイント

Gemini(AI)にポイントを聞いてみました。

Go言語でOAuth 2.0認証を使ってSMTPでメールを送信するには、主に以下の手順が必要です。

1.OAuth 2.0アクセストークンの取得:
メールサービスプロバイダー(Gmail、Office 365など)の開発者コンソールでアプリケーションを登録し、クライアントIDとクライアントシークレットを取得します。
ユーザーの同意を得て(通常はウェブブラウザ経由)、認可コードを取得します。
取得した認可コードとクライアントシークレットを使って、プロバイダーのトークンエンドポイントからアクセストークンを取得します。SMTPで利用する場合は、多くの場合、オフラインアクセス用のoffline_accessスコープと、メール送信権限を与えるスコープ(例: Gmailの場合は https://mail.google.com/)が必要です。
Go言語の標準ライブラリの golang.org/x/oauth2 パッケージがトークン取得プロセスに役立ちます。

2.SASL XOAUTH2形式の文字列生成:
SMTPでOAuth 2.0認証(XOAUTH2メカニズム)を使用する場合、アクセストークンとユーザー名を特定の形式(user=<ユーザー名>^Aauth=Bearer <アクセストークン>^A^A、ここで^AはControl-A、つまり\x01)で結合し、それをBase64エンコードした文字列を作成する必要があります。
この処理を行うためのGoライブラリ(例: github.com/sqs/go-xoauth2)を利用するか、自分で実装します。

3.SMTP接続と認証:
Go言語の標準ライブラリ net/smtp パッケージを使います。
smtp.PlainAuth の代わりに、SASL XOAUTH2認証を実装したカスタムの smtp.Auth インターフェースを実装します。このカスタム実装が、SMTPサーバーに「AUTH XOAUTH2」コマンドと、ステップ2で生成したBase64エンコードされた文字列を送信します。
net/smtp パッケージの SendMail 関数や、より高度なメール送信ライブラリ(例: gopkg.in/mail.v2)で、カスタムの smtp.Auth を使用してメールを送信します。

マインドマップに描いて整理すると

image.png

OAuth 2.0アクセストークンの取得

OAuth2の参考サイトは
Googleの場合

Mictosoft場合

です。

GoogleのPythonのサンプルコードを実行すれば、アクセストークンを取得できます。

クライアントIDの取得は、Google Cloud Consoleで

image.png

のように設定します。リダイレクトのURIをプログラムと合わせるのがポイントです。
違っているとエラーになります。

Go言語で同じことを実現するプログラムは、

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google" // Googleサービスの場合
)

var (
	// Google Cloud Consoleなどで取得した情報に置き換えてください
	clientID     = "YOUR_CLIENT_ID"
	clientSecret = "YOUR_CLIENT_SECRET"
	redirectURL  = "http://localhost:8180/callback" // アプリケーションのコールバックURL

	// 必要な権限スコープ (例: Gmailの全アクセス権限)
	// SMTP送信に必要なスコープは通常 "https://mail.google.com/"
	scopes = []string{"https://mail.google.com/"}
)

func getConfig() *oauth2.Config {
	return &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		// Googleサービスの場合、google.Endpointを使用
		Endpoint:    google.Endpoint,
		RedirectURL: redirectURL,
		Scopes:      scopes,
	}
}

func getAuthCode() string {
	config := getConfig()
	// StateはCSRF対策のためのランダムな文字列。ここではシンプルにするため固定値
	state := "randomstate"

	// 認可URLを生成
	url := config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
	fmt.Printf("以下のURLをブラウザで開いて認証を完了してください:\n%s\n", url)

	// ユーザーが認証後にリダイレクトされるのを待つHTTPサーバーを設定
	// 実際のアプリケーションでは、この部分はウェブフレームワークで実装されます
	authCodeChan := make(chan string)

	http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
		// Stateの検証
		if r.FormValue("state") != state {
			http.Error(w, "State mismatch", http.StatusBadRequest)
			return
		}

		// 認可コードを取得
		code := r.FormValue("code")
		if code == "" {
			http.Error(w, "Authorization code not found", http.StatusBadRequest)
			return
		}

		// ユーザーに認証成功を伝え、コードをチャネルに送信
		fmt.Fprintf(w, "認証が完了しました。このウィンドウを閉じてください。")
		authCodeChan <- code
	})

	// サーバーをバックグラウンドで起動
	go http.ListenAndServe(":8180", nil)

	// 認可コードがチャネルに送られるのを待つ
	return <-authCodeChan
}

func getTokenFromWeb(code string) (*oauth2.Token, error) {
	config := getConfig()
	ctx := context.Background()

	// 認可コードを使ってトークンを取得
	token, err := config.Exchange(ctx, code)
	if err != nil {
		return nil, fmt.Errorf("トークンの取得に失敗しました: %w", err)
	}

	return token, nil
}

func main() {
	// 1. 認可コードを取得
	authCode := getAuthCode()

	// 2. 認可コードからトークンを取得
	token, err := getTokenFromWeb(authCode)
	if err != nil {
		log.Fatalf("トークン取得エラー: %v", err)
	}

	fmt.Println("\n--- 成功 ---")
	fmt.Printf("アクセストークン: %s\n", token.AccessToken)

	// リフレッシュトークンが存在する場合、永続化して将来的に再利用する必要があります
	if token.RefreshToken != "" {
		fmt.Printf("リフレッシュトークン (永続化が必要です): %s\n", token.RefreshToken)
	} else {
		fmt.Println("リフレッシュトークンは取得されませんでした (oauth2.AccessTypeOffline が必要です)。")
	}

	// 💡 次のステップ: このアクセストークンを
	// カスタムの XOAUTH2実装に渡し、SMTPメールを送信します。
}

メール送信の設定画面

Webやデスクトップアプリに組み込む場合には、設定画面が必要です。
TWSNMP FKの場合には

image.png

のような画面にしました。
プロバイダーを選択するとOAuth2やメール送信に必要なパラメータの入力画面を表示します。
クライアントID,クライアントシークレット、ユーザー(アカウント)です。
Micsrosoftの場合は、テナントIDが必要でした。

設定を保存したら<トークン取得>ボタンをクリックして、最初のトークン取得を行います。
先程のテストプログラムでは
「以下のURLをブラウザで開いて認証を完了してください」
のメッセージで表示されるURLにアクセスすれば同じ画面が表示されます。

image.png

のようにブラウザーでアカウントの選択画面が表示されます。ログインしていない場合は、
ログイン画面が表示されます。<続行>をクリックして、確認画面がでます。
最後に

image.png

アクセスを許可すれば、トークンが発行されます。

アクセストークンを使ってメールを送信する

Go言語の標準のnet/smtpパッケージでは、OAuth2の認証に対応していません。
対応したパッケージとして

があります。これを使えば簡単に対応できます。
テストコードは、

package main

import (
	"log"

	"github.com/wneessen/go-mail"
)

func main() {
	username := "<Gamilのアカウント(メールアドレス)>"
	password := "<取得したOAuth2のトークン>"
	client, err := mail.NewClient("smtp.gmail.com",
		mail.WithTLSPortPolicy(mail.TLSMandatory),
		mail.WithSMTPAuth(mail.SMTPAuthXOAUTH2),
		mail.WithLogAuthData(),
		mail.WithDebugLog(),
		mail.WithUsername(username), mail.WithPassword(password))
	if err != nil {
		log.Fatalf("failed to create mail client: %s\n", err)
	}

	message := mail.NewMsg()
	if err := message.From("<送信元のメールアドレス>"); err != nil {
		log.Fatalf("failed to set From address: %s", err)
	}
	if err := message.To("<宛先のメールアドレス>"); err != nil {
		log.Fatalf("failed to set To address: %s", err)
	}
	message.Subject("This is my first mail with go-mail!")
	message.SetBodyString(mail.TypeTextPlain, "Do you like this mail? I certainly do!")
	if err := client.DialAndSend(message); err != nil {
		log.Fatalf("failed to send mail: %s", err)
	}
}

テストする時は、username,送信元、宛先を、同じgmailのアドレスにするのが
よいと思います。

OAuth 2.0アクセストークンの更新

Googleの場合アクセストークンは、1時間ぐらいで無効になってしまいます。トークンを更新する必要があります。更新は、リフレッシュ用のトークンを使って行います。
トークンのリフレッシュ処理は、

// persistedToken: DBなどに保存されていた前回の認証情報
func refreshAccessToken(persistedToken *oauth2.Token) (*oauth2.Token, error) {
	ctx := context.Background()
	config := getConfig()

	// 1. Saved Token (persistedToken) から TokenSource を作成します。
	//    この TokenSource は、トークンが期限切れの場合にリフレッシュトークンを使用して自動的にリフレッシュを試みます。
	tokenSource := config.TokenSource(ctx, persistedToken)

	// 2. TokenSource.Token() を呼び出すと、以下の処理が行われます。
	//    - トークンがまだ有効な場合は、そのトークンを返す。
	//    - トークンが期限切れの場合は、リフレッシュトークンを使用して新しいトークンを自動で取得する(リフレッシュリクエスト)。
	//    - 新しいトークンが取得されると、そのトークンを返す。
	newToken, err := tokenSource.Token()
	if err != nil {
		return nil, fmt.Errorf("TokenSourceによるトークンの取得またはリフレッシュに失敗しました: %w", err)
	}

	// 3. ⚠️ 重要: 新しいトークン(newToken)には、新しい有効期限と、
	//    更新されたリフレッシュトークン(もしプロバイダーが更新した場合)が含まれています。
	//    この新しいトークン全体をデータベースやファイルに保存し直す必要があります。
	//    (リフレッシュトークンは古いものと同じである場合もあります。)

	return newToken, nil
}

です。取得したトークンを保存しておくのがポイントです。この処理の場合、トークンが有効ならば更新を行わず、そのまま使います。

なんとなく、トークンを定期的にリフレッシュする処理が必要なように思いますが、メールを送信する前に、このリフレッシュの処理を実行すれば、必要な時だけリフレッシュされます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?