はじめに
FlaskとVue.jsを使ったWebアプリケーションの実装する際、ログイン機能とJWTを使ったAPIの認可の仕組みを実装しました。その機能のまとめです。
JWTをどこに保存するか問題など、明確な正解がない中で実装をしましたので、同じようにどうしようか迷っている方の参考になればと考えています。
脆弱性やもっと堅牢にするにはこうした方がよいなどあればコメントください。皆さんでより良い認証の仕組みを考えることができればいいと考えています。
今回はFlask-JWT-Extendedというライブラリを使用して、JWTの生成や検証を行っています。
また、生成されたJWTはcookieに保存されます。
サーバーサイド
- Flask
- Flask-JWT-Extended
クライアントサイド
- Vue.js
用語
今回の記事で使用する用語と意味合いです。認識の齟齬を生まないために一応記載しておきます。
-
アクセストークンJWT
APIリクエストの際に使用するトークンの役割を果たすJWT -
リフレッシュトークンJWT
アクセストークンJWTの有効期限が切れた際の更新処理に利用するリフレッシュトークンの役割を果たすJWT -
アクセストークンCSRF対策トークン
CSRFの対策のためにAPIリクエストの際にヘッダーにセットする文字列。アクセストークンJWTと1:1 -
リフレッシュトークンCSRF対策トークン
CSRFの対策のためにトークンリフレッシュの際にヘッダーにセットする文字列。リフレッシュトークンJWTと1:1
認証・認可フロー
以下のフローでログイン認証、JWT生成、JWT検証と行います。
(1) ログイン画面からID・PWを入力し、ログインボタンを押下(クライアント)
(2) ログインボタンが押下されたら、入力されたIDとPWをリクエストボディにのせログイン用エンドポイント(/loginなど)にリクエストを投げる(クライアント)
(3) サーバ側はリクエストからID・PWを取り出し、ユーザの検証(DBとの比較など)を行う(サーバ)
(4) ID・PWの組み合わせが不正な場合はステータスコード「401」を返す。(サーバ)
(5) ID・PWの組み合わせが正しい場合は、JWT等を生成しCookieにセットし、ステータスコード「200」を返す。この際、Cookieにセットされる情報は以下の4つ(サーバ)
- 「アクセストークンJWT」
- 「リフレッシュトークンJWT」
- 「アクセストークンCSRF対策トークン」
- 「リフレッシュトークンCSRF対策トークン」
(6) エラーコードを受け取った場合は再度認証を促す(クライアント)
(7) ステータスコード「200」を受け取った場合は、ログイン後画面に遷移する(クライアント)
(8) APIリクエストを行う際は、Cookieの「アクセストークンJWT」をサーバ側に送る。また、CSRF対策として、「アクセストークンCSRF対策トークン」をヘッダーにセットしてサーバに送る。(クライアント)
(9) APIリクエストを受けったサーバはCookieから「アクセストークンJWT」を取り出し、検証を行う。検証内容は「JWTの改ざんが行われていないか」「JWTの有効期限が切れていないか」。合わせて「アクセストークンCSRF対策トークン」の検証も行う(サーバ)
(10) JWTの改ざんが行われている場合はエラーを返し、ログイン画面に強制的にリダイレクト返す。検証に成功した場合は正しいAPIレスポンスを返す(サーバ)
(12) JWTの有効期限が切れていた場合は、ステータスコード「401」を返す(サーバ)
(13) 401エラーを受け取ったクライアント側はトークンリフレッシュ用のエンドポイント(/refreshなど)にリクエストを投げる(クライアント)
(14) サーバは「リフレッシュトークンJWT」の検証(項番9と同等)と「リフレッシュトークンCSRF対策トークン」の検証を行い、問題なければ、新しい「アクセストークンJWT」「リフレッシュトークンJWT」「アクセストークンCSRF対策トークン」「リフレッシュトークンCSRF対策トークン」をCookieにセットし成功レスポンスを返す(サーバ)
(15) リフレッシュトークンの検証に失敗した場合はエラーを返し、ログイン画面に強制的にリダイレクトする(サーバ)
JWTの有効期限問題
アクセストークンJWTの有効期限はなるべく短くすることを推奨します。
これはシンプルにJWTが漏洩した際の情報漏洩リスクを下げるためです。
しかし短く設定してしまうと、APIリクエストの度にリフレッシュ処理が行われるなどパフォーマンスにも影響が出るため作成するアプリケーションの仕様などによって調整する必要があります。
また、リフレッシュトークンJWTの有効期限に関しては、アクセストークンJWTより長くなると思いますが、
仮にリフレッシュトークンJWTが漏洩してしまった際、アクセストークンJWTの漏洩より被害が大きくなる可能性があります。そのため、特定のリフレッシュトークンJWTを有効期限内であっても無効にする処理などを実装する必要があるかもしれません。
JWTどこに保管するか問題
JWTに限らずトークン情報をどこに保存するか議論がずっと続いています。
選択肢として上げられるのが以下の3つ
- Local storage
- Cookie
- Session storage
それぞれのメリデメは以下の記事でまとめられています。
JWT・Cookieそれぞれの認証方式のメリデメ比較
XSS問題
今回の場合はJWTをCookieに保存しているためXSSの脆弱性が残ります。
XSSの対策はCookieのHttpOnly属性をtrueにすることです。
HttpOnlyをtrueにすることで、JavaScriptから対象のCookieへアクセスすることができなくなります。今回の場合は「アクセストークンJWT」「リフレッシュトークンJWT」にHttpOnlyを設定します。「アクセストークンCSRF対策トークン」と「リフレッシュトークンCSRF対策トークン」に関してはHttpOnly属性は設定しません。これらの使い方は以下のCSRF問題で説明します。
CSRF問題
CookieにJWTを保管している場合、CSRFの脆弱性が出てきます。
上記の「認可・認証フロー」に記載していませんが、「アクセストークンJWT」と「リフレッシュトークンJWT」に対応する「CSRF対策用トークン」をサーバ側で発行しています。JWTには任意の文字列を含めることも可能なので、それぞれのJWTにCSRF対策用トークンを含めておくことで簡単に検証を行うことが出来ます。APIリクエストやトークンのリフレッシュを行う場合は、このCSRF対策用トークンをhttpヘッダーに付与してリクエストをし、サーバー側でこの値を検証します。これによりCSRFに対応することができます。
Flask-JWT-Extendedの場合は「X-CSRF-TOKEN」ヘッダーにCSRF対策用トークンをセットします。
例えばaxiosを使う場合は以下のようにheadersにセットします。Cookie情報の取得はVue-cookiesを使用しています。
// 一部抜粋
const request = { hoge : fuga }
Axios
.post("/XXXXX", request, {
headers: {
"X-CSRF-TOKEN": this.$cookies.get("csrf_access_token")
}
})
.then(res => {})
.catch(error => {});
トークンのリフレッシュ方法
アクセストークンの有効期限切れの際、リフレッシュ後再度本来のAPIリクエストを行うinterceptorを作成し、APIリクエストの際は必ずこのAxios定義を使用するようにします。
以下のaxios.jsを各コンポーネント(.vue)でインポートして使用します。
以前の記事で同じような処理を書いていますが、アクセストークンの有効期限が切れた状態でほぼ同時に複数のAPIリクエストを実施すると、無駄に複数回リフレッシュ処理が実施されてしまう問題があったため以下のようになりました。海外版stackoverflowで教えていただきました。有能。
import Axios from 'axios'
import Vue from 'vue'
const http = Axios.create({
baseURL: 'https://XXXXXXXX/api/v1',
withCredentials: true,
headers: {
"Content-Type": "application/json; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest"
}
})
let refreshTokenPromise;
const getRefreshToken = () => http.post('/refresh', {}, {
withCredentials: true,
headers: { 'X-CSRF-TOKEN': Vue.$cookies.get('csrf_refresh_token') }
}).then(() => Vue.$cookies.get('csrf_access_token'))
http.interceptors.response.use(r => r, error => {
if (error.config && error.response && error.response.status === 401 && !error.config._retry) {
if (!refreshTokenPromise) {
error.config._retry = true;
refreshTokenPromise = getRefreshToken().then(token => {
refreshTokenPromise = null
return token
})
}
return refreshTokenPromise.then(token => {
error.config.headers['X-CSRF-TOKEN'] = token
return http.request(error.config)
})
}
return Promise.reject(error)
})
export default http
URL直叩き問題
正しく認証が行われていない状態でURLを直叩きされた際、機密性の高い情報(APIリクエストにより取得する情報)は閲覧されませんが、画面が見えてしまいます。問題はないと言えばないですが、嫌なので以下のように実装してそれを防ぎます。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import Axios from './axios'
Vue.prototype.$http = Axios
// routerによる画面遷移前に共通で行う処理
router.beforeEach((to, from, next) => {
// ログイン成功の情報をブラウザ情報どこかに保持しそこから値を取得する。
// 今回はtrueをべた書きしていますが、実装に合わせて変更します。
const loggingIn = true;
if (to.matched.some(page => page.meta.isPublic) || loggingIn) {
// ログイン済みの場合はそのまま次のページへ
next();
} else {
// ログイン済みでない場合はログイン画面にリダイレクト
next('/login');
}
});
new Vue({
router,
render: h => h(App)
}).$mount('#app')
import Vue from 'vue'
import Router from 'vue-router'
import Index from "./components/pages/index";
import About from "./components/pages/about";
import Login from "./components/pages/login";
import Public from "./components/pages/public";
Vue.use(Router)
export default new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'Index',
component: Index
},
{
path: "/about",
component: About
},
{
path: "/login",
component: Login,
meta: { isPublic: true }
},
{
path: "/public",
component: Public,
meta: { isPublic: true }
}
]
})
上記のようにすることで、meta情報にisPublicが設定されていないpathにログインしない状態でアクセスした場合は強制的にログイン画面にリダイレクトすることが可能です。
おわりに
セキュリティ難しい。