Web備忘録

雑多に書いています

SPAでのログイン認証とCSRF対策の実装(JWT使用)

SPA(Vue + RailsAPI)で何とかログイン認証機能 + CSRF対策を実装したので、ブログにメモしておきます。

実装の概要

今回は、JWT + (WebStorage + Cookie)を使って実装しました。(後に用語説明します)

WebStorageとJWTによるセッションの管理(ログイン状態の管理)は、セキュリティ的な難点を指摘する記事がちらほらありましたが、クッキーも利用して「アクセストークン」と「リフレッシュトークン」の2つを利用するライブラリを採用し、XSS脆弱性のリスクを下げました。あとはそのライブラリにCSRFトークンも発行も担保してもらうことで、CSRF脆弱性にも対応しています。

ただし、この方法がSPAの認証として確実にベターなのかは定かではありません。「あくまでもこういう実装があるよ」という風に捉えていただければ幸いです。

あと、今回は「リフレッシュトークン」自体は発行せず、「アクセストークン」にその機能をもたせた形で実装しています。

基本的に、既存のライブラリの教えに従った実装なので、詳しくはそちらを参考にどうぞ。

JWTSession: GitHub - tuwukee/jwt_sessions: XSS/CSRF safe JWT auth designed for SPA

自分が詰まったところをメインで文章にしておこうと思います。

使用した技術たち

JWT(JsonWebToken)

改ざんされず、URLSafe(URLに含めることができる文字列のみで構成されている)JSON形式のトークンです。

Base64エンコードされているだけなので、鍵情報なしに復号可能です。

# JWTをデコードすると…
Base64.decode64('"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1NjI5NDUzMzAsInVzZXJfaWQiOjczLCJ1aWQiOiI1M2U4ODdlMy0zYTE4LTQyNzgtYTU0MS1jMDMwNzA4OTUwMDEiLCJleHAiOjE1NjI5NDUzMzAsInJ1aWQiOiI4Yjg3MjFlYS1kZGNmLTQ3NTgtYTUwYi1iMDk0ZDhkODU5MWIifQ._ygcLPv8UiGAX3oNlomGmOKFfRQYDJZRjZiAYPfDSrk"')

# algで、改ざん防止のための署名を暗号化するアルゴリズムを指定します。あとはユーザー情報だったり有効期限だったりも内包してます。
=> "{\"alg\":\"HS256\"}{\"exp\":1562945330,\"user_id\":73,\"uid\":\"53e887e3-3a18-4278-a541-c03070895001\",\"exp\":1562945330,\"ruid\":\"8b8721ea-ddcf-4758-a50b-b094d8d8591b\"}\f\xA0p\xB3\xEF\xF1H\x86\x01}\xE86Z&\x1Ac\x8A\x15\xF4P`2YF6b\x01\x83\xDF\r*\xE4"

参考記事: JWT(JSON Web Token)の仕組みと使い方まとめ │ Web備忘録

アクセストークン、リフレッシュトークンって?

セッション機能を担保するためのJWTタイプのトークンです。アクセストークンは短時間で期限が切れるトークンで、リフレッシュトークンはその期限の切れたアクセストークンのかわりに新しいアクセストークンを発行するためのトークンです。当然ながらリフレッシュトークンの方が期限が長いです。どちらも、トークン自体にデータを内包しています。

WebStorage

ブラウザのデータストアです。4MBまで保存できるのが魅力です。Chromeデベロッパーツールでいう「Applicationタブ」で確認できます。

SessionStorageとLocalStorageがありますが、今回はCSRFトークンを保存するために後者を使ってます。このCSRFトークンはJWTではありません。

JWTSession

トークン作成・管理を担ってくれるライブラリ。

GitHub - tuwukee/jwt_sessions: XSS/CSRF safe JWT auth designed for SPA

遷移の概要(より正確な内容は要Gem参照)

クライアント側でユーザーがパスワード等の認証情報を入力し、ログインのためのリクエストを送る。

バックエンド側がその内容で認証に成功したら、「HttpOnly(JSで操作不能)でSecure(HTTPs通信のみで取引)なクッキーとしてのアクセストークン」と、「POST、PUTリクエスト等で使うCSRFトークン(LocalStorageで保存)」をレスポンスする。アクセストークンは、「ユーザー情報」を内包している。

