GoでSMTPを使用したメール二段階認証ページを作ってみた

# GoでSMTPを使用したメール二段階認証ページを作ってみた
Goでユーザー名/パスワード認証とワンタイムパスワードを使用した二段階認証を作ってみました。イメージをつかむためのテストとして作成したものでそのまま使用できるようなものではありませんが、ご参考になればと思います。

また、駆け出しなのでプログラムのミスや単語の誤用があるかと思いますので、コメントにてご指摘頂けると嬉しいです。

## 開発環境
* OS
  * Microsoft Windows 10
* エディタ
  * Visual Studio Code
* Go SDK
  * go1.20 windows/amd64

## SMTPサーバーについて
SMTPサーバーは何でもよいのですが、試験的に使うものなのでGmailを利用しました。GmailのSMTPサーバー機能を使用する場合、以前は普段のログインと同じパスワードが使用できたのですが、現在はアプリパスワードを取得する必要があります。

アプリパスワードの取得方法は本筋から逸れる内容ですので割愛します。筆者は以下のサイトを参考にアプリパスワードを取得しました。

参考:
ぼくらのハウツーノート
[2段階認証に未対応のアプリからのログイン用にアプリパスワードを生成する](https://www.howtonote.jp/google-account/2step-verify/index6.html)

## 認証フローのイメージ
以下の図のようなものを作ります。

![design.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884998/86dfcbc4-776d-06bc-7d82-412187b32790.png)

端的にいうとユーザー名とパスワードで一度目の認証を行い、メールで受け取ったワンタイムパスワードを使って二度目の認証を行うほか、認証済みユーザーがちゃんとリソースを受け取れるかどうかの動作確認もできるものを実装します。

## 認証画面
画面については本題から逸れるのでコードの説明はしませんが、イメージを掴むためにHTMLで作成しました。

ユーザー名（user）とパスワード（password）を入力します。

![2023-02-10-21-18-54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884998/ae4b70f7-3adc-e88f-fe20-1add06315e73.png)

Gmailにワンタイムパスワードが届きます。

![Gmail.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884998/3cb028f6-b118-a09e-3350-198f10e7bb4f.jpeg)

届いたワンタイムパスワードを入力します。

![2023-02-10-21-36-16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884998/1028400a-89ef-3874-ca3c-76bc6e5a33e4.png)

二段階認証に成功すると受け取った二段階認証済みトークンが正しいものか検証できるページに遷移します。

![2023-02-10-21-37-42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884998/5eda9384-4c58-4971-a2a0-369808768138.png)


「送信」ボタンを押すとトークンが正しいかどうかサーバーに問い合わせます。ブラウザの開発ツールで確認すると、ベアラートークンがヘッダに含まれており、ステータスコード200が返答されているのがわかります。

![2023-02-10-21-38-22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884998/d9a6f268-b3a2-c8d3-10a9-93f873510ef5.png)

## 認証処理
記事ではコアとなる部分のみ記載しますので、全体が見たい方はGitHubに上げたソースコードをご確認ください。

[https://github.com/HoppingGanon/test2fa](https://github.com/HoppingGanon/test2fa)

また、認証に必要な通信はgoのWEBアプリフレームワーク`Echo`を使用したREST APIもどきを使用します。

```go
func main() {
	e := echo.New()

	// 環境変数の設定
	loadEnv()

	// ミドルウェアからCORSの使用を設定する
	// これを設定しないと、別サイト等からのアクセスが拒否される
	e.Use(middleware.CORS())

	// 二段階認証テストのAPI
	e.POST("/api/temp-token", postTempToken)
	e.POST("/api/token", postToken)
	e.GET("/api/test", getTest)

	// テスト用のページ
	e.GET("/view/login.html", createLogin)
	e.GET("/view/onetime.html", createOnetime)
	e.GET("/view/test.html", createTest)

	fmt.Printf("\n'%s/view/login.html'にアクセスすることで、動作を確認できます\n", envAuthBaseUri)
	fmt.Printf("[注意] 一段階目の認証プロセスに成功すると、'%s'から'%s'へメールが送信されるため、アドレス等が間違ってないか十分に確認してから実行してください\n",
		envSmtpFromAddress,
		envSmtpToAddress,
	)
	e.Logger.Fatal(e.Start(":" + envAuthServerPort))
}
```

認証用のAPI2つと、認証済みトークンを検証するためのAPIを用意しました。
* `POST /api/temp-token`はフローでいうところのRequest①
* `POST/api/token`はフローでいうところのRequest②
* `GET /api/test`はフローでいうところのRequest③

```go
	// 二段階認証テストのAPI
	e.POST("/api/temp-token", postTempToken)
	e.POST("/api/token", postToken)
	e.GET("/api/test", getTest)
```

### 一段階目の認証トークンとワンタイムパスワードを生成
まずは`POST /api/temp-token`へのリクエストについての処理を実装しました。

```go
// 最初のログイン画面から送信された認証情報を処理する
// 認証に成功したら、一段階目の認証トークンを送付する
func postTempToken(c echo.Context) error {
	fmt.Println("\n-------------------------")

	// Bodyの読み取り
	b, err := ioutil.ReadAll(c.Request().Body)
	if err != nil {
		fmt.Println("認証情報の送信方式が間違っています")
		fmt.Println(err.Error())
		return c.String(403, "認証情報の送信方式が間違っています")
	}

	// 認証情報のパース
	var authData AuthData
	err = json.Unmarshal(b, &authData)
	if err != nil {
		fmt.Println("認証情報の送信方式が間違っています")
		fmt.Println(err.Error())
		return c.String(403, "認証情報の送信方式が間違っています")
	}

	fmt.Printf("ユーザーID'%s'およびパスワード'%s'を受け取りました\n", authData.UserId, authData.Password)

	// 受け取ったユーザーIDとハッシュ化したパスワードが一致しない場合はエラー
	if authData.UserId != userId || getSHA256(authData.Password) != passwordHash {
		fmt.Println("ユーザー名またはパスワードが違います")
		return c.String(403, "ユーザー名またはパスワードが違います")
	}

	// 一段階目の認証トークンをランダムな文字列(Token68形式と互換のあるBase64形式)で生成
	tempToken, err := getRandomBase64()
	if err != nil {
		fmt.Println("一段階目の認証トークンの生成に失敗しました")
		fmt.Println(err.Error())
		return c.String(400, "一段階目の認証トークンの生成に失敗しました")
	}

	fmt.Printf("一段階目の認証トークン'%s'を生成しました\n", tempToken)

	// ワンタイムパスワードの数字列を生成
	n, err := rand.Int(rand.Reader, big.NewInt(1000000))
	if err != nil {
		fmt.Println("ワンタイムパスワードの生成に失敗しました")
		fmt.Println(err.Error())
		return c.String(400, "ワンタイムパスワードの生成に失敗しました")
	}
	onetime := fmt.Sprintf("%06d", n)

	fmt.Printf("ワンタイムパスワード'%s'を生成しました\n", onetime)

	// メールでワンタイムパスワードを送信
	err = SendMail(
		envSmtpHost,
		envSmtpPort,
		envSmtpUser,
		envSmtpPassword,
		envSmtpFromAddress,
		[]string{envSmtpToAddress},
		"二段階認証テスト ワンタイムパスワード通知",
		fmt.Sprintf("二段階認証テストのワンタイムパスワードを通知します。\n\n\tワンタイムパスワード\n\t%s", onetime),
	)
	if err != nil {
		fmt.Println("ワンタイムパスワードのメール送信ができませんでした")
		fmt.Println(err.Error())
		return c.String(400, "ワンタイムパスワードのメール送信ができませんでした")
	}

	fmt.Println("メールを送信しました")

	// 一段階目の認証トークンとワンタイムパスワードを保管
	savedTempToken = tempToken
	savedOnetime = onetime

	return c.String(201, tempToken)
}
```

一段階目の認証トークンはランダムな値のSHA256ハッシュを`Base64`形式の文字列にして生成します。トークンを作成する際に、一般的には`token68`という形式で生成することが多いようですが、この形式は`Base64`や`Base64 URL Encode`と互換性があるようです。さまざまな環境でトークンを生成できるように配慮されていて素晴らしいですね。

```go
	// 一段階目の認証トークンをランダムな文字列(Token68形式と互換のあるBase64形式)で生成
	tempToken, err := getRandomBase64()
```

ワンタイムパスワードはランダムな6桁の数字を生成します。

サーバー側に保持している一段階目の認証トークンとワンタイムパスワードはグローバル変数`savedTempToken`と`savedOnetime`に保持しています。あくまでテスト用なので、実装する際はデータベース等で期限を決めて管理してください。

```go
	// ワンタイムパスワードの数字列を生成
	n, err := rand.Int(rand.Reader, big.NewInt(1000000))
	if err != nil {
		fmt.Println("ワンタイムパスワードの生成に失敗しました")
		fmt.Println(err.Error())
		return c.String(400, "ワンタイムパスワードの生成に失敗しました")
	}
	onetime := fmt.Sprintf("%06d", n)
```

SendMail関数でワンタイムパスワードを記載したメールを送信しています。

```go
	// メールでワンタイムパスワードを送信
	err = SendMail(
		envSmtpHost,
		envSmtpPort,
		envSmtpUser,
		envSmtpPassword,
		envSmtpFromAddress,
		[]string{envSmtpToAddress},
		"二段階認証テスト ワンタイムパスワード通知",
		fmt.Sprintf("二段階認証テストのワンタイムパスワードを通知します。\n\n\tワンタイムパスワード\n\t%s", onetime),
	)
	if err != nil {
		fmt.Println("ワンタイムパスワードのメール送信ができませんでした")
		fmt.Println(err.Error())
		return c.String(400, "ワンタイムパスワードのメール送信ができませんでした")
	}
```

SendMail関数は以下のサイトを参考に実装しました。

参考:
[Go で簡単なメール送信](https://zenn.dev/spiegel/articles/20220929-sendmail-with-golang)

```go
func SendMail(
	// SMTPサーバーのホスト名
	hostname string,
	// SMTPサーバーのポート番号
	port string,
	// ユーザー名(送信元Gmailアドレス)
	username string,
	// API キー
	password string,
	// 送信元アドレス
	from string,
	// 宛先アドレス
	to []string,
	// 件名
	subject string,
	// 本文
	body string,
) error {
	auth := smtp.PlainAuth("", username, password, hostname)
	msg := []byte(strings.ReplaceAll(fmt.Sprintf("To: %s\nSubject: %s\n\n%s", strings.Join(to, ","), subject, body), "\n", "\r\n"))
	return smtp.SendMail(fmt.Sprintf("%s:%s", hostname, port), auth, from, to, msg)
}
```

Gmailに合わせて、SMTPの認証を平文認証にしています。

Gmailの587番ポートはTLSでの認証とのことなので、認証情報は平文でないと受け付けてくれないようです（調べても詳細が分からなかったので、もし詳しい方がいたらコメントでご教授いただけると嬉しいです）。

### 二段階認証の処理
次に`POST /api/token`へのリクエストについての処理を実装しました。

```go
func postToken(c echo.Context) error {
	fmt.Println("\n-------------------------")

	// Bodyの読み取り
	b, err := ioutil.ReadAll(c.Request().Body)
	if err != nil {
		fmt.Println("認証情報の送信方式が間違っています")
		fmt.Println(err.Error())
		return c.String(403, "認証情報の送信方式が間違っています")
	}

	// 二段階認証情報のパース
	var tfd TowFactorData
	err = json.Unmarshal(b, &tfd)
	if err != nil {
		fmt.Println("認証情報の送信方式が間違っています")
		fmt.Println(err.Error())
		return c.String(403, "認証情報の送信方式が間違っています")
	}

	fmt.Printf("一段階目の認証済みトークン'%s'およびワンタイムパスワード'%s'を受け取りました\n", tfd.TempToken, tfd.OneTime)

	// トークンとワンタイムパスワードが一致しなければエラー
	if savedTempToken != tfd.TempToken || savedOnetime != tfd.OneTime {
		fmt.Println("ワンタイムパスワードが一致しません")
		return c.String(403, "ワンタイムパスワードが一致しません")
	}

	// 二段階認証済みトークンをランダムな文字列(Token68形式と互換のあるBase64形式)で生成
	token, err := getRandomBase64()
	if err != nil {
		fmt.Println("トークンの生成に失敗しました")
		fmt.Println(err.Error())
		return c.String(400, "トークンの生成に失敗しました")
	}

	// 二段階認証済みトークンを保管
	savedToken = token

	fmt.Printf("二段階認証済みトークン'%s'を生成しました\n", savedToken)

	// 以前の認証データを破棄
	savedOnetime = ""
	savedTempToken = ""

	return c.String(201, token)
}
```

このテスト環境ではトークンとワンタイムパスワードはグローバル変数で1つだけ保管しているので、突合しています。

```go
	// トークンとワンタイムパスワードが一致しなければエラー
	if savedTempToken != tfd.TempToken || savedOnetime != tfd.OneTime {
		fmt.Println("ワンタイムパスワードが一致しません")
		return c.String(403, "ワンタイムパスワードが一致しません")
	}
```

一段階目の認証トークンと同様に、token64形式で二段階認証済みトークンを生成します。

```go
	// 二段階認証済みトークンをランダムな文字列(Token68形式と互換のあるBase64形式)で生成
	token, err := getRandomBase64()
	if err != nil {
		fmt.Println("トークンの生成に失敗しました")
		fmt.Println(err.Error())
		return c.String(400, "トークンの生成に失敗しました")
	}
```

二段階認証済みトークンを保管して、一段階目の認証トークンとワンタイムパスワードを無効化します。

```go
	// 二段階認証済みトークンを保管
	savedToken = token

	fmt.Printf("二段階認証済みトークン'%s'を生成しました\n", savedToken)

	// 以前の認証データを破棄
	savedOnetime = ""
	savedTempToken = ""
```

### 二段階認証済みトークンの検証
最後に`GET /api/test`へのリクエストについての処理を実装しました。

```go
func getTest(c echo.Context) error {
	fmt.Println("\n-------------------------")

	token, f := getBearer(c)

	fmt.Printf("二段階認証済みトークン'%s'を受け取りました\n", token)

	if !f {
		fmt.Println("認証情報の送信方式が間違っています")
		return c.String(403, "認証情報の送信方式が間違っています")
	} else if token != savedToken {
		fmt.Println("ベアラートークンが一致しません")
		return c.String(403, "ベアラートークンが一致しません")
	}

	fmt.Println("トークンは正しく認証されたものです")
	return c.String(200, "トークンは正しく認証されたものです")
}
```

二段階認証済みのトークンは`Authorization`ヘッダにベアラートークンとして含まれているので、`getBearer()`関数によって取り出しています。取り出し方がやや強引ですが以下のように実装しました。

```go
// ヘッダからBearerトークンを抜き出す関数
func getBearer(c echo.Context) (string, bool) {
	auth := c.Request().Header.Get("Authorization")
	typeStr := substring(auth, 0, 7)

	if typeStr != "Bearer " {
		return "", false
	}
	return substring(auth, 7, len(auth)-7), true
}
```

このベアラートークンが正しいものと検証できれば、ステータスコード200を返します。

## 実際に試してみる
### docker
`git clone`で試していただいてもいいのですが、より簡単に扱えるようDockerHubにイメージをアップロードしました。よければこちらで試してみてください。

コマンド例は以下の通りです。

```
docker run -it \
  --env AUTH_SERVER_PORT=8080 \
  --env AUTH_BASE_URI=http://localhost:8080 \
  --env SMTP_HOST=smtp.gmail.com \
  --env SMTP_PORT=587 \
  --env SMTP_USER="<from>@gmail.com" \
  --env SMTP_PASSWORD="<application password>" \
  --env SMTP_FROM_ADDRESS="<from>@gmail.com" \
  --env SMTP_TO_ADDRESS="<to>@gmail.com" \
  -p 8080:8080 \
  hoppingganon/test2fa
```

環境変数は以下のように定義してあります。

|環境変数名|意味|既定値|
|:-|:-|:-|
|AUTH_SERVER_PORT|テスト用のサーバーが受け付けるポート番号|8080|
|AUTH_BASE_URI|ベースとなるURL（ポートを変更する場合や外部からのアクセスを前提とする場合、プロキシでURIを書き換える場合は変更してください）|http://localhost:8080|
|SMTP_HOST|SMTPサーバのホスト名|smtp.gmail.com|
|SMTP_PORT|SMTPの宛先ポート|587|
|SMTP_USER|SMTPの認証に使用するユーザ名（Gmailの場合はメールアドレス）|\<from\>@gmail.com|
|SMTP_PASSWORD|SMTPの認証に使用するパスワード（Gmailの場合はアプリパスワード）|\<application password\>|
|SMTP_FROM_ADDRESS|送信元のメールアドレス（Gmailの場合はSMTP_USERと同じものを指定）|\<from\>@gmail.com|
|SMTP_TO_ADDRESS|宛先のメールアドレス（実際に送信されるので、ご自身でお持ちの他アドレスを指定することを推奨します）|\<to\>@gmail.com|

上記のソースコードやDockerイメージを利用した場合に発生した損害等の保証は致しかねますので、ご自身で責任をもって試してみてください。

## 余談
### 実用的な実装について
実際に使用するのであれば、まずはデータベースでトークンを管理すべきです。また、トークンやワンタイムパスワードには期限を設けるのが一般的です。その他一般的なことですが、SQLインジェクション対策やブルートフォースアタック対策やプロキシを使ったTLS通信も必要です。

上記は一例であり、二段階認証自体が高めのセキュリティ要件だと思いますので、十分にセキュリティを配慮すべきだと考えています。

### SMTPサーバーについて
ちょっとした自作ツールに二段階認証を入れたい場合であればGmailもアリですが、本来はSMTPサーバーを用意して実装しましょう。

ちなみにGoogleのSMTPサーバーは前述のとおりセキュリティ要件が2022年に変更されています。今後も変更される可能性がありますので、先を見据えるのであればSMTPサーバーを用意するか他の多要素認証を検討することを推奨します。

### JWTの利用
トークンはサーバー側に保存せず、JWTでステートレスな実装をすることも可能でしょうか。
ワンタイムパスワードにソルトを加えたものをハッシュ化して、一段階目の認証JWTを発行すれば設計できそうです。

原理上不可能ではないように思いますが、多要素認証でログインするようなシステムにJWTを用いるのはなんだかちぐはぐな気がします。高めのセキュリティ要件があるならば、強制ログアウト（セッションの無効化）の機能も備えるべきだと思います。