SPAのログイン周りについて、「これがベストプラクティスだ!」という情報があまり見当たらないので、様々な可能性を模索してみました。
いろいろな状況が想定され、今回記載する内容に考慮の漏れや不備などがありましたら是非コメントでご指摘いただきたいです!特に「おすすめ度:○」と記載しているものに対しての批判をどしどしお待ちしております!
この記事でおすすめしているものであっても、ご自身の責任で十分な検討・検証の上で選択されてください。
前提
想定しているAPIは、
- ログイン外のAPIにはPOST/PUT/DELETEのものがなく、GETのみ
- GETのAPIにはDBを更新するなどの操作がない
とし、そのためログイン外ではCSRFを考慮しなくてよい、
という前提で話を進めます。
また、XSSに関しては常に対策は必要なのですが(フレームワーク側が自動的にしてくれる部分もある)、認証周りに関係すること以外はあまり記載していません。
Cookieについて
- httpOnly属性: trueだと、JSからCookieを参照できなくなる。
- secure属性: trueだと、httpsのときしかCookieを送信しない。
- sameSite属性: クロスオリジンの際にCookieを送付するかどうかを3段階(strict/lax/none)で調整できる
XSSの際、悪意あるJSにCookieを抜き取られないようにするため、基本的にhttpOnly:trueにする。
また、http通信時にCookieが送信されてしまうと、通信内容を傍受されてしまった際にJWTが流出してしまう。
それを防ぐために基本的にはすべての通信をhttpsにして、secure:trueにする。
sameSite属性は場合による。(参考:https://numb86-tech.hatenablog.com/entry/2020/01/26/112607 )
早見表
方法 | おすすめ度 | コメント |
---|---|---|
JWTを用い、localStorageに保存 | △〜✖ | XSSでJWTが盗まれうる |
JWTを用い、localStorageに保存 + refreshトークン | △〜✖ | XSSでJWTが盗まれうる。refreshトークンをCookieに保存する方法を他で聞いたことがないので不安。。 |
JWTを用い、Cookieに保存 + CSRFトークンを利用 | ○ | |
JWTを用い、Cookieに保存 + CSRF対策にプリフライトリクエストを利用 | △ | Access-Control-Max-Ageの期間内だとCSRFがおきうる? |
JWTを用い、Cookieに保存、JWT送信時はカスタムヘッダに付与して送信 | △〜✖ | httpOnly:trueを外さざるを得ないのでXSSでJWTが盗まれうる |
JWTを用い、SameSiteCookieに保存 | ○ | SPAとAPIが同一オリジンである必要がある |
Sessionを用いる + CSRFトークンを利用 | ○ | SPAでないアプリのAJAXでよくあるやつ |
Sessionを用いる + CSRF対策にプリフライトリクエストを利用 | △ | Access-Control-Max-Ageの期間内だとCSRFがおきうる? |
Sessionを用い、SessionのCookieはSameSiteCookie | ○ | SPAとAPIが同一オリジンである必要がある |
JWTを用い、localStorageに保存
概要
認証(mail + passwordなど)
↓
JWTが返却される(レスポンスヘッダやレスポンスボディなどで返却。ここではCookieでの返却は考えない。)
↓
JWTをlocalStorageに保存
↓
リクエスト時はJWTを一緒に送信(リクエストヘッダやリクエストボディ)
ログイン有効時間をユーザーが最後に操作してから○○分以内という風にする場合は、リクエストの度にJWTを更新する必要がある。
そうでない場合はログイン時に設定した有効期限が来るとユーザー操作中でも強制ログアウト。
XSSについて
localStorageはOriginごとに独立しているため、別のOriginからJSでJWTを盗まれることはない。
しかしながらXSSが起きると、
同一Originの状況で悪意あるJSによってJWTが漏れてしまう。
たとえばnpmで導入した外部ライブラリの中に、JWTを抜き取るための悪意あるコードがないかどうかすべて確認するのは現実的ではないだろう。XSSは起きうるという前提で考えると、localStorageにJWTを保存するのは基本的にはよくない。
【参考】https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851
CSRFについて
「不正なサイトから当該サイトにリクエストを投げたときに自動でcookieが付与される」という典型的なパターンの事象が起きないので考慮不要。
総括
JWTをlocalStorageに保存するだけの方法では、XSSによりJWTが抜き取られ、第三者によるリクエストが可能になる。特にJWTの有効期限が長い場合、被害が大きくなりうる。
有効期限が長く、リクエストの度にJWTを更新する場合
おすすめ度:✖
XSSでJWT流出の危険性
有効期限が長く、一度JWTを発行したらそのJWTを使い続ける場合(操作中でも有効期限がきたらログアウトさせる)
おすすめ度:✖
XSSでJWT流出の危険性
有効期限が十分に短い
おすすめ度:△
XSSでJWTが流出した場合の危険性をある程度抑えれれている。
JWTを用い、localStorageに保存 + refreshトークン
概要
例えば、JWTの有効期限を30分に設定するものの、refreshトークンの有効期限は1ヶ月、のような設定にする。
JWTの有効期限が切れてもrefreshトークンの有効期限内であれば自動的にrefresh、またrefreshする際にrefreshトークンの有効期限をその時点から1ヶ月になるように更新することで、1ヶ月に1度以上の頻度でユーザーが操作する限り、UX的には常にログインしているような状態を実現する。
refreshトークンがあるので、リクエストの度にJWTを更新することは考えない。
こうすれば、もしJWTが抜き取れれた場合もrefreshトークンが抜き取られなければ30分でJWTが使えなくなりある程度の安全性を確保できる。
認証(mail + passwordなど)
↓
JWT(レスポンスヘッダやレスポンスボディなどで返却。ここではCookieでの返却は考えない。)
と
refreshトークン(Set-Cookie, httpOnly:true, secure:true)
を返却
↓
JWTをlocalStorageに保存
↓
リクエスト時は
JWT(リクエストヘッダやリクエストボディ)
と
refreshトークン(リクエストヘッダに自動的にCookieが付与される)
を一緒に送信
JWTの有効期限が切れていたらJWTとrefreshトークンで両者をrefresh
XSSについて
XSSによりJWTが抜き取られうる。refreshトークンはCookie(httpOnly: true)なので抜き取られない。
CSRFについて
JWTがCookieでないので基本的には考慮不要。
refreshトークンがCookieに存在するため、JWTが抜き取られた状態だとCSRFによって一応refreshすることができてしまう。
しかしながらCSRFでrefreshしてもCORS制限によりブラウザはレスポンスを読み取れず有効なJWTを利用することができないため問題ないと考えられる。
refreshトークンについて
JWTにはユーザー情報と、refreshトークンを第三者が復号できない方式で暗号化したもの、が含まれている。JWTの中身自体は第三者でも復号して確認できるので、refreshトークンの情報は、第三者が復号できない方法で暗号化したものを入れる必要があると考えた。
refresh時はサーバーサイドではJWTを検証してユーザー情報と暗号化されたrefreshトークンを取得し、
暗号化されたrefreshトークンを復号して(←サーバーでだけ復号できる)refreshトークンと一致しているかどうかを確認する。
確認が取れれば、JWTとrefreshトークンをrefreshする。
総括
JWTをlocalStorageに保存する方法では、XSSによりJWTが抜き取られ、第三者によるリクエストが可能になる。特にJWTの有効期限が長い場合、被害が大きくなりうる。
また、refreshトークンがcookieに存在するのでリクエストのたびに毎回送信されているのがすこし怖い気がする。
(2020.04.26 追記)↑に対して
cookie の path 属性を使って refresh 時にのみ送信する
という方法をコメントにて教えていただきました!ご指摘ありがとうございます!
JWTの有効期限が長い場合
おすすめ度:✖
XSSでJWT流出の危険性
JWTの有効期限が十分に短い
おすすめ度:△
XSSでJWTが流出した場合の危険性をある程度抑えれれている。
ただ、refreshトークンをCookieに保存する方法を他で聞いたことがない。この方法の危険性などありましたら是非指摘をお願いいたします!
JWTを用い、Cookieに保存 + CSRFトークンを利用
概要
認証(mail + passwordなど)
↓
JWT(Set-Cookie, httpOnly:true, secure:true)
と
CSRFトークン(レスポンスヘッダやレスポンスボディなどで返却。Set-Cookieでの返却だとCSRF対策にならない。)
を返却
↓
リクエスト時は、
JWT(リクエストヘッダに自動的にCookieが付与される)
と
CSRFトークン(リクエストヘッダやリクエストボディ)←POST/PUT/DELETEの場合に限定しても良いかもしれない
を送信
また、リロードした際にCSRFトークンが消えるのでJWTの有効期限が切れていない場合に再取得できる仕組みが必要。
さらに、ログイン有効時間をユーザーが最後に操作してから○○分以内という風にするにはリクエストの度にJWTを更新しなければならない。
そうでない場合はログイン時に設定した有効期限が来るとユーザー操作中でも強制ログアウト。
XSSについて
XSSが起きた場合でもhttpOnly:trueによりJWTが流出しない。
また、CSRFトークンもJSのグローバルから参照できない変数に保持していれば流出しない。
CSRFについて
悪意のあるサイトから当該サイトにリクエストを投げたとき、JWTはCookieとして自動で送信されるが、CSRFトークンは送信できず、サーバーサイド側で不正なリクエストと判断することができる。
※もしCSRF対策しなかった場合も、CORSの制約によりブラウザがレスポンスを読み取らせてくれず、ブラウザ側ではきちんとエラーがでる。ではなぜ対策が必要なのかと言うと、「レスポンスが読み取れないものの、サーバーサイドとしてはリクエストが正常に完了している」と言う点が問題だからである。
例えば、DBのレコード追加をするための通信の場合、レスポンスは読み取れないもののレコードの追加は完了してしまっている。
上記で記載した「POST/PUT/DELETEに限定する」というのは、リソースの更新を伴う機能にはGETを用いないという前提のもと、CSRFでアクセスされたとしてもリソースの更新はなく、CORS制約によりレスポンスが読み取られないため実害があまりないということによるものである。
※リロード時にCSRFトークン再取得をする機能があるので、悪意のあるサイトからCSRF的にCSRFトークン再取得を行ったとしてもレスポンスを読み取れないため問題なし。
CSRFトークンについて
このJWTにはユーザー情報と、CSRFトークンを第三者が復号できない方式で暗号化したもの、が含まれている。(JWTの中身自体は第三者でも復号して確認できるので、CSRFトークンの情報はさらに別の、復号できない方法で暗号化したものを入れる必要があると考えた)
サーバーサイドではJWTを検証してユーザー情報と暗号化されたCSRFトークンを取得し、
暗号化されたCSRFトークンを復号して(←サーバーでだけ復号できる)CSRFトークンと一致しているかどうかを確認する
総括
JWTをlocalStorageでなくCookieに保存するためXSSでJWTを奪われない。一方でCookieであるがゆえにCSRF対策が必要であり、CSRFトークンを用いている。
リクエストの度にJWTを更新する(ログイン有効時間をユーザーが最後に操作してから○○分以内とする)場合
おすすめ度:○
一度JWTを発行したらそのJWTを使い続ける場合(操作中でも有効期限がきたらログアウトさせる)場合
おすすめ度:○
類似実装
CSRFトークンをlocalStorageに保存する方法が下記で紹介されていた。
https://fujiten3.hatenablog.com/entry/2019/07/13/130459
こちらだとサーバーサイドでのトークンの管理をgemに一任し、フロント側ではCSRFトークンをlocalStorageで管理しているようだ。
こうなるとCSRFトークンがXSSで盗まれうるのだが、JWTの有効期限が短ければ頻繁にCSRFトークンがアップデートされるので危険性が低いと言えるかもしれない。また、Redisなども必要になるようだ。
JWTを用い、Cookieに保存 + CSRF対策にプリフライトリクエストを利用
概要
認証(mail + passwordなど)
↓
JWT(Set-Cookie, httpOnly:true, secure:true)を返却
↓
リクエスト時は、JWT(リクエストヘッダに自動的にCookieが付与される)とカスタムヘッダを送信
XSSについて
XSSが起きた場合でもhttpOnly:trueによりJWTが流出しない。
CSRFについて
Cookieを用いているため、CSRF対策が必要。
そこで、CORSによるプリフライトリクエストを利用してCSRFを防ぐ
プリフライトリクエストが起きるようにするための方法はいくつかあるが、カスタムヘッダを用いる方法がある。カスタムヘッダがあると、必ずプリフライトリクエストが起きる。サーバー側でどのOriginからのリクエストを許容するかをきちんと設定していれば、プリフライトリクエストで不正なリクエストが弾かれることになる。
また、カスタムヘッダがなければプリフライトリクエストが起こらないことがあるが、カスタムヘッダがないものはサーバー側で不正なリクエストとして扱えば良い。
参考:https://qiita.com/uryyyyyyy/items/9a8276f7241b650f1c15#%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%81%AEpre-flight%E3%82%92%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B
総括
JWTだとCSRF対策が不要なイメージがあるが、Cookieを使っているせいでこの方式では対策が必要である。
そこでプリフライトリクエストを用いて対策している。
リクエストの度にJWTを更新する(ログイン有効期限をユーザーが最後に操作してから○○分以内とする)場合
おすすめ度:△
Access-Control-Max-Ageの期間内だとCSRFがおきうる?
一度JWTを発行したらそのJWTを使い続ける場合(操作中でも有効期限がきたらログアウトさせる)場合
おすすめ度:△
Access-Control-Max-Ageの期間内だとCSRFがおきうる?
JWTを用い、Cookieに保存、 JWT送信時はカスタムヘッダに付与して送信
概要
認証(mail + passwordなど)
↓
JWT(Set-Cookie, httpOnly: false, secure:true)を返却
↓
リクエスト時は、JWT(リクエストヘッダのカスタムヘッダ)を送信
JWTはCookieで保存するものの手動でカスタムヘッダにセットリクエストを送信し、サーバーサイドではカスタムヘッダの方のJWTを検証することとする。
XSSについて
Cookieに保存しているJWTをJSで取得してカスタムヘッダに付け替える操作が必要であるため、httpOnly:trueを設定できない。
そのため、XSSでJWTが流出しうる。
CSRFについて
CookieのJWTを検証するわけではないのでCSRFは考慮しなくてよい。
総括
結局XSSでJWTが流出するのでlocalStorageに保存している場合と同じである。
有効期限を短くして、refreshトークンをhttpOnly:trueのCookieに設定するのであれば有用と思われる。
おすすめ度:△〜✖
理由に関しては、JWTをlocalStorageに入れるケースの説明を参照
JWTを用い、SameSiteCookieに保存
概要
SPAとAPIを同一Originにできる場合限定
認証(mail + passwordなど)
↓
JWT(Set-Cookie, httpOnly:true, secure:true, SameSite: Strict)を返却
↓
リクエスト時は、JWT(リクエストヘッダに自動的にCookieが付与される)を送信
XSSについて
XSSが起きた場合でもhttpOnly:trueによりJWTが流出しない。
CSRFについて
SameSiteCookieを用いているため、クロスオリジンだとCookieが送信されずCSRF対策は不要。
総括
すごくシンプルな実装でSPAとAPIが同一ドメインの場合はこれだけでいいかと思いました。
おすすめ度:○
Sessionを用いる + CSRFトークンを利用
概要
認証(mail + passwordなど)
↓
SessionのCookie(Set-Cookie, httpOnly:true, secure:true)
と
CSRFトークン(レスポンスヘッダやレスポンスボディなどで返却。Set-Cookieでの返却だとCSRF対策にならない。)
を返却
↓
リクエスト時は、
SessionのCookie(リクエストヘッダに自動的にCookieが付与される)
と
CSRFトークン(リクエストヘッダやリクエストボディ)←POST/PUT/DELETEの場合に限定しても良いかもしれない
を送信
また、リロードした際にCSRFトークンが消えるのでJWTの有効期限が切れていない場合に再取得できる仕組みが必要。
XSSについて
XSSが起きた場合でもhttpOnly:trueによりCookieが流出しない。
また、CSRFトークンもJSのグローバルから参照できない変数に保持していれば流出しない。
CSRFについて
悪意のあるサイトから当該サイトにリクエストを投げたとき、Cookieは自動で送信されるが、CSRFトークンは送信できず、サーバーサイド側で不正なリクエストと判断することができる。
※リロード時にCSRFトークン再取得をする機能が必要であるが、悪意のあるサイトからCSRF的にCSRFトークン再取得を行ったとしてもレスポンスを読み取れないため問題なし。
総括
SPAでないアプリケーションでAJAXするときのよくあるパターンのように、セッションを用いつつ、CSRFトークンで対策する
おすすめ度:○
Sessionを用いる + CSRF対策にプリフライトリクエストを利用
概要
認証(mail + passwordなど)
↓
SessionのCookie(Set-Cookie, httpOnly:true, secure:true)を返却
↓
リクエスト時は、
SessionのCookie(リクエストヘッダに自動的にCookieが付与される)とカスタムヘッダを送信
XSSについて
XSSが起きた場合でもhttpOnly:trueによりCookieが流出しない。
CSRFについて
プリフライトリクエストで対策する。
カスタムヘッダがあると、必ずプリフライトリクエストが起きる。サーバー側でどのOriginからのリクエストを許容するかをきちんと設定していれば、プリフライトリクエストで不正なリクエストが弾かれることになる。
また、カスタムヘッダがなければプリフライトリクエストが起こらないことがあるが、カスタムヘッダがないものはサーバー側で不正なリクエストとして扱えば良い。
総括
おすすめ度:△
Access-Control-Max-Ageの期間内だとCSRFがおきうる?
Sessionを用い、SessionのCookieはSameSiteCookie
概要
JWT in SameSiteCookieのSessionバージョン。
SPAとAPIを同一Originにできる場合限定
認証(mail + passwordなど)
↓
SessionのCookie(Set-Cookie, httpOnly:true, secure:true, SameSite: Strict)を返却
↓
リクエスト時は、SessionのCookie(リクエストヘッダに自動的にCookieが付与される)を送信
CSRFについて
SameSiteCookieを用いているため、クロスオリジンだとCookieが送信されずCSRF対策は不要。
XSSについて
XSSが起きた場合でもhttpOnly:trueによりCookieが流出しない。
総括
JWTのときと同様、すごくシンプルな実装でSPAとAPIが同一ドメインの場合はこれだけでいいかと思いました。
おすすめ度:○
今後の課題(自分用メモを兼ねる)
- IDaas(Cognitoなど)が使いやすそうか今後検証(参考:https://tech.hicustomer.jp/posts/modern-authentication-in-hosting-spa/ )
- Contents Security Policyについて調べる
- Fetch Metadata Request Headersについて調べる
- DNS Rebindingをされても大丈夫かどうか、それぞれの方法で考える(参考:https://blog.tokumaru.org/2007/11/dns-rebinding.html )
- リクエストメソッドの「Trace」メソッドによる脆弱性を検証
- https://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/704.html を理解し対策を考える
- セキュリティ関係諸々調べる
まとめ
認可のための情報はJSでアクセスできるところにおいてはいけない
JWTをWebStorageやhttpOnly:falseのCookieに置かない。SessionのCookieにはhttpOnlyをつける。
CSRF対策は、CSRFトークンを用いる or プリフライトリクエストを用いる or SameSiteCookie
CSRF対策で、本来改ざんできないはずのOriginを確認する方法があるようだが、場合によっては改ざん可能らしいので完璧ではないかもしれない。(参考:https://insert-script.blogspot.com/2018/05/adobe-reader-pdf-client-side-request.html )
また、プリフライトリクエストを用いる方法に疑問視の声もある(参考:http://blog.a-way-out.net/blog/2015/03/23/stateless-csrf-protection/ )。
(2020.4.27追記)
プリフライトリクエストに関しては、Access-Control-Max-Ageの期限内だとCSRFが起きうる気がしてきました!
最後に Access-Control-Max-Age は、プリフライトリクエストを再び送らなくてもいいように、プリフライトのレスポンスをキャッシュしてよい時間を秒数で与えます。この例では86400秒、つまり24時間です。なお、ブラウザーは個々に内部の上限値を持っており、 Access-Control-Max-Age が上回った場合に制限を掛けます。
↑引用元(https://developer.mozilla.org/ja/docs/Web/HTTP/CORS)
SameSiteCookieについては実装例があまり多くなさそうな気がするが妥当性はどうだろうか。