その受け取ったトークンたちをクライアントサイドで保存し、各リクエストに応じてそれらを送信することで、「ログインしているユーザー情報」をセッション管理しつつ、CSRFXSS対策を行っている。

トークンストアの設定

Redisを使っています。Docker上で運用していて、下記のようにdocker-compose.ymlファイルを作ってます。

version: '3.3'
services:
  app:
    build:
      context: .
    stdin_open: true
    tty: true
    ports:
      - '3000:3000'
    volumes:
      - .:/app
      - bundle_data:/usr/local/bundle
    depends_on:
      - db
      - redis
    environment:
      - REDIS_HOST=redis
      - REDIS_PORT=6379
  db:
    image: postgres:11.2
    ports:
      - '5432:5432'
    volumes:
      - postgresql_data:/var/lib/postgresql/data
  redis:
    image: redis:3.2.12-alpine
    ports:
    - 6379:6379
    volumes:
    - redis:/data
  
volumes:
  bundle_data:
    driver: local
  postgresql_data:
    driver: local
  redis:

今回使ったGem(JWTSession)は、環境変数にREDIS_URLをセットしておけばその場所をトークンストアとして使用します。

ローカルでは、REDIS_URL=redis://redis:6379、本番では実際のURLを指定しています。本番環境はherokuで運用してるのですが、REDISプラグインを導入すると自動で環境変数REDIS_URLをセットした気がします。(しなかった場合は、自分でセットしましょう)

JWT暗号化のアルゴリズムのための種は環境変数でセットしておきましょう。

(config/initializers/jwt_sessions.rb)

if Rails.env.production?
  JWTSessions.encryption_key = ENV['JWT_SESSION_KEY']
else
  JWTSessions.encryption_key = 'secret'
end

バックエンド側の仕事

トークン発行と管理を行います。

Signinコントローラー

以下は、説明コメント付きのSigninコントローラーです。ここで認証を行います。

class SigninController < ApplicationController
  # リクエストで送られたトークンの内容の検証し、検証失敗で例外を出す。(only: destroy(セッション破棄時))
  before_action :authorize_access_request!, only: [:destroy]

  # create は ユーザー作成ではなく、「トークン作成(セッション作成)」
  def create
    user = User.find_by(email: params[:email])
    if user&.authenticate(params[:password])
      # payloadは、トークン自体に内包させられるユーザー情報。ここではuser_idを内包させている。
      payload = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login

      # このメソッドで、「Set-Cookieヘッダー」に、{jwt_access=アクセストークン; Secure; HttpOnly}をセットし、クライアントに送る。
      response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        # 開発環境ではHTTPs通信ではないので、secure: falseになるようにしないと、クライアントがクッキーを受け取れない
                        secure: Rails.env.production?)
      # LocalStorageに保存するためのcsrfトークンを返しておく。
      render json: { csrf: tokens[:csrf] }
    else
      not_found
    end
  end

  def destroy
    session = JWTSessions::Session.new(payload: payload)
    session.flush_by_access_payload
    render json: :ok
  end

  private

    def not_found
      render json: { error: "メールアドレスとパスワードの組み合わせが見つかりません"}, status: :not_found
    end
    
end

フロントエンド側の仕事

アクセストークンに関してはクッキーに保存しているので、とくに設定を行わなくてもバックエンド側と自動でやり取りします。(クッキーはリクエストで常に送信されるため。)

CSRFトークンの使用に関しては、明示的に設定を記述する必要があります。

axiosから作ったインスタンス(非同期通信時に使用する)に、その設定を書き、POSTやPUTリクエストのさい、CSRFトークンを遅れるように設定しておきましょう!

axiosのインスタンスの作成

(axiosのインスタンスを作成し、exportしたものをグローバルで使う。)

import axios from 'axios'

// インスタンスを作る
const securedAxiosInstance = axios.create({
  baseURL: process.env.VUE_APP_API_URL,
  // クッキーといった認証情報を送るためにCredentialsはtrueにする
  withCredentials: true,
  headers: {
  // もちろん送信するのはJSON
    'Content-Type': 'application/json'
  }
})

