golangとAWS Cognitoで認証機能を実装してみた

# 環境について
## Cognitoへのアクセス準備
Cognitoへ接続するためには
`aws_access_key_id`
`aws_secret_access_key`
がそれぞれ必要なので、[AWS SDK for Go](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#creating-the-credentials-file)を見て下記のファイルを作成します。
```text:credentials
[default]
aws_access_key_id = xxxxxxxxxxxxxxxxxxxx
aws_secret_access_key = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
それぞれのKeyについてはIAMでCognitoへのアクセス権限を持ったユーザーを作成すると発行される。

上記で用意したDockerfileにコンテナ内へコピーするコマンドを追加
```docker:Dokerfile
〜略〜
# AWS COGNITOの環境変数設定
RUN mkdir /root/.aws
COPY ./credentials /root/.aws/
```
あとはCognitoの設定等必要ですが、
[ここら辺](https://dev.classmethod.jp/articles/get-aws-temporary-security-credentials-with-cognito-id-pool-by-aws-cli/)を参考にしました。
他にもいろいろ記事があると思うのでいろいろ見てみてください。

# その他アクセスに必要なもの
`UserPoolId`：ユーザープールを作成時に発行
`ClientId`：ユーザープール作成時にアプリクライアントを作成すると発行
`Region`：リージョンを東京とかにしていると必要

:::note warn
アクセスキーやIDは漏洩すると大変なので扱いは注意しましょう！
:::

# コーディング
## 変数を定義
それぞれのメソッドで使用するのでグローバル変数に用意しました。
```golang:service.go
var clientId = "xxxxxxxxxxxxxxxxxxxxxxxxx"//用意したClientId
var userPoolId = "ap-northeast-1_xxxxxxxxx"//用意したユーザープールID
var sess = session.Must(session.NewSessionWithOptions(
	session.Options{
		SharedConfigState: session.SharedConfigEnable,
		Config: aws.Config{
			CredentialsChainVerboseErrors: aws.Bool(true),
			Region:                        aws.String("ap-northeast-1"),//ここでRegionを使用
		},
	}))
```
上記の`CredentialsChainVerboseErrors: aws.Bool(true)`の部分は
SDKを実行した時に、下記のようなエラーが出る場合があります。
```
NoCredentialProviders: no valid providers in chain. Deprecated. 
    For verbose messaging see aws.Config.CredentialsChainVerboseErrors
```
このままだと、エラー詳細まで確認できないのでConfigで設定しておくと、エラー詳細を確認できるようになります。
```
err: NoCredentialProviders: no valid providers in chain
caused by: EnvAccessKeyNotFound: failed to find credentials in the environment.
SharedCredsLoad: failed to load profile, .
EC2RoleRequestError: no EC2 instance role found
caused by: RequestError: send request failed
caused by: Get http://169.254.169.254/latest/meta-data/iam/security-credentials/: dial tcp 169.254.169.254:80: connect: invalid argument
```
アクセスキーが間違ってたり設定されていないと上記のようなエラーが発生します。

## 認証処理
Cognitoに追加されているユーザーの認証処理を下記のようにします。
（ユーザー追加はCognitoで直接追加しています。）
```golang:service.go
func (Service) CognitoAuth(ut model.UserToken) (model.UserToken, error) {
	//認証パラメータ生成
	params := &cognitoidentityprovider.AdminInitiateAuthInput{
		AuthFlow: aws.String(cognitoidentityprovider.AuthFlowTypeAdminNoSrpAuth),
		AuthParameters: map[string]*string{
			"USERNAME": aws.String(ut.Id),
			"PASSWORD": aws.String(ut.Password),
		},
		ClientId:   aws.String(clientId),
		UserPoolId: aws.String(userPoolId),
	}

	//Sessionの生成
	client := cognitoidentityprovider.New(sess)

	//認証実行
	res, err := client.AdminInitiateAuth(params)
	if err != nil {
		log.Print("AdminAuth Error")
		log.Fatal(err)
	}
	if res == nil {
		log.Fatal("failed to login")
	}
	if res.Session != nil {
		ut.Session = res.Session
		ut.AuthState = model.NewPassword
		return ut, nil
	}
	if res.AuthenticationResult == nil || res.AuthenticationResult.IdToken == nil {
		log.Fatal("failed to login")
	}

	ut.IdToken = res.AuthenticationResult.IdToken
	ut.AccessToken = res.AuthenticationResult.AccessToken
	ut.RefreshToken = res.AuthenticationResult.RefreshToken
	ut.AuthState = model.Authticated

	return ut, nil
}
```
初回認証時はパスワードの再設定を要求されるので`Token`は`null`でレスポンスされます。
その代わり、Sessionに一時トークンがレスポンスされるので、これを使って再設定を行います。

再設定は以下のように用意しました。
```golang:service.go
func (Service) SetNewPassword(ut model.UserToken) error {

	params := cognitoidentityprovider.AdminRespondToAuthChallengeInput{
		ChallengeName: aws.String("NEW_PASSWORD_REQUIRED"),
		ChallengeResponses: map[string]*string{
			"NEW_PASSWORD":               aws.String(*ut.NewPassword),
			"USERNAME":                   aws.String(ut.Id),
			"userAttributes.name":        aws.String(*ut.Name),
			"userAttributes.family_name": aws.String(*ut.FamilyName),
		},
		ClientId:   aws.String(clientId),
		Session:    ut.Session,
		UserPoolId: aws.String(userPoolId),
	}

	client := cognitoidentityprovider.New(sess)

	res, err := client.AdminRespondToAuthChallenge(&params)
	if err != nil {
		log.Print("No Changed Password")
		log.Fatal(err)
	}
	log.Print(res)
	return nil
}
```
ユーザープール作成時に必須属性を追加している場合は、初回パスワード設定の際に合わせて設定する必要があります。
`userAttributes.{属性名}`で追加する必要があります。

コントローラー、モデル等を実装したら、これで一通り実装は終わりです。

# まとめ
Sessionで渡される一時トークンは有効期限があるので、期限を過ぎるとエラーがレスポンスされます。
今回はまとめてエラーを処理していますが、実運用する場合はトークン切れ等の処理が必要と思います。

:::note info
間違いや、もっとこうした方がいい等ありましたらご指摘ください。
:::