LoginSignup
7
10

More than 1 year has passed since last update.

XSSとCSRFに対応するJWTを使ったSPAのログイン認証のベストプラクティスってこれじゃない?

Last updated at Posted at 2021-06-18

すみません、これじゃダメでした

以下、コメント欄抜粋です

元記事のコメントでも指摘されていますが、XSSによるJavaScriptから、LocalStorageの値を取得した上で、XMLHttpRequestあるいはFetch APIによるリクエストを送信すれば、HttpOnly属性つきのクッキーでも自動的に送信されます。なのでXSSによる攻撃を防ぐことはできません。そもそも、XSSによるJavaScript実行は正規のJavaScriptと同じ条件(Origin)で動作するため、ご提案の方法はXSSの対策にはなりません。

これでいかがでしょう..?

目次

Nuxt.jsとRailsAPIを想定した実装例ですが、考え方は応用できます。
00:00 ログイン認証の流れ
01:00 フロントエンドで考えるべき2つの攻撃
02:24 XSS攻撃への対応
03:11 CSRF攻撃への対応
04:05 XSS対応とCSRF対応のジレンマ
05:15 XSS対応とCSRF対応のジレンマまとめ
05:52 このアプリのJWT保存先
06:30 リクエスト時のユーザー認証
07:15 ログインしたままにする実装
08:08 ログインしたままにする実装メリット
08:36 ここまで説明を実際のアプリケーションで確認しよう
11:21 XSS攻撃でCSRFトークンが盗まれた時
12:52 今回の実装でXSS攻撃が行われた時のリスク
13:34 JWTを使ったログイン認証のまとめ

参考サイト

Securing Authentication in a SPA Using JWT Token — The coolest way - Medium
JWT(JSON Web Token)でCSRF脆弱性を回避できるワケを調べてみた話 - Qiita

結果、こうしました(6/28 追記)

アクセストークンとリフレッシュトークンを使用するようにしました。

  • 前提
    • フロント: Nuxt
    • サーバ: RailsAPI(認証、認可は同一サーバ)
    • JWTGem: ruby-jwt

使用目的と保存場所

  • アクセストークン
    • 使用目的: 保護リソースへアクセスするためのJWT
    • 有効期限: 30分
    • 保存場所: Vuex
    • アルゴリズム: HS256
  • リフレッシュトークン
    • 使用目的: アクセストークンを発行するためのJWT
    • 有効期限: 24時間
    • 保存場所: Cookie(HttpOnly, Secure)
    • アルゴリズム: HS256

ペイロード

アクセストークン

ペイロードには次のデータを持たせました。

subクレームには、暗号化したUserIDが入っています。

  {
    "exp": 1624852637,
    "sub": "9/DBwzTxTOWgiary1kX5edv4gVczmRpMhG6lH3UtvKqYio...",
    "iss": "http://localhost:3000",
    "aud": "http://localhost:3000"
  }
リフレッシュトークン

ペイロードには次のデータを持たせました。

  {
    "exp": 1624939026,
    "sub": "oN1CrX2MVUHRB3IDFZDvgZqwVRQXcLcZ6hCLcKzcJl2uvU...",
    "jti": "69d11a39611b561f364c983ef7685a0e"
  }

セッション管理(リフレッシュトークン)

リフレッシュトークンのjwt_idをUsersテーブルに保存することで、セッション管理を実現しました。

これは、従来のセッション管理と同じです。

Rails console

> User.first
+----+------+-------------+-------------+
| id | name | email       | refresh_jti |
+----+------+-------------+-------------+
| 1  | user | user0@ex... | 69d11a39... |
+----+------+-------------+-------------+

jwt_idは、リフレッシュトークンの発行と同時に、Usersテーブルに保存しています。

api/app/services/user_auth/refresh_token.rb

require 'jwt'

module UserAuth
  class RefreshToken

    def initialize(user_id: nil, token: nil)
      ...
      remember_jti(user_id)
    end

    private

      # jtiをユーザーに保存する
      def remember_jti(user_id)
        User.find(user_id).remember(payload_jti)
      end

  end
end

リフレッシュトークンのデコード時には、「トークンのjwt_idが、Usersテーブルのjwt_idと一致するか」で正しいトークンを判定します。

これにより、「過去に発行された有効期限内のリフレッシュトークン」を実質無効にすることができます。

api/app/services/user_auth/refresh_token.rb

require 'jwt'

