【Go】AwsCognitoを利用した認証機能を作ってみた(後編)

# はじめに
SESエンジニアをしております。masahiroと申します。
本記事では、APIサーバーからAWSの認証サービスCognitoへの接続方法について記述しております。
この記事での実装機能を構築するには、AWSコンソール内でのCognitoユーザープールの作成が必要です。
作成されていない方は下記の記事を参照してください。
[AWS Cognitoでのユーザープールの設定方法](https://qiita.com/MasahiroYoshiichi/items/9d41f21a9956d12d07ce)

前編では、サインアップとサインアップ時の検証機能を構築しているので、そちらから参照してください。
[【Go】AwsCognitoを利用した認証機能を作ってみた(前編)](https://qiita.com/MasahiroYoshiichi/items/7d696ece0bda7998ec9f)

# 実装
まずは、サインイン時の実装をしていきます。

# サインイン機能
今回は、サインアップ時に登録したメールアドレスとパスワードを使用して認証する実装になります。

## SignInハンドラの作成

:::note
/handlers/signIn.goを作成してください。
:::

```go
package handlers

import (
	"encoding/json"
	"github.com/MasahiroYoshiichi/auth/cognito/models"
	"github.com/MasahiroYoshiichi/auth/cognito/services"
	"github.com/MasahiroYoshiichi/auth/config"
	"log"
	"net/http"
	"time"
)

func SignInHandler(w http.ResponseWriter, r *http.Request) {

	// AWS設定ファイル読み込み
	cfg, err := config.LoadConfig()
	if err != nil {
		log.Println("設定ファイルの読み込みに失敗しました。:", err)
		http.Error(w, "サーバー内部エラー", http.StatusInternalServerError)
		return
	}

	// リクエスト処理
	var signinInfo models.SignInInfo
	err = json.NewDecoder(r.Body).Decode(&signinInfo)
	if err != nil {
		log.Printf("リクエストボディの読み込みに失敗しました: %v\n", err)
		http.Error(w, "不正なリクエスト", http.StatusBadRequest)
		return
	}

	// AWS認証情報を元にServiceを作成
	signInService := services.NewSignInService(cfg)

	// AWSCognitoサインイン
	initiateAuthOutput, err := signInService.SignIn(signinInfo)
	if err != nil {
		log.Printf("認証に失敗しました。: %v\n", err)
		http.Error(w, "サーバー内部エラー："+err.Error(), http.StatusInternalServerError)
		return
	}

	// email Cookie　格納
	http.SetCookie(w, &http.Cookie{
		Name:     "email",
		Value:    signinInfo.Email,
		HttpOnly: true,
		Secure:   false,
		SameSite: http.SameSiteStrictMode,
		Expires:  time.Now().Add(30 * time.Minute),
	})
	log.Printf("Cookieを設定(email): %s\n", signinInfo.Email)

	// Session Cookie　格納
	http.SetCookie(w, &http.Cookie{
		Name:     "session",
		Value:    *initiateAuthOutput.Session,
		HttpOnly: true,
		Secure:   false,
		SameSite: http.SameSiteStrictMode,
		Expires:  time.Now().Add(30 * time.Minute),
	})
	log.Printf("Cookieを設定(session): %s\n", *initiateAuthOutput.Session)

	// httpステータス返却
	w.WriteHeader(http.StatusOK)
}

```
上記のコードでは、リクエストのメールアドレスとパスワードをサービスのメソッドに渡すといった、基本的な実装をしています。
コードの終盤では、HTTPOnlyCookieを利用して、値を返却しています。

:::note
Cookieに格納されている、emailとsessionはMFA認証時に必要な情報になります。
:::

## SignInServiceの作成

:::note
/services/signInService.goを作成してください。
:::

```go
package services

import (
	"github.com/MasahiroYoshiichi/auth/cognito/models"
	"github.com/MasahiroYoshiichi/auth/config"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/cognitoidentityprovider"
)

// SignInService サービスの構造体を作成
type SignInService struct {
	cognitoClient *cognitoidentityprovider.CognitoIdentityProvider
	clientId      string
}

// NewSignInService サインインのサービスを作成(引数：AWS設定情報、戻り値：サービスの構造体)
func NewSignInService(cfg *config.Config) *SignInService {

	// AWSリージョンとのセッションを確保
	sess := session.Must(session.NewSession(&aws.Config{Region: aws.String(cfg.AwsRegion)}))

	// Cognitoのクライアントを作成
	cognitoClient := cognitoidentityprovider.New(sess)

	// サービス構造体を作成
	return &SignInService{
		cognitoClient: cognitoClient,
		clientId:      cfg.ClientId,
	}
}

// SignIn Cognitoへのサインアップを実行(引数；認証情報、戻り値：セッション情報)
func (s *SignInService) SignIn(signinInfo models.SignInInfo) (*cognitoidentityprovider.InitiateAuthOutput, error) {

	// サービスで作成したCognitoクライアントへ認証情報を格納
	input := &cognitoidentityprovider.InitiateAuthInput{

		// AWS設定情報のクライアントID
		ClientId: aws.String(s.clientId),

		//　認証情報
		AuthFlow: aws.String(cognitoidentityprovider.AuthFlowTypeUserPasswordAuth),
		AuthParameters: map[string]*string{
			"USERNAME": aws.String(signinInfo.Email),
			"PASSWORD": aws.String(signinInfo.Password),
		},
	}

	// Cognitoクライアントへの認証を実行
	initiateAuthOutput, err := s.cognitoClient.InitiateAuth(input)
	if err != nil {
		return nil, err
	}

	if initiateAuthOutput.ChallengeName != nil && *initiateAuthOutput.ChallengeName == cognitoidentityprovider.ChallengeNameTypeSmsMfa {
		return initiateAuthOutput, nil
	}

	// MFA認証時に必要なセッション情報を格納
	return initiateAuthOutput, nil
}
```
上記コードでは、cognitoクライアントを設定している構造体を利用したメソッドで、
メールアドレスとパスワードを使用して認証を実施しています。


# MFA認証機能

サインイン時にCognitoではAWS SNS機能を利用して、ショートメッセージを送るように設計されていますが、
この機能を利用するには、別途SNS機能を設定しなければいけません。

## AWS SNSの設定
まず、AWSマネージメントコンソールにログインします。
左上の検索欄からSNSを検索してください。

![スクリーンショット 2023-05-19 18.11.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424828/b38a08bc-d1aa-2fd3-6635-272ab9c42556.png)

次にメニューからテキストメッセージを選択してください。
![スクリーンショット 2023-05-19 18.13.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424828/268afed2-75a6-2aca-a662-11b72210cce4.png)


cognitoからSNSへの連携機能を使用するためには、SMSサンドボックスを終了させる必要があります。

:::note
「SMSサンドボックス終了」ボタンを押下
:::

![スクリーンショット 2023-05-19 18.16.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424828/25f56f5a-dd3b-c88a-919c-064cdfcc9a42.png)

# サービス制限の緩和申請
下記の内容を適宜変更して入力

:::note warn
入力欄は全て埋めていないと申請が通らないので注意してください。
:::

![スクリーンショット 2023-05-19 18.20.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424828/b516e264-db88-6823-9a44-3766395579d8.png)

リクエスト内容を入力してください。

:::note
今回は２要素認証として申請しています。
:::

![スクリーンショット 2023-05-19 18.23.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424828/0aff4881-c3c0-8603-82d6-d0eddf27f51f.png)

最後にケースの説明をします。
例文として下記のように申請をします。

![スクリーンショット 2023-05-19 18.25.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3424828/c651499e-285b-a238-f41b-76e8fb39c072.png)

# MFA認証機能

AWS SNSの設定が完了したので、MFA認証機能の実装をしていきます。

## MFAハンドラを作成

:::note
/handlers/mfaSignIn.goを作成
:::

```go
package handlers

import (
	"encoding/json"
	"github.com/MasahiroYoshiichi/auth/cognito/models"
	"github.com/MasahiroYoshiichi/auth/cognito/services"
	"github.com/MasahiroYoshiichi/auth/config"
	"log"
	"net/http"
)

func MFAHandler(w http.ResponseWriter, r *http.Request) {

	// AWS設定ファイル読み込み
	cfg, err := config.LoadConfig()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	//email Cookie　取得
	var mfaEmail string
	emailCookie, err := r.Cookie("email")
	if err != nil {
		http.Error(w, "EmailのCookieが取得できませんでした。", http.StatusBadRequest)
	}
	mfaEmail = emailCookie.Value
	log.Printf("email Cookie: %s\n", mfaEmail)

	//session Cookie 取得
	var mfaSession string
	sessionCookie, err := r.Cookie("session")
	if err != nil {
		http.Error(w, "SessionのCookieが取得できませんでした。", http.StatusBadRequest)
	}
	mfaSession = sessionCookie.Value
	log.Printf("session Cookie: %s\n", mfaSession)

	//MFACode 取得
	var mfaCode models.MFACode
	err = json.NewDecoder(r.Body).Decode(&mfaCode)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// AWS認証情報を元にServiceを作成
	MFASignInService := services.NewMFASignInService(cfg)

	// AWSCognitoMFA認証を実施
	authenticationResult, err := MFASignInService.CompleteMFA(mfaSession, mfaEmail, mfaCode)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	//　アクセストークンと認証状態管理トークンを返す
	response := struct {
		Token          string `json:"accessToken"`
		Authentication string `json:"authentication"`
	}{
		Token:          *authenticationResult.IdToken,
		Authentication: "true",
	}

	json.NewEncoder(w).Encode(response)
}
```
emailとsessionをCookieから取得、リクエストのMFA認証コードと一緒にMFA認証サービスに渡します。
処理が完了すると、cognito側からアクセストークンが帰ってくるので、これを元に認証後にユーザーを判定して処理を実行するようにします。
また、ブラウザ側での認証状態を管理するために、認証状態管理するトークンを返却しブラウザでの状態管理で使用します。

## MFAサービスを作成

:::note
/services/mfaSignInServiceを作成
:::

```go
package services

import (
	"github.com/MasahiroYoshiichi/auth/cognito/models"
	"github.com/MasahiroYoshiichi/auth/config"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/cognitoidentityprovider"
)

// MFASignInService サービスの構造体を作成
type MFASignInService struct {
	cognitoClient *cognitoidentityprovider.CognitoIdentityProvider
	clientId      string
}

// NewMFASignInService MFA認証のサービスを作成(引数：AWS設定情報、戻り値：サービスの構造体)
func NewMFASignInService(cfg *config.Config) *SignInService {

	// AWSリージョンとのセッションを確保
	sess := session.Must(session.NewSession(&aws.Config{Region: aws.String(cfg.AwsRegion)}))

	// Cognitoのクライアントを作成
	cognitoClient := cognitoidentityprovider.New(sess)

	// サービス構造体を作成
	return &SignInService{
		cognitoClient: cognitoClient,
		clientId:      cfg.ClientId,
	}
}

// CompleteMFA SignIn CognitoへのMFA認証を実行(引数；認証情報、戻り値：アクセストークン)
func (s *SignInService) CompleteMFA(mfaSession string, mfaEmail string, mfaCode models.MFACode) (*cognitoidentityprovider.AuthenticationResultType, error) {

	// サービスで作成したCognitoクライアントへ認証情報を格納
	input := &cognitoidentityprovider.RespondToAuthChallengeInput{

		// AWS設定情報のクライアントID
		ClientId: aws.String(s.clientId),

		// 認証情報
		ChallengeName: aws.String(cognitoidentityprovider.ChallengeNameTypeSmsMfa),
		Session:       aws.String(mfaSession),
		ChallengeResponses: map[string]*string{
			"USERNAME":     aws.String(mfaEmail),
			"SMS_MFA_CODE": aws.String(mfaCode.MFACode),
		},
	}

	// CognitoクライアントへのMFA認証を実行
	res, err := s.cognitoClient.RespondToAuthChallenge(input)
	if err != nil {
		return nil, err
	}

	// アクセストークンなどの認証情報を返す
	return res.AuthenticationResult, nil
}
```

クッキーとリクエストから取得した認証情報を利用して、cognitoへのMFA認証を実施します。
正常に実施できた場合には、アクセストークンをクラインアントへ返すようになっています。

:::note
返却されるアクセストークンは、jwt形式でミドルウェアを介してcognitoとの認証に使用されます。
:::


# おわりに
以上がcognitoを使用した、サインイン機能とMFA認証機能の構築手順となります。