Deviseの認証をCognitoに委譲し、カスタムログイン画面でログイン機能を実装する

##  はじめに 
今回はdeviseの認証をcognitoに委譲し、アプリケーション側で独自のログイン画面を作成して、ユーザーがログインできるように実装していきたいと思います。
## 対象読者
- deviseを使用したことがある人
- deviseの認証をcognitoに委譲したい人
- deviseの認証をcognitoに委譲したが、cognitoの[ホストされたUI](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-pools-hosted-ui-user-experience.html)では物足りない人
## 動作環境
今回はdeviseログイン画面ありのアプリケーションのひな形を作ってくれている方がいたので、そちらのプロジェクトを使用したいと思います。

https://github.com/timothyjamesmarias/rails_7_devise_example

## 実践
流れとしては下記に沿って進めていきます。
Cognitoを既に構築されている方は、[3. 実装](#3-実装)へ飛んでいただければと思います。

1. Cognitoの構築
1. サンプルユーザーの作成
1. 実装
1. 動作確認

## 1. Cognitoの構築
### サインインエクスペリエンスを設定
今回はメールアドレスを使用するため、「Eメール」のみを有効にします。
![cognito1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/90aff2ce-723e-8cdc-613c-724fa7365fec.png)
### セキュリティ要件を設定
MFAについては設定する必要がないので「MFAなし」としました。
それ以外は何も変更せずデフォルトのまま設定してあります。
![cognito2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/c7a749e5-9d9c-a2af-eda9-1937032667a2.png)
![cognito2-1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/f219b03b-e033-b184-bc83-43f827a1905f.png)
### サインアップエクスペリエンスを設定
ここも特別な設定などはせず、すべてデフォルトのまま次に行きます。
![cognito3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/7c320f27-502e-f1ed-bea2-ccd71ae3b8a3.png)
![cognito3-1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/8b893790-c99f-37de-709d-4b1381ed5465.png)
![cognito3-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/20a2e192-44ae-10ce-dc32-dd48363008d5.png)
### メッセージ配信を設定
今回はEメール送信の機能を使用しないので、どちらでも構わないのですが「CognitoでEメールを送信」を選択しておきます。
![cognito4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/fcd73e78-a44c-3194-d1a5-7262c2c2f177.png)
### アプリケーションを統合
ここの設定が肝になります。
「秘密クライアント」・「クライアントのシークレットを生成する」を選択します。
また、認証フローでは「ALLOW_ADMIN_USER_PASSWORD_AUTH」を選択します。
![cognito5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/418e4023-a8f1-94e3-882b-281f0e0be007.png)
![cognito5-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/3af56684-4f0b-dac8-4f92-29d2f67e0e60.png)
![cognito5-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/fefe8f08-38e5-73f7-2826-90bd66197445.png)
以上でCognitoの構築完了です。

## 2. サンプルユーザーの作成
下記コマンドでcognitoにユーザーを作成しておきます。

**cognitoユーザーの作成**
```
aws cognito-idp admin-create-user \
--user-pool-id ${user_pool_id} \
--username ${email} \
--user-attributes Name=email,Value=${email} Name=email_verified,Value=true
```

**ユーザーパスワードの再設定**

ユーザー作成後には、**パスワードを強制的に変更**をしなければいけないため、下記コマンドでパスワードを変更しておきます。
```
aws cognito-idp admin-set-user-password \
--user-pool-id ${user_pool_id} \
--username ${email} \
--password ${password} \
--permanent
```

**DBへのユーザー作成**

次に、上記で設定したメールアドレスでDBにユーザーを作成します。

今回はDBに保存されているパスワードは使用しないため、バリデーションに通るような任意のパスワードを入力するようにしてください。
```diff_ruby
User.create!(email: "test@example.com", password: "password")
```
以上でユーザーの作成完了です。
## 3. 実装

### Gemのインストール
```diff_ruby:Gemfile
gem 'dotenv-rails'
gem 'aws-sdk-cognitoidentityprovider'
gem 'faraday'
gem 'jwt'
```

### 環境変数の設定

.envファイルに下記を追記します。
```diff_ruby:.env
AWS_REGION=""
AWS_ACCESS_KEY=""
AWS_SECRET_ACCESS_KEY=""
COGNITO_CLIENT_ID=""
COGNITO_SECRET=""
COGNITO_USERPOOL_ID=""
COGNITO_JWK_ENDPOINT="https://cognito-idp.${region}.amazonaws.com/${user-pool-id}/.well-known/jwks.json"
COGNITO_TOKEN_ISSUER="https://cognito-idp.${region}.amazonaws.com/${user-pool-id}"
```
<br>

`COGNITO_USERPOOL_ID`は、ユーザープール内の「ユーザープールの概要」に記載されています。
`COGNITO_JWK_ENDPOINT`、`COGNITO_TOKEN_ISSUER`は、リージョンとユーザープールIDの穴埋めをします。

`COGNITO_CLIENT_ID`・`COGNITO_SECRET`は、少しわかりづらいですがアプリケーションクライアント内の画像の場所に記載されています。
![cognito_env-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/df131094-2bf2-6a7b-f05b-2a9d6d9f548d.png)


### Deviseまわりの設定

今回はサンプルアプリを使用しているため、Deviseのセットアップは省きます。

### ルーティングの修正

Deviseの初期設定では、`/users/sign_in`などのパスは、`devise/sessions#new`などのDeviseから提供されるControllerにマッピングされています。

そこで、カスタムされたController(今回は`Users::SessionsController`)を使用できるように、`routes.rb`を修正します。

```config/routes.rb
devise_for :users, controllers: {
      sessions: 'users/sessions'
    }
```

### カスタムストラテジの実装

それでは本題のカスタムストラテジの実装に入っていきます。
はじめに、認証をするときに呼び出すための `authenticate!` メソッドを定義します。
`authenticate!` メソッドの大まかな流れは下記になります。
1. cognito sdkを使用して、認証をする
1. 取得したIDトークンをデコード・検証する
1. IDトークンに含まれているメールアドレスをもつユーザーがDBに存在するか確認する
```diff_ruby
def authenticate!
  # 1. cognito sdkを使用して、認証をする
  resp = cognito_sign_in(params['user']['email'], params['user']['password'])
  return fail!(:invalid) unless resp

  # 2. 取得したIDトークンをデコード・検証する
  decoded_token_sub, decoded_token_kid  = decode_id_token(resp.authentication_result.id_token)

  if decoded_token_sub['iss'] != ENV.fetch('COGNITO_TOKEN_ISSUER')
    raise JWT::VerificationError, 'Invalid issuer'
  end

  if decoded_token_sub['aud'] != ENV.fetch('COGNITO_CLIENT_ID')
    raise JWT::VerificationError, 'Invalid audience'
  end

  # 3. IDトークンに含まれているメールアドレスをもつユーザーがDBに存在するか確認する
  user = User.find_by(email: decoded_token_sub['email'])
  user ? success!(user) : fail!(:invalid)
end
```
それでは、詳細な流れを追っていきます。
<br>

**1.  cognito sdkを使用して、認証をする**

`cognito_sign_in` メソッドの内部では、cognito sdkを呼び出します。
今回は `admin_initiate_auth` を使用し、管理者として認証をしますが、ほかにもさまざまな方法が用意されています。
<br>
また、今回は下記に該当するため `secret_hash` を設定する必要があります。
> ユーザープールのアプリケーションクライアントがユーザープールのクライアントシークレットで設定されている場合、API のクエリ引数に SecretHash 値が必要です。

https://repost.aws/ja/knowledge-center/cognito-unable-to-verify-secret-hash

`secret_hash` メソッドは、AWSから[ハッシュ値の計算方法](https://docs.aws.amazon.com/cognito/latest/developerguide/signing-up-users-in-your-app.html#cognito-user-pools-computing-secret-hash)が提供されているため、それに従って実装したものになります。
```diff_ruby
def cognito_sign_in(username, password)
  cognito_client = Aws::CognitoIdentityProvider::Client.new(
    region: ENV.fetch('AWS_REGION'),
    access_key_id: ENV.fetch('AWS_ACCESS_KEY'),
    secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY')
  )
  cognito_client.admin_initiate_auth(
    user_pool_id: ENV.fetch('COGNITO_USERPOOL_ID'),
    client_id: ENV.fetch('COGNITO_CLIENT_ID'),
    auth_flow: 'ADMIN_USER_PASSWORD_AUTH',
    auth_parameters: {
      USERNAME: username,
      PASSWORD: password,
      SECRET_HASH: secret_hash(username)
    }
  )
rescue
  false
end

def secret_hash(username)
  data = username + ENV.fetch('COGNITO_CLIENT_ID')
  Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', ENV.fetch('COGNITO_SECRET'), data))
end
```
<br>

**2. 取得したIDトークンをデコード・検証する**

今回作成したユーザープール用のJWKを取得して、
「1. cognito sdkを使用して、認証をする」で渡されたIDトークンをデコードします。

今回はJWK取得に`faraday`、デコードに`jwt`のGemを使用しています。

```diff_ruby
def decode_id_token(jwt)
  jwks = JSON.parse(Faraday.get(ENV.fetch('COGNITO_JWK_ENDPOINT')).body)
  JWT.decode(jwt, nil, true, {jwks: jwks, algorithms: 'RS256' })
end
```

IDトークンデコード後は、IDトークンの要素で検証が行えます。
今回は、2つの要素で検証を行っています。
- トークン発行者を識別する`issuer`
- 発行要求をしてきた相手を識別する`audience` 

```diff_ruby
decoded_token_sub, decoded_token_kid  = decode_id_token(resp.authentication_result.id_token)

if decoded_token_sub['iss'] != ENV.fetch('COGNITO_TOKEN_ISSUER')
  raise JWT::VerificationError, 'Invalid issuer'
end

if decoded_token_sub['aud'] != ENV.fetch('COGNITO_CLIENT_ID')
  raise JWT::VerificationError, 'Invalid audience'
end

```

<br>

**3. IDトークンに含まれているメールアドレスをもつユーザーがDBに存在するか確認する**

IDトークンからメールアドレスを取り出し、ユーザーを検索します。
ユーザーが存在したら、`success!`にUserオブジェクトを渡し、認証を成功させます。
```diff_ruby
user = User.find_by(email: decoded_token_sub['email'])
user ? success!(user) : fail!(:invalid)
```

<br>

下記が最終的に出来上がったファイルになります。

```lib/devise/strategies/user_cognito_authenticatable.rb 
require 'devise/strategies/authenticatable'

module Devise
  module Strategies
    class UserCognitoAuthenticatable < Authenticatable
      def authenticate!
        resp = cognito_sign_in(params['user']['email'], params['user']['password'])
        return fail!(:invalid) unless resp

        decoded_token_sub, decoded_token_kid  = decode_id_token(resp.authentication_result.id_token)

        if decoded_token_sub['iss'] != ENV.fetch('COGNITO_TOKEN_ISSUER')
          raise JWT::VerificationError, 'Invalid issuer'
        end

        if decoded_token_sub['aud'] != ENV.fetch('COGNITO_CLIENT_ID')
          raise JWT::VerificationError, 'Invalid audience'
        end

        user = User.find_by(email: decoded_token_sub['email'])
        user ? success!(user) : fail!(:invalid)
      end

      private

      def cognito_sign_in(username, password)
        cognito_client = Aws::CognitoIdentityProvider::Client.new(
          region: ENV.fetch('AWS_REGION'),
          access_key_id: ENV.fetch('AWS_ACCESS_KEY'),
          secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY')
        )
        cognito_client.admin_initiate_auth(
          user_pool_id: ENV.fetch('COGNITO_USERPOOL_ID'),
          client_id: ENV.fetch('COGNITO_CLIENT_ID'),
          auth_flow: 'ADMIN_USER_PASSWORD_AUTH',
          auth_parameters: {
            USERNAME: username,
            PASSWORD: password,
            SECRET_HASH: secret_hash(username)
          }
        )
      rescue
        false
      end

      def secret_hash(username)
        data = username + ENV.fetch('COGNITO_CLIENT_ID')
        Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', ENV.fetch('COGNITO_SECRET'), data))
      end

      def decode_id_token(jwt)
        jwks = JSON.parse(Faraday.get(ENV.fetch('COGNITO_JWK_ENDPOINT')).body)
        JWT.decode(jwt, nil, true, {jwks: jwks, algorithms: 'RS256' })
      end
    end
  end
end

Warden::Strategies.add(:user_cognito_authenticatable, Devise::Strategies::UserCognitoAuthenticatable)
```


```lib/devise/models/user_cognito_authenticatable.rb
require Rails.root.join('lib/devise/strategies/user_cognito_authenticatable')
module Devise
  module Models
    module UserCognitoAuthenticatable
    end
  end
end
```
### カスタムストラテジの追加
ここで先ほど定義したカスタムストラテジを使用できるように追加します。
```diff_ruby:app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable, 
+        :user_cognito_authenticatable
end
```

### コントローラーの修正

`app/controllers/users/sessions_controller.rb` のコメントアウトを外して、上で定義したカスタムストラテジの`authenticate!`メソッドを呼び出します。

```app/controllers/users/sessions_controller.rb
def create
    request.env['warden'].authenticate!(:user_cognito_authenticatable, scope: :user)
    redirect_to home_index_path
end
```
以上で実装は完了です。

## 4. 動作確認
![success_signin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838613/6b9ac8a2-6afc-3cff-502c-8bbefb143170.png)

作成したユーザーでログインすると、画面がリダイレクトされます。
左下に小さく「You are already signed in.」と表示されているので、成功しているのがわかります。


## 参考

https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/CognitoIdentityProvider/Client.html

https://tech.andpad.co.jp/entry/2020/06/27/194319

https://tech.stmn.co.jp/entry/tech/6689