module UserAuth
  class RefreshToken
    ...

    # デコード時のjwt_idを検証する
    def verify_jti?(jti, payload)
      user_id = get_user_id_from(payload)
      user = entity_for_user(user_id)
      user.refresh_jti == jti
    rescue UserAuth.not_found_exception_class
      false
    end

    # デコードオプション
    def verify_claims
      {
        verify_expiration: true,           # 有効期限の検証するか(必須)
        verify_jti: proc { |jti, payload|  # jtiとセッションIDの検証
          verify_jti?(jti, payload)
        },
        algorithm: algorithm               # decode時のアルゴリズム
      }
    end
  end
end

この実装は、Gem device-jwtを参考にしています。

セッションコントローラー

create、refresh、destroyの3つのアクションを用意しました。

ログイン、リフレッシュ、ログアウト用のアクションとなります。

api/app/controllers/api/v1/auth_token_controller.rb

class Api::V1::AuthTokenController < ApplicationController
  include UserSessionizeService

  # userのログイン情報を確認する
  before_action :authenticate, only: [:create]
  # session_userを取得する
  before_action :sessionize_user, only: [:refresh, :destroy]

  # ログイン
  def create
    @user = login_user
    set_refresh_token_to_cookie
    render json: login_response
  end

  # リフレッシュ
  def refresh
    @user = session_user
    set_refresh_token_to_cookie
    render json: login_response
  end

  # ログアウト
  def destroy
    delete_session if session_user.forget
    cookies[session_key].nil? ?
      head(:ok) : response_500("Could not delete session")
  end

  private
        ...
end
  • ログイン(create)
    1. emailとpasswordでユーザーを取得
    2. Cookieにリフレッシュトークンをセット
    3. レスポンスにアクセストークンとUserオブジェクトを返す
  • リフレッシュ(refresh)
    1. リフレッシュトークンからユーザーを取得
    2. Cookieにリフレッシュトークンを新しいセット
    3. レスポンスに新しいアクセストークンとUserオブジェクトを返す
  • ログアウト(destroy)
    1. リフレッシュトークンからユーザーを取得
    2. Usersテーブルのjtiを削除する
    3. Cookieを削除する
    4. レスポンス200を返す

対CSRF

リフレッシュトークンを使用することでCSRF対応はおおよそできたかと思います。

  • リフレッシュトークンが使えるのはセッションコントローラーだけ
  • レスポンスは新しいトークンのみ
  • CSRFは別オリジンでの攻撃のため、このレスポンスを受け取れない

ただこれは、CORSの設定がちゃんとされている前提の話です。

ここがガバガバだと、CSRFでリフレッシュトークンとアクセストークンが受け取れます。

受け取ったトークンを使えば、本人になりすまされる恐ろしい現象が起こります。

RailsAPIでCORSの設定を行うには、Gem crosを使用します。

config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins ENV["API_DOMAIN"]

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],

    credentials: true
  end
end

保険を打って、XMLHttpRequestしか受け付けないチェックもしています。

api/app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  ...
  # CSRF対策
  before_action :xhr_request?

  private

    # XMLHttpRequestでない場合は403エラーを返す
    def xhr_request?
      return if request.xhr?
      render status: :forbidden, json: { status: 403, error: 'Forbidden' }
    end

end

参考: 【Rails API + SPA】ログイン方法とCSRF対策例

サーバ側でこのチェックを行うと、フロントではカスタムリクエストヘッダを追加しなければなりません。

(Nuxt)plugins/axios.js

export default ({ $axios, $auth }) => {
  // リクエスト
  $axios.onRequest((config) => {
    config.headers.common['X-Requested-With'] = 'XMLHttpRequest'
  })
  ...
}

クロスオリジンによるリクエストで、カスタムリクエストヘッダが許可されるのは、CORSで許可されたドメインのみです。

上記のコードで言うENV["API_DOMAIN"]だけが、カスタムリクエストヘッダでのリクエストを行えます。

この辺の仕組みは、下記書籍の85P, 416Pあたりに丁寧に解説されています。

体系的に学ぶ 安全なWebアプリケーションの作り方 第2版

ただこれも、CORS設定がちゃんとなされている上でのCSRF対策です。

フロントエンドのアクセストークン

アクセストークンを受け取ったNuxt.jsはVuexに保存します。

同時に、

  • アクセストークンの有効期限
  • Userオブジェクト
  • アクセストークンをデコードしたpayloadの情報

も保存します。

front/plugins/auth.js

import jwtDecode from 'jwt-decode'

