LoginSignup
2

More than 1 year has passed since last update.

AWS Copilot で Phoenix Framework の本番環境を構築する 認証編(Auth0)

Last updated at Posted at 2022-06-08

はじめに

AWS Copilot で Phoenix Framework の本番環境を構築するシリーズです

実装したサンプルはこちらに格納しています
※今回の認証付コードは with-auth ブランチに入れています

Auth0

本番環境として運用するからには、認証は必要でしょう

AWS だけで完結するなら Cognito を使うところですが、今回は Auth0 を使おうと思います

Auth0 は認証基盤としての機能が豊富で拡張性も高く、認可にも使えます

無料で使うこともでき、ドキュメントも親切です

今回のフロントエンドで使っている React にも Auth0 用のライブラリが用意されています

というわけで、 Auth0 で認証機能を付けてみましょう

Auth0 のアプリケーション作成

Auth0 の認証に必要なアプリケーションを作成していきます
※Auth0 のアカウント作成方法などは割愛します

Auth0 のダッシュボードから、左メニューの Applications を開き、
右上の Create Application をクリックします

スクリーンショット 2022-06-06 9.30.52.png

モーダルが開くので、適当な名前を入力します

今回は React でフロントエンドを実装しているので、 Single Page Web Applications を選択します

Create をクリックしてください

スクリーンショット 2022-06-06 9.32.46.png

以下のような画面が表示されればOKです

React のアイコンをクリックすれば React の場合の導入方法が表示されます
※今回は React に Auth0 を導入するのが主目的ではないので割愛します
※ポイントになるとこだけ解説します

スクリーンショット 2022-06-06 9.34.18.png

大事なのは Settings タブに表示される Domain 、 Client ID 、 Client Secret です

この値を各所で利用します

スクリーンショット 2022-06-06 9.39.25.png

また、以下の項目を AWS Copilot のデプロイ後に表示される ELB のエンドポイント URL
http://xxx.ap-northeast-1.elb.amazonaws.com に設定してください
※後で CloudFront の URL に変更することになりますが、、、

  • Allowed Callback URLs
  • Allowed Logout URLs

スクリーンショット 2022-06-06 10.21.57.png

環境変数の引き渡し

Auth0 の React の Quick Start には以下のようなコードが書かれています

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { Auth0Provider } from "@auth0/auth0-react";

ReactDOM.render(
  <Auth0Provider
    domain="dev-elgpyi5g.auth0.com"
    clientId="rHKZ23HXNaj1OHD3WZH9QNeI5tmpfFhE"
    redirectUri={window.location.origin}
  >
    <App />
  </Auth0Provider>,
  document.getElementById("root")
);

基本的にコレでいいのですが、 domain や clientId が剥き出しなのはいただけません

秘密にする値ではないのでコードに書かれていること自体は問題ありませんが、
環境によって変動する値は環境変数で渡したいものです

以下のサイトを参考に、環境変数を読むようします

AWS Copilot のサービス設定で環境変数をローカルから ECS のコンテナに渡します

copilot/lb-svc/manifest.yml

...
variables:
  SECRET_KEY_BASE: ${SECRET_KEY_BASE}
  AUTH0_DOMAIN: ${AUTH0_DOMAIN}
  AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID}
...

Elixir の設定情報で環境変数を読み込みます

react_chat/config/config.exs

...

config :react_chat, ReactChatWeb.Token,
  domain: System.get_env("AUTH0_DOMAIN"),
  client_id: System.get_env("AUTH0_CLIENT_ID"),
  client_secret: System.get_env("AUTH0_CLIENT_SECRET")

...

ビルド時ではなく実行時の環境変数を読みたいので、 runtime.exs にも追加します

react_chat/config/runtime.exs

...

if config_env() == :prod do
  ...

  config :react_chat, ReactChatWeb.Token,
    domain: System.get_env("AUTH0_DOMAIN"),
    client_id: System.get_env("AUTH0_CLIENT_ID"),
    client_secret: System.get_env("AUTH0_CLIENT_SECRET")
end

Phoenix のテンプレートで設定情報を読み込み、 PUBLIC_CONFIGS に入れます

react_chat/lib/react_chat_web/templates/layout/root.html.heex

<!DOCTYPE html>
<html lang="en">
  <head>
    ...
    <script type="text/javascript">
      var PUBLIC_CONFIGS = {
        domain: "<%= Application.fetch_env!(:react_chat, ReactChatWeb.Token)[:domain] %>",
        client_id: "<%= Application.fetch_env!(:react_chat, ReactChatWeb.Token)[:client_id] %>",
      }
    </script>
  </head>

