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

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


# 実装
まずはAPIエンドポイントの実装から見ていきます。

# APIエンドポイントの実装
:::note info
gorilla/muxの導入
:::
ルーティング機能として、以下のWebサーバーフレームワークをインストールします。
```
go install github.com/gorilla/mux
```
インストール後は以下のように設定を行います。
```golang
package main

import (
	"fmt"
	"github.com/MasahiroYoshiichi/auth/cognito/cognitotoken"
	"github.com/MasahiroYoshiichi/auth/cognito/handlers"
	"github.com/gorilla/mux"
	"log"
	"net/http"
)

func main() {
	//　ルーター設定（gorilla使用）
	router := mux.NewRouter()

	// APIエンドポイント
	router.HandleFunc("/signup", handlers.SignUpHandler).Methods("POST")
	router.HandleFunc("/confirm-signup", handlers.ConfirmSignUpHandler).Methods("POST")
	router.HandleFunc("/signin", handlers.SignInHandler).Methods("POST")
	router.HandleFunc("/mfa", handlers.MFAHandler).Methods("POST")
	router.Handle("/signout", cognitotoken.Middleware(http.HandlerFunc(handlers.SignOutHandler))).Methods("POST")

	//　APIサーバー設定
	port := ":8080"
	fmt.Printf("API server started at %s\n", port)
	log.Fatal(http.ListenAndServe(port, router))
}
```
上記では、各Apiエンドポイントのパスに紐づく処理とApiサーバの立ち上げを実施しています。
また、サインアウトなどの一部の実装には、JWT認証トークンを利用したミドルウェアを設定しています。
こちらは別途下記で解説をしておりますので、興味があれば見ていただけたらと思います。

# AWS接続時の設定ファイルの作成
ルートディレクトリと同じ階層にConfigディレクトリを作成し、以下の２つのファイルを作成してください。

:::note info
・config.go 
・config.json
:::

## config.jsonの実装
こちらには、AWS Cognitoの設定情報を入力していきます。
```
{
  "aws_region": "ap-northeast-1",
  "user_pool_id": "ap-northeast-xxxxxxxxx",
  "client_id": "xxxxxxxxxxxxxxxxxxxxxxxxx",
}
```
この設定の確認方法としては、下記の記事を参考にしてください。

## config.goの実装

```go
package config

import (
	"encoding/json"
	"os"
)

// 設定情報を
type Config struct {
	AwsRegion  string `json:"aws_region"`
	UserPoolId string `json:"user_pool_id"`
	ClientId   string `json:"client_id"`
}

func LoadConfig() (*Config, error) {
	file, err := os.Open("/usr/local/bin/config.json")
	if err != nil {
		return nil, err
	}
	defer file.Close()

	decoder := json.NewDecoder(file)
	config := &Config{}
	err = decoder.Decode(config)
	if err != nil {
		return nil, err
	}

	return config, nil
}
```
上記の実装で、jsonに設定した内容を取得し、構造体のタグに基づいてデコードを実施しています。

# サインアップ機能を実装
上記でAWSとの接続情報に関しては、設定ができたので、
サインアップ機能の実装に取り組んでいきます。

## SignUpハンドラの作成

まず、ルートディレクトリにhandlersパッケージを作成を作成し、
signup.goファイルを作成します。

:::note
/handlers/signUp.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 SignUpHandler(w http.ResponseWriter, r *http.Request) {
	// AWS設定ファイル読み込み
	cfg, err := config.LoadConfig()
	if err != nil {
		log.Printf("設定ファイルの読み込みに失敗しました。: %v\n", err)
		http.Error(w, "サーバー内部エラー："+err.Error(), http.StatusInternalServerError)
		return
	}

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

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

	// AWSCognitoサインアップ
	_, err = signUpService.SignUp(signupInfo)
	if err != nil {
		log.Printf("登録に失敗しました。: %v\n", err)
		http.Error(w, "サーバー内部エラー："+err.Error(), http.StatusInternalServerError)
		return
	}

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

	// httpステータス返却
	w.WriteHeader(http.StatusOK)
}
```
このコードでは、AWSの設定情報を元にサービスを作成し、その中のSignUpメソッドでユーザー登録を実施しています。

コード後半で記載している、Cookieの設定ですが、こちらはサインアップ検証時に必要になります。
今回はHTTPOnlyCookieを利用して、セキュアにクライアントサイドにCookieを返却しています。
細かな処理に関してはコメントに記載しているため、ここでは省略させていただきます。

## SignUpサービスの作成
実際に登録処理をするサービスについて実装していきます。
こちらもルートディレクトリにservicesディレクトリを作成し、signUpService.goを作成してください。

:::note
/services/signUpServiceを作成してください。
:::
```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"
)

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

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

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

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

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

