今回やること
フロント側で FirebaseAuthentication を導入し、React を build し、Django に読み込ませ、とりあえず Request にトークンを載せて送信してみる
はじめに
未経験から web 系エンジニアになるための独学履歴~React+DRF+Heroku で Todo アプリを作る 製作記録~ を作った時に認証(ログインや会員登録も含む)でだいぶしんどい思いをしました。
と、いうのも言うまでもなく React はフロント(クライアント)でのフレームワーク、Django(DRF)はバックエンド(サーバーサイド)のフレームワークであり、普通はドメインが分かれています。
なので認証のフローとしては例えばユーザー名とパスワードでのログインであるならば
- フロントで Form から受け取ったユーザー名とパスワードとともにバックエンドにリクエストを送る
- リクエストを検証して、それを DB の情報と照合し、問題なければユーザーの情報を返す
- フロント側で認証したよという処理を行う(ex. ログインフォームからユーザーマイページに遷移し、state を True にする)
という過程になります。
普通の認証なら Django の標準のフレームワークやその拡張のようなサードパーティの認証ライブラリ(ex. allauth)これで事が足ります(Django であるならば BaseAuthentication と SessionAuthentication)。
しかし、ここにソーシャルログイン等所謂トークンを必要とする認証方式をやろうと思うと話が変わってきます。
例えば、日本では 1 番使用率が高いであろう Twitter での認証を見てみます。
引用: developer.twitter.com より Authentication
- POST リクエストを Twitter に送り、request_token を受け取って認証フォームへリダイレクトする
- 自動的に認証が始まる or 認証に用いる Twitter アカウントを選択し情報を入力して認証開始
- 認証に成功すると oauth_token 及び oauth_verifier が返ってくる
- oauth_token が request_token に一致するか検証、一致すればクライアント側の任意のページへリダイレクト
- oauth / access_token エンドポイントへ oauth_verifier 及び oauth_token を含んだ POST リクエストを送る
- 問題なければ oauth_token, oauth_token_secret を含んだ Response が返ってくる
- 以後の認証は oauth_token, oauth_token_secret を使う
という工程になります。
フロント、あるいは Django や PHP などのサーバーサイドのフレームワークのみなら大抵はライブラリが解決してくれますし、なんなら手順等も各 SNS の Developer サイトに載っているので、ググったりすれば頑張れば自分で実装もできます。
しかし、React+DRF のようにフロントとサーバーとで分けているとこれがすんなりいきません。
私は当時 4 と 7 で詰まりました。
React を使うということは SPA でモノを作りたいということになりますが、SPA はリロード及びページ遷移すると基本的には情報はリセットされてしまいます。
例えば書籍検索アプリであるなら検索結果はリロードすると消えて、検索フォームが描画されますし、もちろん認証状態も消えます。
なので、普通は state の永続化等、必要に応じてリロードしても情報がリセットされないような処理をする必要があります。
閑話休題、こういう SPA の仕様もあって私は別ページでリクエスト → 認証 →Response→ クライアントへのリダイレクトという過程で Response をどうクライアントへパスするかというところがうまく噛み砕けませんでした。
というのも当時は Django のallauth
及びdj-rest-auth
を使ってこのフローを処理しようとしていたのですが、それにはdj-rest-auth
にaccess_token
(=oauth_token
)とoauth_token_secret
を渡してエンドポイントに POST リクエストしないといけないので、どうしてもフロント側で一回 Response に含まれるそれらを保持しないといけなかったのですが、その保持の仕方とさらにそれをどうやって DRF に渡すかがわからなかったのです。
この辺りは後ほど言及することにします。
閑話休題、この通りフロント+DRF での認証は余計なことをやろうとすると非常に面倒です。
何分一応上記の通りライブラリはあるものの、そもそもフロントフレーム+DRF で何かを作っている人たちが全然いなくて、情報も海外勢がまばらに発信しているのみなので余計に面倒に拍車がかかっています。
そのあたりをどう解決してこうかというアプローチを今回考えて行きたいと思います。
Django でフロントエンドフレームワークを使うことに対しての考え方
参考:Modern JavaScript for Django Developers
さて、基本的にフロントエンドフレームワークを使うには前述しましたが基本的には以下の通りとなります。
- フロントエンドフレームワーク+バックエンドフレームワーク(Laravel、Django、RoR 等)
- フロントエンドフレームワーク+BaaS or MaaS(ex. Firebase)
そして 1 の場合、多くの解説記事ややり方解説だとドメインを分ける……というより別に環境を構築するか、バックエンドを API 化してそれをフロントで叩いて使う……みたいな紹介のされ方をしていると思います。
こういうのをクライアントファーストなんて言い方をするみたいですがこれにはいくつか弊害があるみたいです。
However, client-first setups come with substantial tradeoffs.
The most important one is that by moving the entire UI to the JavaScript framework, Django's built-in support for templates, forms, and other front-end goodies are basically thrown out the window. In many cases these need to be rebuilt within the front-end framework.
As a result, simple tasks can become much more complicated in a client-first setup. For example, building a page to update a data model will typically involve creating a Serializer class, an API endpoint, and custom front-end code to render a form and handle validation when all you really wanted was a 5-line ModelForm.
訳
しかし、クライアントファーストのセットアップには大きなトレードオフがあります。
最も重要なことは、UI 全体を JavaScript フレームワークに移行することで、Django に組み込まれているテンプレートやフォーム、その他のフロントエンドの優れた機能が基本的に窓から投げ出されることです。多くの場合、これらはフロントエンドフレームワーク内で再構築する必要があります。
その結果、クライアントファーストのセットアップでは、単純なタスクがはるかに複雑になる可能性があります。例えば、データモデルを更新するためのページを構築するには、通常、シリアライザクラス、API エンドポイント、フォームをレンダリングしてバリデーションを処理するためのカスタムフロントエンドコードを作成しなければなりませんが、5 行の ModelForm が必要でした。
基本的にはバックエンドフレームワークは機能が充実している反面、余計なことをしようとすると途端にめんどくさくなるということが割とあるように思えます。
例えば Django なんかは MVT が常にセットでついてくるのですが、フロントエンドフレームワークを使う場合フロント部分を担当する T(Template)の部分はまるっきり必要なくなってしまうということはなんとなくわかると思います。
しかし、そうなると引用部分で指摘されているような弊害が生まれてしまいます。
例えば Django で Form を使うときには必ず
{% csrf_token %}
というタグを HTML 部分(Template)に入れます。
わかりやすい例で言えば CSRF 対策は Django ではひとまずこれだけでいいというのが利点になりますが、フロントエンドフレームワークを使う場合はこれができないので結構面倒だったりします。
また Django を API 化するとなると
- Model 作成
- views.py に定義
- views.py で使う Serializer を定義
- 必要に応じてカスタム permission やカスタム Authentication を定義して適用する
- urls.py でルーティング
- settings.py を必要なら加筆修正する
というのを API を作るたびにやらないといけません。
API は場合によってはリクエストの数だけ作らないといけなくて、さらには単純な CRUD 以外だと別に複雑な処理を書かないといけない場合もありかなり手間がかかることがわかっていただけると思います。
SPA を作るとなるとフロント側でやったルーティングをバックエンド側でも同じように作る必要があるのでより面倒になります。
このあたりを上手いこと折り合いつけられないかと考えて出てきたのがハイブリッドアーキテクチャという考え方です。
The key concept is that in a hybrid architecture, rather than choosing between client-first or server-first at the project level, we choose it at the page level.
For some pages—login, static content, simple UIs—we rely primarily on a server-first setup and let Django carry the load. For other pages where we need a complex front end we lean heavily on client-first principles. And for everything in between we rely on a sane toolchain that allows us to mix and match Django with a front-end codebase that is clear and sensible to navigate.
訳
キーコンセプトは、ハイブリッドアーキテクチャでは、プロジェクトレベルでクライアント優先かサーバー優先かを選択するのではなく、ページレベルで選択するということです。
ログイン、静的なコンテンツ、シンプルな UI など、いくつかのページでは、主にサーバーファーストのセットアップに頼り、Django に負荷を任せています。複雑なフロントエンドが必要なページでは、クライアントファーストの原則に大きく依存します。そして、その間のすべてのページで、私たちは Django と、明確で賢明なナビゲーションが可能なフロントエンドのコードベースを混ぜ合わせることを可能にする、まともなツールチェーンに頼っています。
簡潔に言うと Django でであるならばページレベルで Template を使うのか、フロントフレームワークを使うのかを選択するということになります。
そのために後述しますが、例えば React を使うのであれば React を Build して main.js という一つの Javascript にして、それを Template に読み込ませるようにする手法を取ります。
こうすることでドメインはサーバー側(Django)に統一できるので CORS その他諸々の設定は不要になるので先に書いたような認証問題も Django のそれを使えば解決するということになります。
しかし、それはそれとして今回はソーシャルログインのみ Firebase に任せてみるということはしたいのでそこだけはうまくやってみたいと思います。
トークン認証は難しい
参考: SPA のログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち?〜
参考: Cookie(Session)での認証と Token での認証の違いについて
参考: JWT 認証のメリットとセキュリティトレードオフの私感
認証方法は大きく Cookie(Session)での認証と Token での認証とで分かれると思います。
散々痛い目を見た認証ですが、どちらの認証を選んでもセキュリティ上のリスクはつきものであり、完全ではありません。
Cookie(Session)を使う場合は XSS やセッションハイジャック Cookie が盗まれる危険性がありますし、CSRF も考えられます。
ただこの辺りは Cookie を HttpOnly にするなどある程度対策もわかりやすいです。
反面 Token 認証はというとまず仕組みとして API から発行されるトークンを使って以後認証をするということになるので当然そのトークンを保持しておかないといけないのですが、それをどこに保持していくかが常に悩みのタネになります。
当初推奨されていた LocalStorage は XSS で簡単にアクセスされるのでとてもじゃないけどセキュアとは言えない、じゃあ Cookie にとなると結局 Cookie 認証と同じリスクがつきまとう上に、HttpOnly にした Cookie はフロントエンド(JS)からのアクセスができないのでいざ使おうとした時に難しい、では外部ストレージに……とキリがない上に罠が多かったりします。
これだけを見てトークン認証は Cookie(Session)認証に劣るというわけではありませんが気軽に扱うにはハードルが高すぎるというのが私の所感です。
このあたりは未熟な私が語るより以下の記事がよくまとまっているのでそちらをご覧になって頂ければと思います。
参考: JWT・Cookie それぞれの認証方式のメリデメ比較
参考: SPA のログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち?〜
Firebase で解決できないか?
上述の通り、ただでさえ Token 認証は面倒くさいのですがそれをさらに普通に React+Django(DRF)で使おうとすると CORS や SameSite の問題が出てくる上に、Django(DRF)がまだ React などのフロントサイドフレームワークと併用することに対して煮詰まっていないのか Token を使う仕組み自体はあり、ライブラリもありますがいまいち使いづらく、またベストプラクティスはないように感じました。
特にソーシャルログイン絡みを DRF でやろうとするとよりわかりづらく、例えばメジャーなライブラリであるallauth
とそれを拡張して DRF での認証に用いるためのものが揃ってるdj-rest-auth
の場合だとソーシャルログインの場合ドキュメントはこれぐらいしか書いてありません。
参考: dj-rest-auth より API endpoints
Basing on example from installation section Installation
/dj-rest-auth/facebook/ (POST)
access_token
code
Note
access_token OR code can be used as standalone arguments, see https://github.com/iMerica/dj-rest-auth/blob/master/dj_rest_auth/registration/views.py
/dj-rest-auth/twitter/ (POST)
access_token
token_secret
つまり、access_token と token_secret を使って POST リクエストしてくれたら oauth_token を返してあげるよということなのですが、じゃあその access_token はどうするのよということになるわけです。
ソーシャルログインを DRF でやりたいのに認証のやり方しか書いてないじゃないと。
普通の Django であるならば allauth を使えばそれで万事解決なのですが、結局それではサーバー側でしか認証は完了しないし、access_token 等は返ってきますが当時 Token は HttpOnly の Cookie に保存するやり方で試行錯誤していたのですが当然フロント側ではそれを拾う手段がないのでどうすりゃいいんだー!! って大分頭を悩ませました。
今この記事を書いてる段階では私も色々調べて冒頭の通り、ハイブリッドアーキテクチャ認証自体は Django 側でやる(テンプレートを使う)し、allauth にソーシャルログインも入っているのでそれを使えば問題はない(これらの場合結局 Token 認証ではなく Session 認証になる)し、FirebaseAuthentication など外部のサービスを使って access_token を返してもらってそれを使う、あるいは完全に自分で冒頭における TwitterAuth のフローを書くかという手段くらいしか取れることがないということがわかりますが、このあたり日本語のまとまった情報がないので海外の情報源を恐れず漁らないと難しいと思います。
じゃあ FirebaseAuthentication はどうなんだというと以下のような点からこちらもすんなりとこれがベストというわけにはいかないなと感じました。
-
一連の設定が使う環境や場合によって異なるので公式のドキュメントをよく確認しないといけない(大抵の紹介記事はモバイルアプリやフロントエンドで使うことが前提の説明なので Python で使う場合とは結構違うところとかあります)
-
FirebaseAuthentication を使うということはユーザーの情報は必ずそちらに入ることになるので、コンテンツの DB も Firebase のそれを使うか、Firebase からユーザーの情報を引き取って Django の UserModel に登録し直さないといけないのでユーザー DB が重複してしまうことになり、管理が煩雑になる。
-
上記に関連して Firebase からデータを取得するメソッドやそのデータ構造はややクセがあり扱いづらい(個人の感想です)
-
同じく上記に関連することで、コンテンツの DB も Firebase のそれにする場合 NoSQL かつ Firebase の仕様を理解していないと扱えない上にそもそもそれなら Django を利用する必要性がなくなってしまうこと
ただし、設定さえ行ってしまえば許容する認証方式などを選択して各 Developer で得られる SecretKey さえいれてしまえばそれだけで使えますし、データ自体も引っ張ってきてそれをサーバーに送り返すことくらいはなんとかなるのに加え、ポップアップでサクッと認証してくれるのでそういうところは使いやすいと思いました。
ちなみに FirebaseAuthentication での認証フローは以下の通りです。
-
FirebaseAuthentication 経由で Twitter 認証(ポップアップ)
-
問題なければ初回ログインの場合は Firebase 側にユーザー情報が保存され、Response には各種 Token を含め認証に使った Twitter アカウントの情報が含まれたものが返ってくる
-
Response から Token 等を取り出して、Django 側に渡す。自前で作ったカスタム Authentication 等、Django 側の用意によってこのあたりは変わる
-
Django 側で受け取った Token を使って Firebase に認証をかけて、ユーザーの情報を返してもらう。成功すれば 2 と同じようにユーザーの情報が返ってくる。
-
返ってきた情報を使って Django 側の UserModel にユーザーを登録する
という感じになります。
ではここまで長くなりましたがここから実際に準備をしていこうと思います。
フロント側での準備
WebpackとBabelの準備
参考: A Crash Course in Modern JavaScript Tooling
引用: A Crash Course in Modern JavaScript Tooling より Putting it all together の項から
やることは上の画像のようなことです。
まとめていくと
- React 等のフロントエンドフレームワークでフロント部分を書く
- それを BABEL でコンパイラ(機械語に翻訳)する
- フロントエンドフレームワークやライブラリは npm or yarn で一括管理する(PHP とかだと Composer とか)
- それらを Webpack でビルドして 1 つの JS ファイルとして書き出す
ということになります。
BABEL って何なんだろう? とずっと思っていたのですが参考サイトみて漸くコンパイラだったんだと気づいたのが私です。
ディレクトリは前回作ったときに手直していただいたものを参考に以下のような感じで構築してみました。
必要なところだけ抜粋しています。
Django で使う template のフォルダの場所が結構変わっているので注意してください。
├── manage.py
├── Pipfile
├── .env <----- privateにしたい各種設定を書き込むファイル
├── config
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── apps <----- DjangoのアプリフォルダとReactフォルダが入る
├───├── authentication <-----Firebase認証で使うカスタムAuthenticationとその例外のファイルが入る
│ │ ├── excepts
| | | ├── firebase_auth_exceptions.py
│ │ ├── funcs
| | | ├── firebase_authentication.py
| | |
│ │ ├── __init__.py
│ │ ├── models.py
│ │ ├── apps.py
│ │ └── views.py
│ ├── frontend <----- Reactに関するフォルダ
| | ├── node_modules
| | ├── src
| | ├── .babelrc <----- BAVELを定義するファイル
| | ├── package.json
| | ├── package-lock.json
| | ├── yarn-lock.json
| | ├── webpack.config.js <----- ビルドに関する定義をするファイル
| | ├── urls.py <----- Django側でReactのルーティングを反映させるために必要
| | ├── static <----- ビルドされたファイルが以下のように入る
| | | ├── frontend/main.js
| | ├── template <----- Djangoで使うtemplateフォルダ
| | ├── frontend/index.html
| |
│ ├── users <----- DjangoのUserModel
フロントから紹介してしまいしたが、とりあえず Django 側でプロジェクトを作り、startapp コマンドで frontend フォルダを作ってその中で create-app コマンド等で React のプロジェクトを作った方が無駄がないと思います。
あとはnpm init
コマンド等で npm を初期化して各種ライブラリを入れてください。
一例
"dependencies": {
"@mui-treasury/components": "^1.9.2",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"babel-loader": "^8.2.2",
"firebase": "^8.2.9",
"firebase-admin": "^9.2.0",
"firebase-functions": "^3.11.0",
"firebase-tools": "^9.3.0",
"material-ui-popup-state": "^1.7.1",
"moment": "^2.29.1",
"moment-locales-webpack-plugin": "^1.2.0",
"node-sass": "^5.0.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-google-font-loader": "^1.1.0",
"react-scripts": "4.0.2",
"web-vitals": "^1.1.0"
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@babel/preset-env": "^7.12.7",
"@babel/preset-react": "^7.12.7",
"@hookform/error-message": "^0.0.5",
"@material-ui/core": "^4.11.2",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.57",
"@material-ui/pickers": "^3.2.10",
"axios": "^0.21.0",
"eslint": "^7.6.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-react-hooks": "^4.2.0",
"firebase-functions-test": "^0.2.0",
"react-hook-form": "^6.12.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"webpack-cli": "^4.2.0"
}
まずは Webpack.config.js を作っていきます。
参考: webpack.config.js の書き方をしっかり理解しよう
webpack 4 入門
Webpack 公式ドキュメントより
const path = require("path");
const MomentLocalesPlugin = require("moment-locales-webpack-plugin");
module.exports = {
// ビルドの際にどのファイルを読み込むのかを指定している
entry: {
frontend: path.join(__dirname, "src", "index.js"),
},
// ビルド結果をどのファイル名でどこに出力するのかを指定
output: {
path: path.join(__dirname, "static", "frontend"),
filename: "main.js",
},
// ビルドプロセスをカスタマイズする
// 例えば今回はMomentプラグインに対して任意の地域の日時設定しかビルド煮含めないようにしている
plugins: [
// To strip all locales except “en”
new MomentLocalesPlugin(),
// Or: To strip all locales except “en”, “es-us” and “ru”
// (“en” is built into Moment and can’t be removed)
new MomentLocalesPlugin({
localesToKeep: ["es-us", "ja"],
}),
],
// JSやCSSなどをWebpackがどう扱うのかというのを定義する
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.(sa|sc|c)ss$/,
use: ["style-loader", "css-loader", "sass-loader"],
},
],
},
};
module
の部分についてはDocumentsだけだとイマイチ分かりづらいですが、test
プロパティで指定したmoduleをuse
プロパティで指定したloaderで使うようにするという定義になっているようです。
exclude
プロパティは指定したディレクトリ内のモジュールは除外するという意味になります。
これはbabel-loaderがnpmでimportされているES2015+で書かれたモジュールを変換できずにエラーを吐いてしまう場合があるからみたいです。
で、これを実際にBuildするときのコマンドが以下のうちdevまたはbuildプロパティになります。
package.json内に記入します。
"scripts": {
"start": "react-scripts start",
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "webpack --mode development",
"build": "webpack --mode production",
"eject": "react-scripts eject"
},
ちなみにwebpackで定義したことを以下のように一部こちら側で定義するように書くこともできるようです。
"dev": "webpack --mode development ./src/index.js --output-path ./static/frontend",
"build": "webpack --mode production ./src/index.js --output-path ./static/frontend",
.babelrcについてはとりあえず以下の通りにしておきます。
# デフォルトは以下の通り
{
"presets": [
"@babel/preset-env", "@babel/preset-react"
]
}
# herokuに上げるときはこのように書かないとうまくいかなかった
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": true
}
}
],
"@babel/preset-react"
]
}
ちなみにBuildに関してですが、ここから先の注意事項としては以下の2点が挙げられます。
スタイリングがBuild前後でズレる場合がある
通常Reactでlocalで実際の描画を見たいなんて時にはnpm start
等のコマンドを使い、localhost3000で確認すると思いますが、ハイブリッドアーキテクチャの場合はDjango側でpython manage.py runserver
コマンドを使って確認することになります。
ですが、ここでbuildしたファイルを読み込ませた場合Django側と干渉するのかlocalhost3000で確認したスタイリングとはズレていたりする場合があります。
なので、適宜調節するかDjango側で干渉している可能性があるCSSファイルや設定を確認する必要があることに注意してください。
React側のファイルに更新があった場合は適宜Buildし直す必要がある
これは結局main.jsをDjangoで読み込んでいるので当然といえば当然ですね、結構めんどくさいです。
フォーム等必要なものを適宜作っていく
前回まではとりあえずコンテンツの仮置を作っただけなので同じようにレイアウトとルーティングの確認のためそれらを仮置していきます。
ルーティングに関しては認証前後でアクセス権によって制御をかけるみたいなことをする必要がありますが、まだ認証が固まっていないのでとりあえずMaterial-UIのAppBarコンポーネントの例のようにSwitchで認証前後を変更できるようにします。
今回はFirebaseAuthenticationでのTwitter認証がフロントから実行でき、かつそのトークンをDjangoにパスして、カスタムAuthenticationが実行できるかを確認したいのでコンポーネントの詳細は詰めていません。
ではまずForm関係から作っていきます。
TestFirebaseAuthContainer
LoginFormのContainer部分です。
FirebaseAuthenticationへのRequestをするメソッドもここに記入します。
import React from "react";
import { useState } from "react";
import { Card, CardContent, Box } from '@material-ui/core';
import axios from "axios"
import firebase from '../Utils/Firebase'
import TestFirebaseAuthLayout from './TestFirebaseAuthLayout'
import TestFirebaseAuthForm from './TestFirebaseAuthForm'
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles((theme) => ({
contents: {
marginTop: 30
},
input: {
marginBottom: 20,
},
// TextFieldのラベルに関するスタイル
// InputLabelProps={{
// classes: {
// root: classes.labelRoot,
// }
// }} をタグ内に属性として配置する
// labelRoot: {
// fontSize: 12,
// },
}));
// 認証方法の確認のためにひとまずここでルーティングを行う
// 本使用のときは他のコンテンツとともにContents.jsで管理
const TestFirebaseAuthContainer = () => {
const classes= useStyles();
const onTwiiterLogin = () => {
// 必須
const provider = new firebase.auth.TwitterAuthProvider();
firebase
.auth()
.signInWithPopup(provider)
.then(res=> {
console.log(res);
firebase
.auth()
.currentUser
.getIdToken(/* forceRefresh */ true)
.then( (idToken) => {
console.log(idToken)
axios.defaults.headers.common['Authorization'] = idToken;
try {
const response = axios.get("http://127.0.0.1:8000/users/whoami/")
console.log(response)
} catch (error) {
console.log(error)
}
}).catch(function (error) {
console.log(error);
});
})
.catch(err => {
console.log(err);
})
}
return(
<React.Fragment>
<Box className={classes.contents}>
<TestFirebaseAuthLayout>
{/* <Card>
<CardContent> */}
<TestFirebaseAuthForm onSubmit={console.log} TwitterAuth={onTwiiterLogin} />
{/* </CardContent>
</Card> */}
</TestFirebaseAuthLayout>
</Box>
</React.Fragment>
)
}
export default TestFirebaseAuthContainer
const provider = new firebase.auth.TwitterAuthProvider();
は必須なので必ず書きましょう。
あとは順に見ていけば以外に書いてあることは簡単でした。
以下の通りです。
const provider = new firebase.auth.TwitterAuthProvider();
// firebaseの
firebase
// authメソッドで
.auth()
// const providerで指定したプロバイダでの認証をポップアップで実行する
.signInWithPopup(provider)
// 成功した場合
.then(res=> {
console.log(res);
// firebaseの
firebase
// authメソッドで
.auth()
// ログインしているユーザーの
.currentUser
// idTokenを取得するメソッドを実行する
.getIdToken(/* forceRefresh */ true)
// 成功した場合
.then( (idToken) => {
console.log(idToken)
// 取得したTokenをAuthorizationヘッダーに載せる
axios.defaults.headers.common['Authorization'] = idToken;
try {
// DRFへリクエスト
// この段階ではとりあえずUserModelへGetしてみる
const response = axios.get("http://127.0.0.1:8000/users/whoami/")
console.log(response)
} catch (error) {
console.log(error)
}
}).catch(function (error) {
console.log(error);
});
})
.catch(err => {
console.log(err);
})
TestFirebaseAuthForm
Formの部分を書いていきます。
例によってReactHookFormを使用、とりあえずFirebaseAuthenticationを確かめたいのでとりあえずFormの体をなしていれば良しとします。
import React from "react";
import { useForm, Controller } from "react-hook-form";
import { makeStyles } from "@material-ui/core/styles";
import {
TextField,
Button,
Grid,
Input,
Box,
Typography,
} from "@material-ui/core";
import { ErrorMessage } from "@hookform/error-message";
import TwitterIcon from "@material-ui/icons/Twitter";
const useStyles = makeStyles((theme) => ({
input: {
marginBottom: 20,
},
// TextFieldのラベルに関するスタイル
// InputLabelProps={{
// classes: {
// root: classes.labelRoot,
// }
// }} をタグ内に属性として配置する
// labelRoot: {
// fontSize: 12,
// },
}));
const TestFirebaseAuthForm = ({ onSubmit, TwitterAuth }) => {
const { control, handleSubmit, errors } = useForm();
console.log("AuthForm.render");
const classes = useStyles();
return (
<React.Fragment>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid item xs={12}>
<Box mt={1} textAlign="center">
<Controller
as={
<TextField
key="Email"
className={classes.input}
label="Email"
type="email"
variant="outlined"
inputProps={{
min: 0,
style: {
textAlign: "center",
height: 60,
},
}}
/>
}
name="email"
control={control}
rules={{
required: "メールアドレスは必須です",
maxLength: {
value: 30,
message: "30文字以内です",
},
}}
defaultValue=""
/>
<div ClassName="form-error-message">
<ErrorMessage errors={errors} name="multipleErrorInput">
{({ messages }) =>
messages &&
Object.entries(messages).map(([type, message]) => (
<p key={type}>{message}</p>
))
}
</ErrorMessage>
</div>
</Box>
</Grid>
<Grid item xs={12}>
<Box mt={1} textAlign="center">
<Controller
as={
<TextField
className={classes.input}
label="Password"
type="password"
variant="outlined"
inputProps={{
min: 0,
style: {
textAlign: "center",
height: 60,
},
}}
/>
}
name="password"
control={control}
rules={{
required: "パスワードは必須です",
maxLength: {
value: 30,
message: "30文字以内です",
},
}}
defaultValue=""
/>
<div ClassName="form-error-message">
<ErrorMessage errors={errors} name="multipleErrorInput">
{({ messages }) =>
messages &&
Object.entries(messages).map(([type, message]) => (
<p key={type}>{message}</p>
))
}
</ErrorMessage>
</div>
</Box>
</Grid>
<Grid item xs={12}>
<Box mt={1} textAlign="center">
<Button color="primary" variant="outlined" type="submit">
Login
</Button>
</Box>
</Grid>
<Grid item xs={12}>
<Box mt={1} textAlign="center">
<Typography>Social Login</Typography>
<Button
variant="contained"
color="primary"
startIcon={<TwitterIcon />}
onClick={TwitterAuth}
>
Twitter
</Button>
</Box>
</Grid>
</form>
</React.Fragment>
);
};
export default TestFirebaseAuthForm;
TestFirebaseAuthLayout
このコンポーネントの全体のレイアウトのコンポーネントを用意します。
import React from "react";
import { Container } from "@material-ui/core";
const TestFirebaseAuthLayout = (props) => {
return (
<Container>{props.children}</Container>
)
}
export default TestFirebaseAuthLayout;
ここから各コンテンツのコンポーネントです。
これらも同じく仮に置いておくものとします。
前回までの書籍検索のあれこれは省略しますが後ほどルーティングします。
TestFirebaseLogoutContainer
import React from "react";
import { Card, CardContent, Box } from '@material-ui/core';
import axios from "axios"
import firebase from '../Utils/Firebase'
import TestFirebaseAuthLayout from './TestFirebaseAuthLayout'
import TestFirebaseLogoutForm from './TestFirebaseLogoutForm'
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles((theme) => ({
contents: {
marginTop: 30
},
input: {
marginBottom: 20,
},
// TextFieldのラベルに関するスタイル
// InputLabelProps={{
// classes: {
// root: classes.labelRoot,
// }
// }} をタグ内に属性として配置する
// labelRoot: {
// fontSize: 12,
// },
}));
// 認証方法の確認のためにひとまずここでルーティングを行う
// 本使用のときは他のコンテンツとともにContents.jsで管理
const TestFirebaseLogoutContainer = () => {
const classes = useStyles();
const LogoutTest = () => {
firebase
.auth()
.signOut()
.then(() => {
console.log("Your, SignOut")
})
.catch((error) => {
console.log(error)
})
}
return(
<React.Fragment>
<Box className={classes.contents}>
<TestFirebaseAuthLayout>
{/* <Card>
<CardContent> */}
<TestFirebaseLogoutForm onSubmit={LogoutTest} />
{/* </CardContent>
</Card> */}
</TestFirebaseAuthLayout>
</Box>
</React.Fragment>
)
}
export default TestFirebaseLogoutContainer
TestFirebaseLogoutForm
import React from 'react';
import { useForm, Controller } from "react-hook-form";
import {
TextField,
Button,
Grid,
Box,
Typography,
} from "@material-ui/core";
const LogoutForm = (onSubmit) => {
return(
<React.Fragment>
<Grid item xs={12}>
<Box mt={1} textAlign="center">
<Typography variant="h3">Would you like to logout?</Typography>
<Button
color="secondary"
variant="outlined"
type="submit"
onClick={
onSubmit
}
>
Logout
</Button>
</Box>
</Grid>
</React.Fragment>
)
}
export default LogoutForm
TestFirebaseRegisterContainer
import React from "react";
import { useState } from "react";
import { Card, CardContent, Box } from '@material-ui/core';
import axios from "axios"
import firebase from '../Utils/Firebase'
import TestFirebaseAuthLayout from './TestFirebaseAuthLayout'
import TestFirebaseRegisterForm from './TestFirebaseRegisterForm'
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles((theme) => ({
contents: {
marginTop: 30
},
input: {
marginBottom: 20,
},
// TextFieldのラベルに関するスタイル
// InputLabelProps={{
// classes: {
// root: classes.labelRoot,
// }
// }} をタグ内に属性として配置する
// labelRoot: {
// fontSize: 12,
// },
}));
// 認証方法の確認のためにひとまずここでルーティングを行う
// 本使用のときは他のコンテンツとともにContents.jsで管理
const TestFirebaseAuthContainer = () => {
const classes= useStyles();
return(
<React.Fragment>
<Box className={classes.contents}>
<TestFirebaseAuthLayout>
{/* <Card>
<CardContent> */}
<TestFirebaseRegisterForm/>
{/* </CardContent>
</Card> */}
</TestFirebaseAuthLayout>
</Box>
</React.Fragment>
)
}
export default TestFirebaseAuthContainer
TestFirebaseRegisterForm
import React from "react";
import { useForm, Controller } from "react-hook-form";
import { makeStyles } from "@material-ui/core/styles";
import {
TextField,
Button,
Grid,
Input,
Box,
Typography,
} from "@material-ui/core";
import { ErrorMessage } from "@hookform/error-message";
import TwitterIcon from "@material-ui/icons/Twitter";
import firebase from '../Utils/Firebase'
const useStyles = makeStyles((theme) => ({
input: {
marginBottom: 20,
},
// TextFieldのラベルに関するスタイル
// InputLabelProps={{
// classes: {
// root: classes.labelRoot,
// }
// }} をタグ内に属性として配置する
// labelRoot: {
// fontSize: 12,
// },
}));
const TestFirebaseRegisterForm = ({ onSubmit }) => {
const { control, handleSubmit, errors } = useForm();
console.log("RegisterForm.render");
const classes = useStyles();
const RegisterTest = (data) => {
const email = data.email;
const password = data.password;
firebase
.auth()
.createUserWithEmailAndPassword(email, password)
.then(() => {
console.log("Success, UserRegister")
})
.catch((error) => {
console.log(error)
})
}
return (
<React.Fragment>
<form onSubmit={handleSubmit(onSubmit)}>
{/* <Grid item xs={12}>
<Controller
as={
<TextField
label="username"
placeholder="username"
inputProps={{ min: 0, style: { textAlign: "center" } }}
/>
}
name="username"
control= {control}
rules={{
required: "ユーザ名は必須です",
maxLength: {
value: 30,
message: "30文字以内です"
}
}}
defaultValue=""
/>
<div ClassName="form-error-message">
<ErrorMessage errors={errors} name="multipleErrorInput">
{ ({ messages }) =>
messages &&
Object.entries(messages).map(([type, message]) => (
<p key={type}>{message}</p>
))
}
</ErrorMessage>
</div>
</Grid> */}
<Grid item xs={12}>
<Box mt={1} textAlign="center">
<Controller
as={
<TextField
key="Email"
className={classes.input}
label="Email"
type="email"
variant="outlined"
inputProps={{
min: 0,
style: {
textAlign: "center",
height: 60,
},
}}
/>
}
name="email"
control={control}
rules={{
required: "メールアドレスは必須です",
maxLength: {
value: 30,
message: "30文字以内です",
},
}}
defaultValue=""
/>
<div ClassName="form-error-message">
<ErrorMessage errors={errors} name="multipleErrorInput">
{({ messages }) =>
messages &&
Object.entries(messages).map(([type, message]) => (
<p key={type}>{message}</p>
))
}
</ErrorMessage>
</div>
</Box>
</Grid>
<Grid item xs={12}>
<Box mt={1} textAlign="center">
<Controller
as={
<TextField
className={classes.input}
label="Password"
type="password"
variant="outlined"
inputProps={{
min: 0,
style: {
textAlign: "center",
height: 60,
},
}}
/>
}
name="password"
control={control}
rules={{
required: "パスワードは必須です",
maxLength: {
value: 30,
message: "30文字以内です",
},
}}
defaultValue=""
/>
<div ClassName="form-error-message">
<ErrorMessage errors={errors} name="multipleErrorInput">
{({ messages }) =>
messages &&
Object.entries(messages).map(([type, message]) => (
<p key={type}>{message}</p>
))
}
</ErrorMessage>
</div>
</Box>
</Grid>
<Grid item xs={12}>
<Box mt={1} textAlign="center">
<Button color="primary" variant="outlined" type="submit">
Register
</Button>
</Box>
</Grid>
</form>
</React.Fragment>
);
};
export default TestFirebaseRegisterForm;
続いてHeaderです。
AppBarコンポーネントを例からそのまま借りてきて、Headerコンテンツ等はログイン前後で分けたいのでそれぞれコンポーネントを作っていく形になります。
AppBar
ほぼ例示されているものそのまま持ってきています。
AppBarコンポーネント、Menuコンポーネント、SwipeableDrawerコンポーネントの組み合わせになります。
参考: App Bar
参考: Menus
参考: Drawer
SwipeableDrawerコンポーネントはDrawer内のMenuItemをスマートフォンで見た場合、スワイプでスクロールできるようなDrawerを生成してくれます。
import React from "react";
import { useState } from "react";
import { makeStyles } from "@material-ui/core/styles";
import {
AppBar,
Toolbar,
Typography,
IconButton,
Switch,
FormControlLabel,
FormGroup,
MenuItem,
Menu,
SwipeableDrawer,
} from "@material-ui/core";
import MenuIcon from "@material-ui/icons/Menu";
import AccountCircle from "@material-ui/icons/AccountCircle";
import DrawerList from "./DrawerList";
import AuthAccountCircle from "./AuthAccountCircle";
import NoAuthAccountCircle from "./AuthAccountCircle";
const useStyles = makeStyles((theme) => ({
root: {
flexGrow: 1,
},
menuButton: {
marginRight: theme.spacing(2),
},
title: {
flexGrow: 1,
},
list: {
width: 250,
}
}));
const MenuAppBar = () => {
const classes = useStyles();
const [auth, setAuth] = useState(true);
// anchorの値を用意しておかないとコンテンツの位置が決まらない
const [anchorEl, setAnchorEl] = useState(null);
// anchor値はBoolean
const open1 = Boolean(anchorEl);
// Drawerの表示位置
const [state, setState] = useState({ left: false });
// デフォルトのまま置いておく
const toggleDrawer = (anchor, open) => (event) => {
if (
event.type === "keydown" &&
(event.key === "Tab" || event.key === "Shift")
) {
return;
}
setState({ [anchor]: open });
};
const handleChange = (event) => {
setAuth(event.target.checked);
};
return (
<React.Fragment>
<div className={classes.root}>
<FormGroup>
<FormControlLabel
control={
<Switch
checked={auth}
onChange={handleChange}
aria-label="login switch"
/>
}
label={auth ? "Logout" : "Login"}
/>
</FormGroup>
<AppBar position="static">
<Toolbar>
<IconButton
edge="start"
className={classes.menuButton}
color="inherit"
aria-label="menu"
onClick={toggleDrawer('left', true)}
>
<MenuIcon />
</IconButton>
<SwipeableDrawer
open={state.left}
onClick={toggleDrawer('left', false)}
onKeyDown={toggleDrawer('left', false)}
>
<DrawerList Styles={classes.list}/>
</SwipeableDrawer>
<Typography variant="h6" className={classes.title}>
Sample App
</Typography>
{!auth && (
<NoAuthAccountCircle />
)}
{auth && (
<AuthAccountCircle />
)}
</Toolbar>
</AppBar>
</div>
</React.Fragment>
);
};
export default MenuAppBar
authStateによって表示されるHeaderコンテンツが変わるようになっています。
以下Headerコンテンツとなります。
AuthAccountCircle
import React from "react";
import { useState } from "react";
import IconButton from "@material-ui/core/IconButton";
import AccountCircle from "@material-ui/icons/AccountCircle";
import MenuItem from "@material-ui/core/MenuItem";
import Menu from "@material-ui/core/Menu";
import { Link } from "react-router-dom";
const AuthAccountCircle = () => {
const [anchorEl, setAnchorEl] = useState(null);
const handleMenu = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return (
<React.Fragment>
<div>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={open}
onClose={handleClose}
>
<MenuItem component={Link} to="/Profile" onClick={handleClose}>Profile</MenuItem>
<MenuItem component={Link} to="/Logout" onClick={handleClose}>Logout</MenuItem>
</Menu>
</div>
</React.Fragment>
)
}
export default AuthAccountCircle
NoAuthAccountCircle
import React from "react";
import { useState } from "react";
import IconButton from "@material-ui/core/IconButton";
import AccountCircle from "@material-ui/icons/AccountCircle";
import MenuItem from "@material-ui/core/MenuItem";
import Menu from "@material-ui/core/Menu";
import { Link } from "react-router-dom";
const NoAuthAccountCircle = () => {
const [anchorEl, setAnchorEl] = useState(null);
const handleMenu = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return (
<React.Fragment>
<div>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={open}
onClose={handleClose}
>
<MenuItem component={Link} to="/Login" onClick={handleClose}>Login</MenuItem>
<MenuItem component={Link} to="/SignUp" onClick={handleClose}>Register</MenuItem>
</Menu>
</div>
</React.Fragment>
)
}
export default NoAuthAccountCircle
DrawerList
Drawerのリストもログイン前後で中身が変わりますが、まだコンテンツがすべて確定していないのでとりあえず仮置になります。
import React from 'react';
import { makeStyles } from '@material-ui/core';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import { Link } from "react-router-dom";
// import ListItemIcon from '@material-ui/core/ListItemIcon';
const DrawerList = (Styles) => {
const classes = Styles;
return(
<React.Fragment>
<div className={classes.list}>
<List>
<ListItem button component={Link} to="/Home">
<ListItemText primary="Home" />
</ListItem>
<ListItem button component={Link} to="/">
<ListItemText primary="Test" />
</ListItem>
<ListItem button component={Link} to="/">
<ListItemText primary="Sample" />
</ListItem>
{/*書籍検索画面へ遷移*/}
<ListItem button component={Link} to="/Search_Book">
<ListItemText primary="Search" />
</ListItem>
</List>
</div>
</React.Fragment>
)
}
export default DrawerList
Landingとホーム画面も一応用意しておきます。
HomeComponentLayout
import React from "react";
import { Container } from "@material-ui/core";
const HomeComponentLayout = (props) => {
return (
<Container>{props.children}</Container>
)
}
export default HomeComponentLayout
Home
import React from "react";
import { Box, Typography } from '@material-ui/core';
import HomeComponentLayout from "./HomeComponentLayout"
const Home = () => {
return (
<React.Fragment>
<HomeComponentLayout>
<Box>
<Typography>Home Info</Typography>
</Box>
</HomeComponentLayout>
</React.Fragment>
)
}
export default Home
Landing
import React from "react";
import { Box, Typography } from '@material-ui/core';
import HomeComponentLayout from "./HomeComponentLayout"
const Landing = () => {
return (
<React.Fragment>
<HomeComponentLayout>
<Box>
<Typography>Landing Info</Typography>
</Box>
</HomeComponentLayout>
</React.Fragment>
)
}
export default Landing
Userに関するコンポーネントも仮置しておきます。
UserComponentLayout
import React from "react";
import { Container } from "@material-ui/core";
const UserComponentLayout = (props) => {
return (
<Container>{props.children}</Container>
)
}
export default UserComponentLayout
MyAccount
import React from "react";
import { Box, Typography } from '@material-ui/core';
import UserComponentLayout from "./UserComponentLayout"
const MyAccount = () => {
return(
<React.Fragment>
<UserComponentLayout>
<Box>
<Typography>MyAccount Info</Typography>
</Box>
</UserComponentLayout>
</React.Fragment>
)
}
export default MyAccount
Profile
import React from "react";
import { Box, Typography } from '@material-ui/core';
import UserComponentLayout from "./UserComponentLayout"
const Profile = () => {
return (
<React.Fragment>
<UserComponentLayout>
<Box>
<Typography>Profile Info</Typography>
</Box>
</UserComponentLayout>
</React.Fragment>
)
}
export default Profile
最後にルーティングをするためのSwitchコンポーネントを書きます。
Contents
NoMarch(404エラー)時の画面も後ほど作ってルーティングしておく必要があります。
import React from "react";
import { Switch, Route, Redirect } from "react-router-dom";
// ログイン状態でルーティング
// ログイン・ユーザー登録のルーティング
import Login from "./AuthComponents/TestFirebaseAuthContainer";
import Logout from "./AuthComponents/TestFirebaseLogoutContainer";
import Register from "./AuthComponents/TestFirebaseRegisterContainer";
// コンテンツへのルーティング
import Landing from "./HomeComponents/Landing";
import Home from "./HomeComponents/Home";
import Profile from "./UserComponents/Profile";
import MyAccount from "./UserComponents/MyAccount";
import SearchBooks from "./BookComponents/SearchBookContainer";
// import NoMatch from "./HomeComponents/Nomatch.js"
const Contents = () => {
return (
<Switch>
<Route path="/" exact>
<Landing />
</Route>
<Route path="/Home" component={Home} />
<Route path="/Profile" component={Profile} />
<Route path="/MyAccount" component={MyAccount} />
<Route path="/Login" component={Login} />
<Route path="/Logout" component={Logout} />
<Route path="/SignUp" component={Register} />
{/* <Route path="/user" component={} /> */}
<Route path="/Search_Book" component={SearchBooks} />
{/* <Route path="/search_book" component={SearchBooks} />
<Route path="/search_book" component={SearchBooks} /> */}
{/* <Route component={NoMatch}></Route> */}
<Redirect to="/" />
</Switch>
);
};
これでひとまずフロント側は終わりです。
バックエンドに移っていきます。
バックエンド側での準備
カスタムAuthenticationの準備
参考: Firebase Authentication in Django REST Framework
参考: Authentication
参考: [Django] django-environで環境変数を管理してみる
さて、ここまで何度か言及してきましたがTwitter認証でのログインはTokenを用いた認証になるのでフロントエンドフレームワークとバックエンドフレームワークとで分かれている場合はフロントからバックエンドへTokenをパスして、バックエンドでも認証を行う必要があります。
なのでそのための認証フローを作っておく必要があります。
DRFの認証はsettings.py
でデフォルトのそれを指定できる他、Viewでpermission_classes
で任意のものに指定することもできます。
viewにアクセスした際にデフォルトまたは指定した認証クラスによって認証が行われ、成功するとそのviewへアクセスができるということになります。
ただし、これも何度か言及しましたがハイブリッドアーキテクチャの場合はログイン・認証等はDjango側に一任することができるので、単にTwitterログインを実装したいのであればallauthを用いればいいのでFirebase Authenticationに拘る必要は実際にやってみてわかりましたがさりとて認証周りは面倒なので、Firebase Authenticationを利用できればそれに越したことはないのでこの機会に原理的な手法を学ぶという意味でも今回はやってみようと思います。
以下が必要なライブラリです。
- firebase_admin
- rest_framework
- environ(必要に応じて)
firebase_authentication.py
import os
import environ
import firebase_admin
from firebase_admin import auth
from firebase_admin import credentials
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils import timezone
from rest_framework import authentication
from rest_framework import exceptions
import json
from ..excepts.firebase_auth_exceptions import FirebaseError
from ..excepts.firebase_auth_exceptions import InvalidAuthToken
from ..excepts.firebase_auth_exceptions import NoAuthToken
env = environ.Env(DEBUG=(bool, False))
DEBUG = env('DEBUG')
User = get_user_model()
# Django-environで管理するようにする
cred = credentials.Certificate({
"type": "service_account",
"project_id": env.get_value('FIREBASE_PROJECT_ID', str),
"private_key_id": env.get_value('FIREBASE_PRIVATE_KEY_ID', str),
"private_key": env.get_value('FIREBASE_PRIVATE_KEY', str).replace('\\n', '\n'),
"client_email": env.get_value('FIREBASE_CLIENT_EMAIL', str),
"client_id": env.get_value('FIREBASE_CLIENT_ID', int),
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": env.get_value('FIREBASE_CLIENT_CERT_URL', str)
})
# 上記の認証情報でfirebase初期化
default_app = firebase_admin.initialize_app(cred)
# DRFのBaseAuthenticationクラスを継承してfirebase用の認証クラスを作る
class FirebaseAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
auth_header = request.META.get("HTTP_AUTHORIZATION")
if not auth_header:
raise NoAuthToken("No auth token provided")
id_token = auth_header.split(" ").pop()
decoded_token = None
try:
# トークンの有効期限が切れているかどうかも含めて検証する
decoded_token = auth.verify_id_token(id_token, check_revoked=True)
except auth.RevokedIdTokenError:
raise FirebaseError("Your auth token expired")
except Exception:
raise InvalidAuthToken("Invalid auth token")
if not id_token or not decoded_token:
return None
try:
# firebaseから取得してきた情報からuidを取り出す
uid = decoded_token['uid']
# uidを使って該当のユーザーデータを取得する
fetch_user = auth.get_user(uid)
# Firebaseから取得してきたデータは特殊なのでvarsで変換する
dict_userData = vars(fetch_user)
# varsで変換すると辞書型になるので任意のデータにアクセスする
test = dict_userData['_data']['providerUserInfo']
# 辞書型から任意の指定したデータを取り出すためにリスト内表記でfor文とget()を使う
provider = [d.get('providerId') for d in test if d.get('providerId')]
# ソーシャルログインに使ったプロパイダ名
provider = str(provider[0])
# TwitterのHNを同じように持ってくる
screenName = [d.get('screenName')for d in test if d.get('screenName')]
screenName = str(screenName[0])
# 上記の2変数とuidを引数にしてget_or_createをかける
user = User.objects.get_or_create(username=screenName, social_auth=provider, uid=uid)
# user.profile.last_activity = timezone.localtime()
except Exception:
raise FirebaseError()
return user
まず、Django(DRF)側にTokenが送られるということはすなわちDjango(DRFの場合は各API)に対してHttpRequestが行われたということになります。
なのでフロント側でTokenをHttpRequestに載せて送信する必要があったということですね。
ただし、この辺りはセキュリティに関わるところもあるので今回のこの例をそのまま使える……というわけではないと私も思っています。
前述の通りToken認証はセキュリティリスクについて考えないといけないことも多く、例えばHttpOnlyのCookieにTokenを保存して、HttpRequestに載せて送る……なんて方法も考慮しないといけないこともあるみたいなので。
閑話休題、なのでパスを送られたDjango(DRF)側でTokenをキャッチしてあげるような処理を書けばいいということになります。
参考: Request and response objects
HttpRequest.META
はHTTPヘッダーを辞書として返してくれます。
TokenはHTTPヘッダーの内Authorizationヘッダーに載せてRequestしたので引数をそのように設定します。
Authorizationヘッダーが存在しない場合はエラーを吐くようにします。
次にTokenをリストに加工します。
split()で半角スペース刻みで分割して、pop()で最後の半角を消去します。
で、このタイミングでTokenの検証行います。取得してきたTokenが信用できるものかどうかですね。
検証にはFirebaseSDKのPython向けに用意されている物を使います。
参考: ID トークンを検証する
参考: firebase_admin.auth module
フロント側から送られてきたidTokenを検証することになります。
auth.verify_id_token()
にidTokenを引数としてセットして、第2引数にcheck_revoked
をTrueにしてセットします。
check_revoked
をTrueにしてセットしないとTokenの期限切れを判定できず、期限切れのTokenでも認証を通してしまうようです。
ここでまたtry-catchで例外処理を行い、検証が通るとFirebaseからデータが返ってくるので必要な情報だけ取得します。
が、Firebaseのデータはちょっと特殊なので加工も面倒くさいです。
まずfetch_user = auth.get_user(uid)
でuidをキーとして取ってきたユーザーデータの構造を見てみます。
値の部分は実際にはユーザーのデータが入っていると思ってください。
{'_data': {'localId': '', 'displayName': '', 'photoUrl': '', 'providerUserInfo': [{'providerId': '', 'displayName': '', 'photoUrl': '', 'federatedId': '', 'rawId': '', 'screenName': ''}], 'validSince': '', 'lastLoginAt': '', 'createdAt': '', 'screenName': '', 'lastRefreshAt': ''}}
さて一目瞭然でありますが、辞書型です。しかも多次元な上に肝心の部分がさらにリストになっていることがおわかりになると思います。
これには理由があって実は、fetch_user = auth.get_user(uid)
のままprintしても<firebase_admin._user_mgt.UserRecord object at ......>
という形式で返ってくるだけでデータの中身が見れません。
つまり、Firebaseから返ってきたデータはObjectであったということになります。
普段はJsonで来たものを加工して……って工程に慣れてるのでどうしたものかと悩みましたがググったらオブジェクトを辞書型に変換するvars()
関数があったので、ここからアプローチをかけていくことにしました。
なので、上記のデータ構造はvars()を実行した後ということになります。
プロパティに関してはFirebaseのドキュメント準拠なのでそちらでご覧になっていただいた方が確実ですが、一応必要になりそうなところだけ列挙しておきます。
-
displayName(FirebaseにおいてのHNのようなものでTwitter認証を行うとTwitterのHNがここに入る。Basic認証ではメールアドレスとパスワードでの認証なのでなんと後付で設定しないといけない。)
-
photoUrl(Twitterのサムネイル)
-
providerUserInfo(ソーシャルログインで認証した場合、認証プロパイダから返ってきたデータがリストでここに入っている)
-
screenName(Twitterの場合@testnameと表示される部分の@以下の部分、ユーザー名です)
なので、必要な部分はtest = dict_userData['_data']['providerUserInfo']
の部分ということになります。
すると以下の部分だけ取り出せることになります。
[{'providerId': '', 'displayName': '', 'photoUrl': '', 'federatedId': '', 'rawId': '', 'screenName': ''}]
ただ今度はリスト内辞書型というさらに面倒な形になりましたので、これもググってこのリストに対してfor文をリスト内表記で仕掛けて該当のキーの部分の値だけ取得するようにしました。
重複しているプロパティがないのでこれで問題ないということになります。
あとはそのままだとリスト型なのでstr型に直して使うということになります。
今回はユーザーの情報として
- ユーザー名(screenNameの方)
- 認証プロパイダ
- uid(Firebaseとアレコレするために一応控えておく)
をDjangoのUserModelに登録しておきます。
get_or_createにしておけば自動で取得か新規追加か判断してくれるのでいいですね。
あとは上記で使うカスタムExceptionを定義しておきます。
from rest_framework import status
from rest_framework.exceptions import APIException
class NoAuthToken(APIException):
status_code = status.HTTP_401_UNAUTHORIZED
default_detail = "No authentications token provided"
default_code = "no_auth_token"
class InvalidAuthToken(APIException):
status_code = status.HTTP_401_UNAUTHORIZED
default_detail = "Invalid authentication token provided"
default_code = "invalid_token"
class FirebaseError(APIException):
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
default_detail = "The user provided with the auth token is not valid Firebase user, it has no Firebase UID"
default_code = "no_firebase_uid"
その他
もう一つ重要なのがDjango側でもフロント側でやったのと同じようにルーティングをしておきます。
Djangoからプロジェクトを立ち上げて、startappした後にReactプロジェクトを立ち上げていればReactが入っているフォルダ(今回はfrontendという名前です)にもapps.py等、Djangoのファイルも入っていると思うのでそのディレクトリにurls.pyを作ってルーティングしておきましょう。
もちろん、settings.pyやそれが入っているフォルダのurls.pyにReactが入っているフォルダを登録しておくのも忘れずに。
frontend/urls.py
from django.urls import re_path, path
from . import views
# Reactのルーティングをそのままこちらに持ってきて紐付けている。
# ルート以外はname='other_page'としておく
urlpatterns = [
path('', views.index, name='index_page'),
path('Login/', views.index, name='other_page'),
path('SignUp/', views.index, name='other_page'),
path('Logout/', views.index, name='other_page'),
]
# 補足
# re_pathの部分はReactにおいて:idや:usernameを使っている部分の表現がパスコンバーターではできない
# なので re_path(r'^todo/list/[^/]+/$', views.index, name='other_page')のように正規表現等で書く必要がある
settings.pyも一部抜粋しておきます。
"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 3.1.4.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
from pathlib import Path
from corsheaders.defaults import default_headers
from datetime import timedelta
import os
from dotenv import load_dotenv
# import django_heroku
# import dj_database_url
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
REACT_APP_DIR = os.path.join(BASE_DIR, 'apps', 'frontend')
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# apps
'apps.authentication',
'apps.frontend',
'apps.users',
# 3rdparty
'corsheaders',
'rest_framework',
'firebase_admin',
# 'drf_firebase_auth',
]
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
AUTH_USER_MODEL = 'users.CustomUser'
試してみる
では実際に試してみましょう。
UserModelを以下のように設定してちゃんとデータが返ってくるか見てみます。
users/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import permissions
from django.forms.models import model_to_dict
class WhoAmIView(APIView):
""" Simple endpoint to test auth """
permission_classes = [permissions.IsAuthenticated]
def get(self, request, format=None):
""" Return request.user and request.auth """
return Response({
'request.user': model_to_dict(request.user),
'request.auth': request.auth
})
users/urls.py
""" testapp.api URL Configuration """
from django.contrib import admin
from django.urls import path
from . import views
urlpatterns = [
path('whoami/', views.WhoAmIView.as_view(), name='whoami'),
]
config/urls.py
"""config URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path,include
urlpatterns = [
path('', include('apps.frontend.urls')),
path('admin/', admin.site.urls),
path('users/', include('apps.users.urls')),
]
今回はPostmanを試しに使ってみました。
結構簡単にAPIへRequestした結果とかRequestの中身とか見れて便利でしたね。
実行結果で問題なければ以下のようにデータが返ってくることが確認できました。
uidの部分は実際にはちゃんとuidが入っています。
{
"request.user": {
"id": 4,
"last_login": null,
"is_superuser": false,
"username": "shitikamiyako",
"password": null,
"email": null,
"social_auth": "twitter.com",
"uid": "",
"is_staff": false,
"is_active": true,
"groups": [],
"user_permissions": []
},
"request.auth": false
}
最後に
ハイブリッドアーキテクチャでは今回やったようなことはやる必要はない可能性が高いかもしれませんが、こういったことをやりたいけどDjangoだと全然情報がない……ということに悩まされたので少しでも参考になればとの思いと自分のアウトプットを兼ねて書いてみました。
ちなみに、ハイブリッドアーキテクチャでない場合は当然CORS等の設定は必要になってきますね。
次回からはちゃんとコンテンツ整備していきます……
参考
developer.twitter.com より Authentication
Modern JavaScript for Django Developers
React and Django: Your guide to creating an app
Firebase Authentication in Django REST Framework