JS で PUBLIC_CONFIGS を読みます

react_chat/assets/js/app.js

...
  <Auth0Provider
    domain={PUBLIC_CONFIGS.domain}
    clientId={PUBLIC_CONFIGS.client_id}
    redirectUri={window.location.origin}
  >
...

認証されたときだけチャットを表示するようにします

また、チャットに参加するときの認証情報として Auth0 のトークンを利用したいため、
トークンを取得して Chat に渡します

...
function App() {
  const { isAuthenticated, loginWithRedirect, getIdTokenClaims, user, logout } = useAuth0()
  const [idToken, setIdToken] = useState("")

  if (isAuthenticated && idToken === "") {
    getIdTokenClaims().then((token) => {
      if (token) {
        setIdToken(token.__raw)
      }
    })
  }

  if (isAuthenticated && idToken !== "") {
    return (
      <div>
        <Chat userName={user.name} idToken={idToken}/>
        <button onClick={() => logout({ returnTo: window.location.origin })}>
          Log out
        </button>
      </div>
    );
  } else {
    return <button onClick={loginWithRedirect}>Log in</button>
  }
}
...

Chat 側では受け取ったトークンを websocket の接続時にパラメーターとして渡します

Elixir 側でのトークンの検証は後述します

...
  join() {
    this.socket = new Socket("/socket", {params:
      {token: this.state.idToken}
    });
    this.socket.connect();
...

CloudFront の追加

このまま ELB のエンドポイント http://xxx.ap-northeast-1.elb.amazonaws.com にアクセスすると、
以下のようなエラーがブラウザのコンソールに表示され、ログインボタンも表示されません

スクリーンショット 2022-06-06 11.06.40.png

Uncaught Error: 
      auth0-spa-js must run on a secure origin. See https://github.com/auth0/auth0-spa-js/blob/master/FAQ.md#why-do-i-get-auth0-spa-js-must-run-on-a-secure-origin for more information.
...

auth0-spa-js must run on a secure origin と言われています

つまり、 Auth0 を使うためには、プロトコルが https である必要があるわけです

AWS Copilot でデプロイした ELB のエンドポイント は https ではアクセスできません

したがって、このままでは Auth0 によるログイン画面を表示されることは不可能です

そこで、 ELB の前に CloudFront を立て、 https アクセスが可能なようにします

本番運用を想定した場合も CloudFront によるキャッシュはあった方が良いです

ただし、 CloudFront は AWS Copilot の機能では追加できなさそうなので、
ここだけ Web コンソールか AWS CLI や Terraform などで行うことになります

ディストリビューションの設定

CLoudFront を設定するとき、いくつかの項目に気をつけないと websocket 通信ができません

今回は Web コンソールから設定する例を紹介しますが、 Terraform などでも同じ設定にしてください

まず、 CloudFront のディストリビューション一覧から ディストリビューションを作成 ボタンをクリックします

スクリーンショット 2022-06-06 10.37.51.png

オリジンドメインには react-Publi-... のような、 AWS Copilot でデプロイした時に表示された URL と同じ
ELB のエンドポイントが選択肢に表示されるはずなので、それを選択します

スクリーンショット 2022-06-06 10.38.15.png

それ以外は何も変更せず、最下部の ディストリビューションを作成 ボタンをクリックします

最終変更日が デプロイ になっているので、ここに日時が表示されるまで待ちます

スクリーンショット 2022-06-06 10.50.53.png

Auth0 の以下の項目の値を https://<ディストリビューションドメイン名>/ にします

  • Allowed Callback URLs
  • Allowed Logout URLs

この状態で https://<ディストリビューションドメイン名>/ にアクセスすると、ログインボタンが無事表示されます

スクリーンショット 2022-06-06 11.16.41.png

設定がうまくいっていれば Auth0 のログイン画面が表示されます
※このログイン画面のデザインや文言は変更可能です

スクリーンショット 2022-06-06 11.17.24.png

Sign Up してユーザーを作成してから改めて Log In しましょう

認証できてチャット画面が表示されました

と、思いきや、ブラウザのコンソールにはエラーが表示され、
websocket の接続がエラーになっているようです

スクリーンショット 2022-06-06 11.20.07.png

copilot svc logs で Phoenix のログを見てみると、何だか分からないけどエラーが発生しています

copilot/lb-svc/c4c9313680 02:28:29.809 [error] #PID<0.2241.0> running ReactChatWeb.Endpoint (connection #PID<0.2231.0>, stream id 3) terminated
copilot/lb-svc/c4c9313680 Server: react-publi-rkepxdw2wsdp-243464349.ap-northeast-1.elb.amazonaws.com:80 (http)
copilot/lb-svc/c4c9313680 Request: GET /socket/websocket
copilot/lb-svc/c4c9313680 ** (exit) an exception was raised:
copilot/lb-svc/c4c9313680     ** (FunctionClauseError) no function clause matching in ReactChatWeb.UserSocket.connect/3
copilot/lb-svc/c4c9313680         (react_chat 0.1.0) lib/react_chat_web/channels/user_socket.ex:26: ReactChatWeb.UserSocket.connect(%{}, %Phoenix.Socket{assigns: %{}, channel: nil, channel_pid: nil, endpoint: ReactChatWeb.Endpoint, handler: ReactChatWeb.UserSocket, id: nil, join_ref: nil, joined: false, private: %{}, pubsub_server: ReactChat.PubSub, ref: nil, serializer: Phoenix.Socket.V1.JSONSerializer, topic: nil, transport: :websocket, transport_pid: nil}, %{})
copilot/lb-svc/c4c9313680         (phoenix 1.6.10) lib/phoenix/socket.ex:550: Phoenix.Socket.user_connect/6
copilot/lb-svc/c4c9313680         (phoenix 1.6.10) lib/phoenix/socket.ex:438: Phoenix.Socket.__connect__/3
copilot/lb-svc/c4c9313680         (phoenix 1.6.10) lib/phoenix/transports/websocket.ex:33: Phoenix.Transports.WebSocket.connect/4
copilot/lb-svc/c4c9313680         (phoenix 1.6.10) lib/phoenix/endpoint/cowboy2_handler.ex:22: Phoenix.Endpoint.Cowboy2Handler.init/4
copilot/lb-svc/c4c9313680         (cowboy 2.9.0) /app/deps/cowboy/src/cowboy_handler.erl:37: :cowboy_handler.execute/2
copilot/lb-svc/c4c9313680         (cowboy 2.9.0) /app/deps/cowboy/src/cowboy_stream_h.erl:306: :cowboy_stream_h.execute/3
copilot/lb-svc/c4c9313680         (cowboy 2.9.0) /app/deps/cowboy/src/cowboy_stream_h.erl:295: :cowboy_stream_h.request_process/3

オリジンリクエストポリシー作成

CloudFront で websocket を使うための条件を確認してみましょう

どうやら Sec-WebSocket-Key などのヘッダーが必要なようで、
それが CloudFront から ELB に渡っていないために通信できていないようです

ヘッダーの設定を紹介しているサイトもありました

また、 Auth0 の ID トークンを Phoenix に渡す際、トークンはクエリ文字列として渡されています

クエリ文字列もすべて CloudFront から ELB に渡す必要があります

というわけで、上記の条件を満たすオリジンリクエストポリシーを作成します

  • CloudFront の左メニュー ポリシー をクリック
  • オリジンリクエスト タブをクリック
  • オリジンリクエストポリシーを作成 をクリック

スクリーンショット 2022-06-06 11.38.49.png

適当な名前を付けて、以下のような設定にします

  • ヘッダー

    次のヘッダーを含める

    • Sec-WebSocket-Key
    • Sec-WebSocket-Version
    • Sec-WebSocket-Protocol
    • Sec-WebSocket-Accept
  • クエリ文字列

    すべて

  • cookie

    なし

スクリーンショット 2022-06-06 11.43.46.png

続いて、作成したポリシーをビヘイビアに紐付けます

ELB に紐付いた CloudFront ディストリビューションのビヘイビアタブを開き、
ビヘイビアを作成 をクリックします

スクリーンショット 2022-06-06 11.46.31.png

パスパターンを /socket/* ( websocket の接続先) にします

オリジンとオリジングループを ELB のエンドポイントにします

スクリーンショット 2022-06-06 11.49.14.png

オリジンリクエストポリシーを先程作成したポリシーにします

スクリーンショット 2022-06-06 11.49.25.png

この状態でビヘイビアを作成し、ディストリビューションがデプロイされるのを待ちます

スクリーンショット 2022-06-06 11.52.41.png

ビヘイビアとポリシーの設定により、
CloudFront -> ELB -> Phoenix に必要なヘッダーとクエリ文字列が渡るようになり、
無事チャットが機能しました

スクリーンショット 2022-06-06 11.55.05.png

Auth0 トークンの検証

フロントエンド側の認証は @auth0/auth0-react パッケージがよしなにしてくれますが、
Phoenix 側でも Auth0 のトークンを検証しないと、どんなトークンでもチャットに参加できてしまいます

というわけで、 Phoenix 側にもトークン検証を追加します

Auth0 のトークンは JWT として提供されます

なので、 JWT をパースするためのライブラリを Elixir 側に追加します

react_chat/mix.exs

...
  defp deps do
    [
      ...
      {:joken, "~> 2.4.1"},
      {:joken_jwks, "~> 1.6"},
      {:jose, "~> 1.11"},
      ...
    ]
  end
...

トークン検証用のモジュールを追加します

react_chat/lib/react_chat_web/token.ex

defmodule ReactChatWeb.Token do
  # no signer
  use Joken.Config, default_signer: nil

  add_hook(JokenJwks, strategy: ReactChatWeb.Auth0JwksStrategy)

  @impl true
  def token_config do
    domain = Application.get_env(:react_chat, ReactChatWeb.Token)[:domain]
    client_id = Application.get_env(:react_chat, ReactChatWeb.Token)[:client_id]

    default_claims()
    |> add_claim("aud", nil, &(&1 == client_id))
    |> add_claim("iss", nil, &(&1 == "https://#{domain}/"))
  end
end

defmodule ReactChatWeb.Auth0JwksStrategy do
  use JokenJwks.DefaultStrategyTemplate

  def init_opts(_) do
    domain = Application.get_env(:react_chat, ReactChatWeb.Token)[:domain]
    [jwks_url: "https://#{domain}/.well-known/jwks.json"]
  end
end

上記のコードを少し解説します

JWT はヘッダー、ペイロード、署名から構成されています

ヘッダーには暗号方式など、ペイロードにはユーザー情報などが含まれ、
それらが改竄されていないことを検証するため署名が付いています

トークンを発行するサービス(今回の例では Auth0)は秘密鍵を使って署名し、
トークンを受けるサービス(今回の例では Phoenix)は公開鍵を使って署名を検証します

Auth0 では、トークンに署名する際に使用する公開鍵の情報 JWKS (JSON Web Key Sets) を
https://<Auth0のアプリケーションドメイン>/.well-known/jwks.json
に公開しています

JWKS から公開鍵を取得し、署名を検証、トークンをパースするところまでを joken_jwks がやってくれます

パースされたトークンの aud に Auth0 アプリケーションのクライアント ID 、
iss に Auth0 アプリケーションのドメイン URL が入っているので、
トークン内の値が Auth0 アプリケーションの値と一致することを確かめます

websocket 接続要求時、トークンを検証して問題ない場合だけ接続します

react_chat/lib/react_chat_web/channels/user_socket.ex

defmodule ReactChatWeb.UserSocket do
...
  @impl true
  def connect(%{"token" => token}, socket, _connect_info) do
    case ReactChatWeb.Token.verify_and_validate(token) do
      {:ok, claim_map} ->
        Logger.info("claim_map = #{inspect(claim_map)}")
        {:ok, socket}

      _ ->
        Logger.info("verify error")
        {:error, "Invalid token"}
    end
  end
...
end

wscat を使ってトークン検証ができていることを確認しましょう

テキトーな値を入れた場合

$ wscat -c "wss://dmed6md8okgqs.cloudfront.net/socket/websocket?token=xxx&vsn=2.0.0"
error: Unexpected server response: 403

正当な ID トークンを入れた場合

$ export ID_TOKEN="<ブラウザの開発者ツールなどで取得した ID トークンの値>"
$ wscat -c "wss://dmed6md8okgqs.cloudfront.net/socket/websocket?token=${ID_TOKEN}&vsn=2.0.0"
Connected (press CTRL+C to quit)

正当なトークンの場合のみ接続することができました

まとめ

これで一通り、以下のことが検証できました

  • コスト・料金
  • リリース用ビルド
  • クラスタリング
  • データベース
  • 認証

websocket でリアルタイム通信する仕組みを構築するのであれば、
十分本番利用できるのではないでしょうか

AWS Copilot は非常に簡単でシンプルなので、
EC2 + Ansible とか、 Terraform + SAM とかで疲れているのであれば、
検討してみてください

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2