// SignUp Cognitoへのサインアップを実行(引数；認証情報、戻り値：Cognitoクライアントへの登録情報)
func (s *SignUpService) SignUp(signupInfo models.SignUpInfo) (*cognitoidentityprovider.SignUpOutput, error) {

	// サービスで作成したCognitoクライアントへ登録情報を格納
	signUpInput := &cognitoidentityprovider.SignUpInput{

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

		//　登録情報（必須）
		Username: aws.String(signupInfo.Username),
		Password: aws.String(signupInfo.Password),

		//登録情報(追加属性)
		UserAttributes: []*cognitoidentityprovider.AttributeType{
			{
				Name:  aws.String("email"),
				Value: aws.String(signupInfo.Email),
			},
			{
				Name:  aws.String("phone_number"),
				Value: aws.String(signupInfo.PhoneNumber),
			},
		},
	}
	//aws.Stringはawsのポインタ型に対して文字列をマッピングするためのもの

	// Cognitoクライアントへの登録を実行
	return s.cognitoClient.SignUp(signUpInput)
}
```

上記のコードでは、ハンドラから渡されたAWSの設定情報を元に、
Cognitoとのクライアントを作成し、サービス構造体に格納します。

その後に、構造体をレシーバとして受け取り、メソッドに渡された登録情報を元にCognitoへの登録を実施します。

:::note
今回の設定では、サインアップ時の検証、MFA認証のために必須情報以外も登録しています。
必要に応じて登録情報は変更してください。

:::


ここまでで、Cognitoを利用したユーザーの一時登録が完了しました。
次で、登録情報を検証し、認証情報として利用できるようにしていきます。

# サインアップ検証機能の実装
こちらでは、登録時に使用したメールアドレスに送信される検証情報を元に、検証機能を実装していきます。

## CoinfirmSignUpハンドラの作成
前回と同じくhandlersディレクトリにconfirmSignUp.goを作成します。

:::note
/handlers/confirumSignUpを作成してください。
:::
 
```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 ConfirmSignUpHandler(w http.ResponseWriter, r *http.Request) {

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

	// username Cookie　取得
	var confirmUser string
	usernameCookie, err := r.Cookie("username")
	if err != nil {
		log.Printf("UsernameのCookieが取得できませんでした。: %v\n", err)
		http.Error(w, "サーバー内部エラー: "+err.Error(), http.StatusBadRequest)
		return
	}

	// username Cookie　変数へ格納
	confirmUser = usernameCookie.Value
	log.Printf("UsernameのCookieを表示します。: %s\n", usernameCookie)

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

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

	// AWSCognitoサインアップ検証
	err = confirmSignUpService.ConfirmSignUp(confirmUser, confirmCode)
	if err != nil {
		log.Printf("検証に失敗しました: %s\n", err)
		http.Error(w, "サーバー内部エラー: "+err.Error(), http.StatusInternalServerError)
		return
	}

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

上記のコードでは、サインアップ時と同様に、AWS設定情報でサービスを作成し、
リクエストの検証コードとサインアップ時のCookieの中身からusernameを取得して、
confirumSignUpメソッドに渡しています。

## ConfirumSignUpサービスの作成
検証コード及び登録時のユーザーネームを元にサインアップの検証を実装します。

:::note
/services/confirumSignUpServiceを作成してください。
:::

```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"
)

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

// NewConfirmSignUpService  サインアップ検証のサービスを作成(引数：AWS設定情報、戻り値：サービスの構造体)
func NewConfirmSignUpService(cfg *config.Config) *ConfirmSignUpService {

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

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

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

// ConfirmSignUp Cognitoへのサインアップ検証を実行(引数；認証情報)
func (s *ConfirmSignUpService) ConfirmSignUp(confirmUser string, confirmCode models.ConfirmCode) error {

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

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

		// 検証情報
		Username:         aws.String(confirmUser),
		ConfirmationCode: aws.String(confirmCode.ConfirmationCode),
	}

	// Cognitoクライアントへの検証を実行
	_, err := s.cognitoClient.ConfirmSignUp(confirmSignInInput)
	return err
}
```

こちらの処理はサインアップ時の処理とほとんど変わらず、
設定したCognitoクライアントとの間で、メソッドに渡された検証情報と登録ユーザー名を元に検証を実施しています。


# おわりに
ここまででサインアップとサインアップ時の検証に関する実装は完了しました。
サインイン時とサインアウト時の実装については下記の記事を参照してください。
[【Go】AwsCognitoを利用した認証機能を作ってみた(後編)](https://qiita.com/MasahiroYoshiichi/items/502a83e1de5dd7d165f6)