class Authentication {
  ...
  setAuth ({ token, expires, user }) {
    const exp = (expires > 0) ? expires * 1000 : expires
    const jwtPayload = (token) ? jwtDecode(token) : {}
    this.store.dispatch('getAuthToken', token)
    this.store.dispatch('getAuthExpires', exp)
    this.store.dispatch('getCurrentUser', user)
    this.store.dispatch('getAuthPayload', jwtPayload)
  }
    ...

}

export default ({ store, $axios }, inject) => {
  inject('auth', new Authentication({ store, $axios }))
}

NuxtはVuexにトークンが存在する場合に、そのトークンをリクエストヘッダに付与します。

front/plugins/axios.js

export default ({ $axios, $auth }) => {
  // リクエスト
  $axios.onRequest((config) => {
    ...
    if ($auth.token) {
      config.headers.common.Authorization = `Bearer ${$auth.token}`
    }
  })
}

サーバーサイドのアクセストークン

リクエストを受け取るRailsでは、

  • アクセストークンが正しければcurrent_user返し、
  • 不正なトークンの場合は401を返します。

これにより、リソースを保護します。

(Rails)api/app/controllers/api/v1/projects_controller.rb

class Api::V1::ProjectsController < ApplicationController
  before_action :authenticate_user

  def index
    render json: current_user.projects
  end
end

authenticate_userメソッドの実態はこのようになります。

(Rails)api/app/services/user_authenticate_service.rb

# api/app/services/user_authenticate_service.rb
module UserAuthenticateService

  # 認証済みのユーザーが居ればtrue、存在しない場合は401を返す
  def authenticate_user
    current_user.present? || unauthorized_user
  end

  private

    # リクエストヘッダートークンを取得する
    def token_from_request_headers
      request.headers["Authorization"]&.split&.last
    end

    # access_tokenから有効なユーザーを取得する
    def fetch_user_from_access_token
      User.from_access_token(token_from_request_headers)
    rescue UserAuth.not_found_exception_class,
           JWT::DecodeError, JWT::EncodeError
      nil
    end

    # tokenのユーザーを返す
    def current_user
      return nil unless token_from_request_headers
      @_current_user ||= fetch_user_from_access_token
    end

    # 認証エラー
    def unauthorized_user
      cookies.delete(UserAuth.session_key)
      head(:unauthorized)
    end
end

この実装は、Gem Knockを参考にしています。

Knockは、何年も更新されていませんので、考え方を応用した形となります。

このモジュールは、コントローラー全体で使用するので、application_controllerで読み込んでいます。

(Rails)api/app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include UserAuthenticateService    # 認可を行う
  ...
end

これで、アクセストークンを使って保護リソースにアクセスできるようになりました。

サイレントリフレッシュ

アクセストークンの有効期限は30分です。

それではユーザーは、30分毎にログインが必要でしょうか?

いいえ、アクセストークンの有効期限が切れたらリフレッシュトークンを使って、アクセストークンを再発行します。

従来は非表示のiframeを使って暗黙的にリクエストを行なっていたようですが、ここ最近のブラウザの仕様変更により、実現できなくなったようです。

参考: Securing Single Page Applications with Refresh Token Rotation - Auth0

そもそもiframeを使った実装など今の私には到底できないので、グローバルミドルウェアを使用することにしました。

front/middleware/silent-refresh-token.js

export default async ({ $auth, $axios, store, route, redirect }) => {
  if ($auth.isExistUserAndExpired()) {
    await $axios.$get('/api/v1/auth_token/refresh')
      .then(response => $auth.login(response))
      .catch(() => {
        const msg = 'セッションの有効期限が切れました。' +
                    'もう一度ログインしてください'
        store.dispatch('getToast', { msg })
        ...
        return redirect('/login')
      })
  }
}

このグローバルミドルウェアは、

  • Userが存在し、かつ有効期限が切れたとき

に発火します。

ユーザーの存在定義は、

  • Userオブジェクトのsubjectと、アクセストークンpayloadのsubjectが一致している

場合に「存在している」と判定しています。

front/plugins/auth.js

class Authentication {
  ...
  // ユーザーが存在している場合はtrueを返す
  isExistUser () {
    return this.user.sub && this.payload.sub && this.user.sub === this.payload.sub
  }

  // ユーザーが存在し、かつ有効期限が切れの場合にtrueを返す
  isExistUserAndExpired () {
    return this.isExistUser() && !this.isAuthenticated()
  }
  ...
}

