はじめに
AWS Copilot で Phoenix Framework の本番環境を構築するシリーズです
実装したサンプルはこちらに格納しています
※今回の認証付コードは with-auth
ブランチに入れています
Auth0
本番環境として運用するからには、認証は必要でしょう
AWS だけで完結するなら Cognito を使うところですが、今回は Auth0 を使おうと思います
Auth0 は認証基盤としての機能が豊富で拡張性も高く、認可にも使えます
無料で使うこともでき、ドキュメントも親切です
今回のフロントエンドで使っている React にも Auth0 用のライブラリが用意されています
というわけで、 Auth0 で認証機能を付けてみましょう
Auth0 のアプリケーション作成
Auth0 の認証に必要なアプリケーションを作成していきます
※Auth0 のアカウント作成方法などは割愛します
Auth0 のダッシュボードから、左メニューの Applications を開き、
右上の Create Application をクリックします
モーダルが開くので、適当な名前を入力します
今回は React でフロントエンドを実装しているので、 Single Page Web Applications を選択します
Create をクリックしてください
以下のような画面が表示されればOKです
React のアイコンをクリックすれば React の場合の導入方法が表示されます
※今回は React に Auth0 を導入するのが主目的ではないので割愛します
※ポイントになるとこだけ解説します
大事なのは Settings タブに表示される Domain 、 Client ID 、 Client Secret です
この値を各所で利用します
また、以下の項目を AWS Copilot のデプロイ後に表示される ELB のエンドポイント URL
http://xxx.ap-northeast-1.elb.amazonaws.com
に設定してください
※後で CloudFront の URL に変更することになりますが、、、
- Allowed Callback URLs
- Allowed Logout URLs
環境変数の引き渡し
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
にアクセスすると、
以下のようなエラーがブラウザのコンソールに表示され、ログインボタンも表示されません
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 のディストリビューション一覧から ディストリビューションを作成
ボタンをクリックします
オリジンドメインには react-Publi-...
のような、 AWS Copilot でデプロイした時に表示された URL と同じ
ELB のエンドポイントが選択肢に表示されるはずなので、それを選択します
それ以外は何も変更せず、最下部の ディストリビューションを作成
ボタンをクリックします
最終変更日が デプロイ
になっているので、ここに日時が表示されるまで待ちます
Auth0 の以下の項目の値を https://<ディストリビューションドメイン名>/
にします
- Allowed Callback URLs
- Allowed Logout URLs
この状態で https://<ディストリビューションドメイン名>/
にアクセスすると、ログインボタンが無事表示されます
設定がうまくいっていれば Auth0 のログイン画面が表示されます
※このログイン画面のデザインや文言は変更可能です
Sign Up してユーザーを作成してから改めて Log In しましょう
認証できてチャット画面が表示されました
と、思いきや、ブラウザのコンソールにはエラーが表示され、
websocket の接続がエラーになっているようです
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 の左メニュー
ポリシー
をクリック -
オリジンリクエスト
タブをクリック -
オリジンリクエストポリシーを作成
をクリック
適当な名前を付けて、以下のような設定にします
-
ヘッダー
次のヘッダーを含める
- Sec-WebSocket-Key
- Sec-WebSocket-Version
- Sec-WebSocket-Protocol
- Sec-WebSocket-Accept
-
クエリ文字列
すべて
-
cookie
なし
続いて、作成したポリシーをビヘイビアに紐付けます
ELB に紐付いた CloudFront ディストリビューションのビヘイビアタブを開き、
ビヘイビアを作成
をクリックします
パスパターンを /socket/*
( websocket の接続先) にします
オリジンとオリジングループを ELB のエンドポイントにします
オリジンリクエストポリシーを先程作成したポリシーにします
この状態でビヘイビアを作成し、ディストリビューションがデプロイされるのを待ちます
ビヘイビアとポリシーの設定により、
CloudFront -> ELB -> Phoenix に必要なヘッダーとクエリ文字列が渡るようになり、
無事チャットが機能しました
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 とかで疲れているのであれば、
検討してみてください