// リクエストのとき、動的にヘッダーにCSRFトークンをセットする
securedAxiosInstance.interceptors.request.use(config => {
  const method = config.method.toUpperCase()
  if (method !== 'OPTIONS' && method !== 'GET') {
    config.headers = {
      ...config.headers,
      // localStorageの値をセット!
      'X-CSRF-TOKEN': localStorage.csrf
    }
  }
  return config
})


// レスポンスで401エラーが帰ってきたとき、トークンの再発行を依頼する。
securedAxiosInstance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (error.response && error.response.config && error.response.status === 401) {
      return plainAxiosInstance.post('/refresh', {}, { headers: { 'X-CSRF-TOKEN': localStorage.csrf } })
        .then(response => {
          localStorage.csrf = response.data.csrf
          localStorage.signedIn = true

          let retryConfig = error.response.config
          retryConfig.headers['X-CSRF-TOKEN'] = localStorage.csrf
          return plainAxiosInstance.request(retryConfig)
        }).catch(error => {
          console.log('failed refresh')
          delete localStorage.csrf
          delete localStorage.signedIn

          location.replace('/signin')
          return Promise.reject(error)
        })
    } else {
      return Promise.reject(error)
    }
  }
)

// exportしておこう
export { securedAxiosInstance }

main.jsでインスタンスの読み込み

main.jsで、そのインスタンスを読み込み、どのコンポーネントでも使用できるようグローバルにする。

Vue.use(VueAxios, {
  secured: securedAxiosInstance,
})

インスタンスの使用方法

signin () {
  this.$http.secured.post('/signin', {email: this.email, password: this.password})
    .then(response => this.signinSuccesful(response))
    .catch(error => this.signinFailed(error))
}

これでフロント側は大丈夫です。

ただ、ブラウザによって上手く動作しないものが…

ただ、こうやっても自分の場合、本番環境のSafariiOS版のChromeでログイン認証が上手くいかない、といった苦労がありました。

JWTSessionについてググりまくったり、ActionDispatchの挙動について調べたりして原因を探ったところ、どうやら、先に挙げたブラウザは、バックエンドが返したレスポンスに含まれる「「Set-Cookieヘッダー」の {jwt_access=アクセストークン; Secure; HttpOnly} を読み込めておらず、ブラウザにクッキーが保存できていない、ということがわかりました。

(サラッと書きましたがこの結論に辿り着くまで結構かかりました(涙目))

そしてクッキーが保存できない原因は、PC版Chrome以外は「3rd party cookiesを受け取らない」という設定がデフォルトであった、ということであると判明しました。

3rd party cookiesとは?

3rd party cookiesというのは、そのサイトの運営者以外のドメインが送信するクッキーのことです。たとえば誰かのブログを見てたりすると、「身につけるだけで腹筋が割れるベルトが新発売」みたいな広告が右端に表示されてることがありますが、そういう第三者の広告がブラウザにクッキーを保存することを防ぐために、PC版Chrome以外は3rd party cookieの受け取りを禁止しているようでした。

(ChromeGoogle Analyticsなどを使用して、ドメインに基づかずとも、そういうクッキーを弾いているみたいです。)

原因がわかったので、VueアプリとRailsAPIのドメインを共通化すると解決しました。(domain.comとwww.domain.comに、それぞれアプリを配置し、メインドメインサブドメインの関係にした)

余談

かなり難しかった…。でも、おかげで知識量は増えた気がします。

ただ、今回のクッキーに関するエラーって、先に「3rd party cookieの取扱い」に知っていれば、もっと早く原因わかったかもな、と思いました。

自分の場合、そもそもこういうエラーを前にして初めてそういう「3rd party cookie」概念があることを知ったのですが、そういった風に「実際にエラーが出てからそのエラーの根本原因について調べる」ような実装をしていると、めちゃくちゃ時間吸われるんですよね。(検索ワードが「クッキー 受け取れない safari」とか「set-cokkie not working」とかぼんやりしたものになって、なかなか欲しい情報にたどり着けない)

もちろん、そのおかげで色々知識は身につくのですが、回り道してる感も否めないので、広く浅く知識の網は広げておいて、エラーが起きたときにすぐ適切なワードでググれるようになれば、時間の節約になりそうです。