これにより、アクセストークンの有効期限が切れても、新しいアクセストークンで保護リソースにアクセスできるようになりました。

リロード対応

ユーザーがブラウザをリロードすると、Vuexの値は全て初期値に戻されます。

このままでは、ログインした後にリロードが行われると、フロントではログアウト状態とみなされ、再度ログインしなければなりません。

そこで、ユーザーがサイトに訪問する都度Railsのrefreshアクションにリクエストを行います。

このリクエストは、初めてサイトに訪れたときだけ行えば良いので、pluginsディレクトリ内で実装します。

front/plugins/nuxt-client-init.js

// client初期設定ファイル
export default async ({ $auth, $axios }) => {
  await $axios.$get(
    '/api/v1/auth_token/refresh',
    { validateStatus: status => $auth.resolveUnauthorized(status) }
  )
    .then(response => $auth.login(response))
}

これにより、リロードしてもログイン状態を維持できるようになりました。

対XSS

アクセストークンをメモリ(Vuex)に保管することにより、保管されっぱなしの状態を回避することができました。

Cookieやローカルストレージより、若干のリスク回避はできたかと思います。

ただこれはアクセストークンの盗み出しのだけを考えた場合の話です。

リフレッシュトークンが盗まれたときは恐ろしい

コメント欄のYoutubeであるように、HttpOnlyオプションを付けたCookieもXSSで盗まれます。

リフレッシュトークンが盗まれると、

  • 攻撃者は、
  • 24時間以内に、
  • 有効なリフレッシュトークンを使って、
  • サーバにリクエストを行うことで、
  • 新しいアクセストークンとリフレッシュトークンを取得できます。

これは、次の24時間も同じことを繰り返せば、また新しいトークンを取得できます。

セッションハイジャックへの対応

上記セッションハイジャックが起こった場合は、以下の2つで対応できます。

  • 正規のユーザーがログインし、Usersテーブルのjwt_idを書き換える
  • サーバ管理者が、Usersテーブルのjwt_idを意図的にnilにする

しかし、ユーザーが長らくサイトに訪れていない、もしくはサーバ管理者が気づかなかった場合は、攻撃者は永遠に成りすませます。

この「永遠に」だけを回避するのであれば、新しいリフレッシュトークンを発行しなければOKです。

api/app/controllers/api/v1/auth_token_controller.rb

class Api::V1::AuthTokenController < ApplicationController
  ...
  # リフレッシュ
  def refresh
    @user = session_user
    # set_refresh_token_to_cookie ここをコメントアウト
    render json: login_response
  end
    ...
end

上記実装により、正規のユーザーには「24時間に1回ログインする」と言う制約が課せられました。

と同時に、「永遠になりすまされる」リスクも無くなりました。

攻撃者が個人情報の取得が目的なら?

ただ、攻撃者が個人情報の取得が目的だった場合はどうでしょうか?

24時間もあれば、情報を隅から隅まで抜き取ることができますね。

じゃあ、リフレッシュトークンの期限を短くすれば良いのでしょうか。

それでは、アクセストークンと変わりなくなります。

対XSSへの結論

結果、「リフレッシュトークンを使わなければ良い」と言う結論に至りました。

つまり「ログインしたままにする」実装を捨てることで、セッションハイジャックのリスクも無くなります。

セッションがなくなるので当たり前っちゃ当たり前ですが。。。

ちなみにAuth0では、JWTの再利用をサーバで検知しているようです。

この実装は、そもそもが私の考え方とは違うもので、よく理解できませんでした。

参考: リフレッシュトークンローテーションによるシングルページアプリケーションの保護 - Auth0

補足

XSS攻撃は同じオリジンで、かつ同じJavaScriptが稼働するので、メモリの情報も盗まれまるようです。

「Cookieやローカルストレージより、若干のリスク回避はできたかと思います。」

この一文は、ただの気休めなのかもしれません。

最後に

本実装は、趣味グラマーによるものです。

コードを用いる場合は、お近くの有識者にご確認ください。

私の近くには有識者が居ないため、ネットと個人の考えを基にした実装となります。

あ、お一人だけ、セキュリティの有識者の方が無料で相談に乗ってくださいました。
その方の教えがないと、本実装はできませんでした。

聞ける環境って本当に素晴らしい!

※ 本実装の大部分は、以下のサイトを参考にしています。
The Ultimate Guide to handling JWTs on frontend clients (GraphQL) - HASURA

7
10
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
10