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


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

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

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

<iframe width="560" height="315" src="https://www.youtube.com/embed/IugtC4BMB6o" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

#### 目次
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](https://ideneal.medium.com/securing-authentication-in-a-spa-using-jwt-token-the-coolest-way-ab883bc372b6)
[JWT（JSON Web Token）でCSRF脆弱性を回避できるワケを調べてみた話 - Qiita](https://qiita.com/mejileben/items/ae7b346f0fc4a59101b5)


## 結果、こうしました（6/28 追記）

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

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

### 使用目的と保存場所

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



### ペイロード

##### アクセストークン

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

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

  ```ruby
  {
    "exp": 1624852637,
    "sub": "9/DBwzTxTOWgiary1kX5edv4gVczmRpMhG6lH3UtvKqYio...",
    "iss": "http://localhost:3000",
    "aud": "http://localhost:3000"
  }
  ```

  

##### リフレッシュトークン

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

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

  

### セッション管理（リフレッシュトークン）

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

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

**Rails console**

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



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

**api/app/services/user_auth/refresh_token.rb**

```ruby
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**

```ruby
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](https://github.com/waiting-for-dev/devise-jwt#allowlist)を参考にしています。



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

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

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

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

```ruby
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**

```ruby
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**

```ruby
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対策例](https://qiita.com/k_kind/items/e26f03f4e24551b46b98)





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

**(Nuxt)plugins/axios.js**

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



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

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

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

[体系的に学ぶ 安全なWebアプリケーションの作り方 第2版](https://www.amazon.co.jp/exec/obidos/ASIN/4797393165/tomoaki0a2-22/)



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





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



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

同時に、

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

も保存します。

**front/plugins/auth.js**

```javascript
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**

```javascript
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**

```ruby
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**

```ruby
# 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](https://github.com/nsarno/knock)を参考にしています。
>
> Knockは、何年も更新されていませんので、考え方を応用した形となります。



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

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

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



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



### サイレントリフレッシュ

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

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

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

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

> 参考: [Securing Single Page Applications with Refresh Token Rotation - Auth0](https://auth0.com/blog/securing-single-page-applications-with-refresh-token-rotation/)



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

**front/middleware/silent-refresh-token.js**

```javascript
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**

```javascript
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**

```javascript
// 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**

```ruby
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](https://auth0.com/blog/securing-single-page-applications-with-refresh-token-rotation/)





### 補足

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

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

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



### 最後に

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

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

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

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


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



※　本実装の大部分は、以下のサイトを参考にしています。
[The Ultimate Guide to handling JWTs on frontend clients (GraphQL) - HASURA](https://hasura.io/blog/best-practices-of-using-jwt-with-graphql/)