SPAでの認証といえばJWTを使うことが多いと思いますが、
localStorageに保存するとセキュリティリスクが高いとかで、
CookieにHttpOnlyな値として保存するのが良いとしばしば言われることもあります。
今回はReact × ExpressでJWTをCookieに保存する具体的な方法を紹介します。
(そもそもJWTを使うべきかとか、localStorageを使うことのリスクなどについては要件次第なのであまり言及しません)
調査にあたっては以下の記事を参考にしました。
React Authentication: How to Store JWT in a Cookie
記事の方法そのままでは自分の環境では上手くいかなかったので、ハマりポイントも含めて手順を解説します。
最終的に出来上がったもの
動作環境
以下のDockerイメージを使用して挙動を確認しました。
node:15.5.1-alpine3.12
準備編
まずはlocalStorageにJWTを保存して動くサンプルアプリケーションを用意します。
上記の参考記事を見てもらっても良いですが、
こちらで用意した以下のリポジトリを見てもらっても良いです。
本記事ではこちらに準じて進めます。
Reactの部分だけTypeScriptを使用 + Dockerを使った構成
https://github.com/Kanatani28/jwt-how-to-use
(ちなみに自前でプロジェクトを作成したい場合はcreate-react-app
でプロジェクトを作成して、各種ライブラリをインストールしてください。)
ソースコードは以下のようになっています。
App.tsx
importReact,{useState}from'react';importaxiosfrom'axios';import'./App.css';constapiUrl='http://localhost:3001';axios.interceptors.request.use(// allowedOriginと通信するときにトークンを付与するようにする設定config=>{const{origin}=newURL(config.urlasstring);constallowedOrigins=[apiUrl];consttoken=localStorage.getItem('token');if(allowedOrigins.includes(origin)){config.headers.authorization=`Bearer ${token}`;}returnconfig;},error=>{returnPromise.reject(error);});typeFood={id:numberdescription:string}functionApp(){conststoredJwt=localStorage.getItem('token');const[jwt,setJwt]=useState(storedJwt||null);const[foods,setFoods]=useState<Food[]>([]);const[fetchError,setFetchError]=useState(null);constgetJwt=async()=>{const{data}=awaitaxios.get(`${apiUrl}/jwt`);localStorage.setItem('token',data.token);setJwt(data.token);};constgetFoods=async()=>{try{const{data}=awaitaxios.get(`${apiUrl}/foods`);setFoods(data);setFetchError(null);}catch(err){setFetchError(err.message);}};return(<><sectionstyle={{marginBottom:'10px'}}><buttononClick={()=>getJwt()}>Get JWT</button>{jwt&&(<pre><code>{jwt}</code></pre>)}</section><section><buttononClick={()=>getFoods()}>
Get Foods
</button><ul>{foods.map((food,i)=>(<li>{food.description}</li>))}</ul>{fetchError&&(<pstyle={{color:'red'}}>{fetchError}</p>)}</section></>);}exportdefaultApp;
server.js
constexpress=require('express');constjwt=require('express-jwt');constjsonwebtoken=require('jsonwebtoken');constcors=require('cors');constapp=express();app.use(cors());constjwtSecret='secret123';app.get('/jwt',(req,res)=>{// JWTを生成する(今回は固定値で作成している)res.json({token:jsonwebtoken.sign({user:'johndoe'},jwtSecret)});});app.use(jwt({secret:jwtSecret,algorithms:['HS256']}));constfoods=[{id:1,description:'burritos'},{id:2,description:'quesadillas'},{id:3,description:'churos'}];app.get('/foods',(req,res)=>{res.json(foods);});app.listen(3001);console.log('App running on localhost:3001');
アプリケーション概要
server.jsには/jwt
と/foods
という2つのエンドポイントを用意しています。
/jwt
はJWTを、/foods
はJSONデータを返します。
App.tsxではボタンを2つ用意し、それぞれボタンを押したタイミングでサーバーと通信するようにしています。
docker-compose up
を実行するとlocalhost:3000
でReactのアプリケーションが立ち上がり、
その後docker-compose exec front node src/server.js
を実行すると
localhost:3001
でNode.jsのアプリケーションが立ち上がります。
localhost:3000
にアクセスすると以下のような画面が表示されるはずです。
いきなりGet Foodsボタンを押すと401エラーが表示され、
Get JWTでJWTを取得後、Get Foodsボタンを押すと、今度は正常に通信できるはずです。
JWTなしで通信
![スクリーンショット 2021-01-21 22.37.22.png]()
JWTありで通信
![スクリーンショット 2021-01-21 22.37.33.png]()
localStorageを確認してみる
Chromeの開発者ツール > Applicationを開くとlocalStorageに取得したtokenが設定されているのが確認できます。
![スクリーンショット 2021-01-21 23.38.45.png]()
localStorageに保存されているので、当然JavaScriptで取得することができます。
localStorage.getItem("token")
この状態があまりよろしくないので修正していきます。
修正編
JWTをCookieに保存する
まず最初にserver.jsのJWTを発行する部分を修正していきます。
そもそもCookieの仕組みって?
図にすると以下のようになります。
(知ってるよって人はスキップしてください)
![スクリーンショット 2021-01-22 17.19.04.png]()
サーバーからのレスポンスヘッダーにSet-Cookieという値が設定されていた場合、
クライアントのCookieにその値がセットされます。
以降そのサーバーとの通信ではセットされたCookieの値が付与されることになります。
フルスタックなフレームワークだとこういった仕組みを提供しているものが多いです。
Set-Cookieヘッダーを付与するようにする
Cookieをセットするためには、サーバーのレスポンスにSet-Cookieヘッダーを含める必要があります。
Cookieを使うため、JWT取得時に以下のようにSet-Cookieヘッダーを含めてレスポンスを返すようにします。
server.js
app.get('/jwt',(req,res)=>{consttoken=jsonwebtoken.sign({user:'johndoe'},jwtSecret);// Set-Cookieヘッダーにtokenをセットする処理res.cookie('token',token,{httpOnly:true});res.json({token});});
今回はHttpOnlyをtrueとしているため、document.cookie
のようなJavaScriptからはアクセスできず、
基本的にはHTTP通信するときのみ参照できるようになっています。
(HttpOnlyを設定していない場合はセキュリティ的にはlocalStorageに保存する方法と大差ないかと思います)
CORS対応する(ハマりポイント)
こちらは元記事にはなかった手順になります。
SPAではよくある構成かと思いますが、今回はlocalhost:3000
とlocalhost:3001
と
クロスオリジンでアプリケーションを起動しています。
クロスオリジンでCookieを使用する場合、いくつか設定が必要になります。
server.jsのcorsを設定している部分を以下のように修正します。
server.js
app.use(cors({credentials:true,origin:"http://localhost:3000"}));
これでlocalhost:3000
で起動しているアプリケーションともCookieをやり取りすることができるようになります。
また、App.tsxの方にも以下を追記します。
App.tsx
axios.defaults.withCredentials=true;
今回はサーバーとの通信にaxiosを使用していますが、
axiosはデフォルトではCookieを使う設定になっていないので、
上記のようにwithCredentialsをtrueにすることで通信時にCookieを送信できるようになります。
ここまで設定できたら再度アプリケーションを動かしてみましょう。
Get JWTボタンを押すとJWTが取得でき、開発者ツールで確認すると
Cookieにtokenが設定できているはずです。
![スクリーンショット 2021-01-24 17.47.38.png]()
Cookieに設定されたtokenを検証するようにする
server.jsでApp.tsxからのリクエスト時にCookieに設定されたtokenを検証する処理を追記・修正します。
まずは新しくcookie-parser
というライブラリを追加します。
docker-compose exec front yarn add cookie-parser
次にserver.jsを以下のように修正します。
server.js
constcookieParser=require('cookie-parser');// 略app.use(cookieParser());app.use(jwt({secret:jwtSecret,algorithms:['HS256'],getToken:req=>req.cookies.token}));
expressではcookie-parserを使用することでRequestに含まれるCookieを簡単に取得できるようになります。
また、検証もexpress-jwtを使うことで手軽にできるようになります。(req.cookies.token
の部分)
getTokenで設定した関数でトークンを取得し、secretに設定した値を使って検証するといったような形です。
次にApp.tsxの方で不要になったlocalStorageを使用する部分を削除しておきます。
この部分は参考記事ではこの対応はしていませんが、
localStorageとCookieどちらが使われているかわかりにくくなるかもしれないので念のために消しておきます。
また、この修正でlocalStorageからJWTを読み込まないようにしたので
画面表示時にJWTが表示されることがなくなります。
HttpOnlyなCookieを使ったのでdocument.cookie
のようなJavaScriptからは取得できないようになっています。
App.tsx
// 略// Bearerで送る必要がなくなったので不要// axios.interceptors.request.use(// config => {// const { origin } = new URL(config.url as string);// const allowedOrigins = [apiUrl];// const token = localStorage.getItem('token');// if (allowedOrigins.includes(origin)) {// config.headers.authorization = `Bearer ${token}`;// }// return config;// },// error => {// return Promise.reject(error);// }// );// 略functionApp(){// localStorageにセットしなくなったので不要// const storedJwt = localStorage.getItem('token');// 初期値はnullにしているconst[jwt,setJwt]=useState<string|null>(null);// 略constgetJwt=async()=>{const{data}=awaitaxios.get(`${apiUrl}/jwt`);// localStorageにセットする必要がないので不要// localStorage.setItem('token', data.token);setJwt(data.token);};// 略
以上でJWTをCookieに保存してサーバーとやりとりできるようになりました。
CSRF対策
localStorageはXSSによる攻撃を受けやすいのに対して、
Cookieの場合はCSRFによる攻撃を受けやすいと言われています。
なのでCookieを使ったtokenのやり取りにはCSRFへの対策とセットで行なう必要があります。
サンプルアプリケーションのアップデート
server.jsにPOSTリクエストを受け付けるエンドポイントを追加します。
server.js
app.post('/foods',(req,res)=>{foods.push({id:foods.length+1,description:'new food'});res.json({message:'Food created!'});});
実装は適当ですが、新しくFoodを追加するようなAPIができたイメージですね。
成功した場合はFood created!というメッセージが返ってきます。
また、App.tsxの方から、POSTリクエストを送信するように修正します。
App.tsx
functionApp(){// 略const[newFoodMessage,setNewFoodMessage]=useState(null);constcreateFood=async()=>{try{const{data}=awaitaxios.post(`${apiUrl}/foods`);setNewFoodMessage(data.message);setFetchError(null);}catch(err){setFetchError(err.message);}};// 略return(<>
// 略
<section><buttononClick={()=>createFood()}>
Create New Food
</button>{newFoodMessage&&<p>{newFoodMessage}</p>}</section></>);}
CSRFトークンを利用する
expressではcsurfというライブラリを使うことで
手軽にCSRF対策をすることができます。
まずはライブラリを追加します。
docker-compose exec front yarn add cookie-parser
/csrf-token
にCSRFトークンを取得するエンドポイントを設定します。
server.js
constcsrf=require('csurf')// 略constcsrfProtection=csrf({cookie:true});app.use(csrfProtection);app.get('/csrf-token',(req,res)=>{res.json({csrfToken:req.csrfToken()});});
これでCSRFトークンを発行できるようになったので、
App.tsxから利用するようにします。
App.tsx
functionApp(){// 略useEffect(()=>{constgetCsrfToken=async()=>{const{data}=awaitaxios.get(`${apiUrl}/csrf-token`);axios.defaults.headers.post['X-CSRF-Token']=data.csrfToken;};getCsrfToken();},[]);// 略}
画面表示時にCSRFトークンを取得し、axiosに設定するようにしています。
これでCSRFの対策ができました。
※ちなみにCSRFトークン取得時にもCookieの値が検証されるので、403エラーが出る場合はJWT取得後に画面を更新してからCreateしてみてください。今回は一画面にすべて詰め込んでいるのでこんな感じになってしまいます。
![スクリーンショット 2021-01-24 17.26.40.png]()
最後に
Cookieの仕組みやCORSについての理解があればフロントエンドがReactからVueになろうが
バックエンドがExpressから他のFWになろうが知識を流用できるはずです。
また、localStorageでもCookieでもXSS対策がされていない場合、難易度に差はあれど盗難のリスクが発生するのは同じなので
そもそもXSS対策がされているかどうかのチェックは必須といえるでしょう。
クロスサイトスクリプティング(XSS)対策としてCookieのHttpOnly属性でどこまで安全になるのか
高い保守性やUXを保持しつつ安全なアプリケーションを目指していきたいですね。
参考
React Authentication: How to Store JWT in a Cookie
クロスサイトでCookieが設定できない場合に確認すること
CORSまとめ
express.jsのcors対応
Express cors middleware
MDN Web Docs Set-Cookie
クロスサイトスクリプティング(XSS)対策としてCookieのHttpOnly属性でどこまで安全になるのか