[SPA]脆弱性を潰して認証情報を扱う[アクセストークン]

最近、新規事業で完全0-1のプロダクト開発をした際に
Rails/Vue.jsのSPAでいかにセキュアに認証情報を扱うかについて調べた結果を書いていきます
「それおかしくない？」とか「脆弱性つぶせてなくない？」とかあればコメントで指摘していただけるととても助かります

# どう実装するか
実装の意図や詳細については下の方で解説します

## Rails側
- 認証情報を`secure`属性と`httponly`属性をつけたcookieでフロントエンドに返す
  - domain属性はAPIのドメインのみにする（特に設定変えなければデフォルトでそうなってるはず)
- 独自ヘッダの有無のチェックを認証処理に組みこむ
- gem`rack-cors`を導入する
  - originsをSPAのURLのみに制限する
  - `credentials`を`true`にしておく

## Vue.js側
- `withCredentials`オプションを`true`に設定する(axiosやxhr)
  - fetchなら`credentials: 'include'`とかかな
- リクエストに独自ヘッダをつける
  - なんでもいいし中身も空文字でよくて、ヘッダがあることが重要

# なぜ上記のような実装をするのか
以下の3つがポイントになります

- `httponly`属性と`secure`属性が有効で`domain`属性にはAPIのドメインのみを設定したcookieでアクセストークンを持たせる
- 独自ヘッダが必要であること
- corsでoriginsを制限されていること


## cookieについて
### なぜhttponlyにするのか
- XSSや悪意あるパッケージなどのjsによりcookieにあるトークンを抜かれないようにするため
  - jsから抜かれる危険性はlocalstorageを使わない理由でもある
- 代わりに、cookieにあるトークンを非同期通信でもリクエストするために`withCredentials`オプションを有効にする

### なぜsecureにするのか
- 非SSLのリクエストを盗聴されないように、とかっていうよくある理由

### なぜdomainをAPIのドメインのみに制限するのか
- サブドメをワイルドカードにしたりとか他のドメインを許容する必要がないため
  - 少なくとも今回のケースでは複数ドメインをまたいでcookieの共有は必要なかった
  - withCredentialを有効にしてcookieとして飛ばすからフロントエンドのjsからcookieを操作できる必要はない
     - フロントのurlからはcookie見えないけどAPIのドメインへのcookieとしてちゃんと存在しているのでwithCredential有効にしておけばちゃんとリクエストされる
- ワイルドカードを入れようものならXSSで攻撃者のサーバにリクエストされたりした時にcookieが一緒に飛んでしまう
- 恥ずかしながら自分は最初「フロントのドメインでも扱えなきゃいけないよなぁ」と思い込んでいました。。。

## 独自ヘッダについて
- htmlのformによるCSRF対策
  - htmlのform(同期通信)では独自ヘッダを付与することはできないため

## CORSについて
- 独自ヘッダと併用してプリフライトリクエストを強制することでjsによるCSRF対策となる
  - originsでちゃんと制限かけることで外からの不正なリクエストを弾く
     - ただし、それだけでは不十分で、実際はブラウザ上ではエラーになるがリクエスト自体は飛んで処理されてしまうので「弾く」という表現は誤解を招く
     - レスポンスを見せないので不正なGETには有効だが単純リクエストのPOSTは処理されてしまうので困る
  - POSTは条件次第(formDataを使うこと)で単純リクエストになり、プリフライトリクエストが走らない場合があるため独自ヘッダによりプリフライトを強制させる
     - これにより外部サイトからの不正なリクエストをすべてプリフライトリクエストで止める事ができる
- 独自ヘッダは2度美味しい
- ちなみに、rack-corsの設定で`credentials: true`にしないとフロントで`withCredential: true`のリクエストを弾いてしまう

# 実装のサンプル
一部抜粋して書いていきます
参考記事などは下の方にまとめてます

## rails側
### rack-corsなどの設定
```rb:config/application.rb
# ...割愛

    # apiモードのrailsでcookiesを使えるようにするため
    # コントローラ側に `include ActionController::Cookies` も必要
    config.middleware.use ActionDispatch::Cookies

    config.middleware.insert_before 0, Rack::Cors do
      allow do
        # 複数のorigins制限するためドメインの配列
        # 環境変数はjsonにすると配列が簡単に扱えるやんという最近見つけたtips
        origins JSON.parse(ENV.fetch('CORS_DOMAINS_JSON') { '[]' })
        resource "*",
          # axiosなどで `withCredential: true` にした上で、そのcookieを受け取るため
          credentials: true,

          headers: :any,
          methods: [:get, :post, :patch, :delete]
      end
    end

# ...割愛
```

### 認証情報をcookieで返す
- アクセストークンの期限はDBにあるので`permanent`
- 開発環境はsecure外したいので環境変数`COOKIE_SECURE`で操作

```rb
# ...割愛

cookies.permanent[:access_token] = {
  value: access_token,
  httponly: true,
  secure: ENV['COOKIE_SECURE'].present?,
}
cookies.permanent[:refresh_token] = {
  value: refresh_token,
  httponly: true,
  secure: ENV['COOKIE_SECURE'].present?,
}

# ...割愛
```

### 認証処理
```rb
access_token = cookies[:access_token]

# ...割愛(access_tokenチェックの処理など)

raise 適当な例外クラス if request.headers[:'X-REQUESTED-BY-MY-APP'].nil?
```

## Vue.js側
### withCredentialsを有効にする
```js:src/main.js
// ...割愛

Vue.prototype.$http = axios.create(
  { baseURL: process.env.VUE_APP_API_URI, withCredentials: true },
);

// ...割愛
```

### 独自ヘッダをつける
- Vue.prototype.$httpにインスタンス入れることでどこからでも`this.$http`で使える
- interceptorsを使うことですべてのリクエストでヘッダ設定の処理を走らせる

```js:src/main.js
// ...割愛

Vue.prototype.$http.interceptors.request.use((request) => ({
  ...request,
  headers: {
    ...request.headers,
    ...{ 'X-REQUESTED-BY-MY-APP': '' },
  },
}));


// ...割愛
```

# 疑問
XSS脆弱性があっても認証情報を抜かれないための対策とか、外からのCSRFの対策はしたけど
XSSでXHRのコードをインジェクトされて外部ではなく本来のバックエンドに勝手にリクエストを飛ばされないようにするための対策ってどうすればいいんだろう。。。
根本的にXSSを塞ぐしかないってことになるのかな


# 参考記事
[rails-apiでcookieを使う](https://pocke.hatenablog.com/entry/2016/06/23/193232)
[RailsでAPIにCORSを設定する](https://qiita.com/yumikokh/items/b5fd604e12720027b4d5#rack-cors)
[rack-corsでCORS設定をする](https://techblog.lclco.com/entry/2018/09/30/200122)
[さよならCSRF(?) 2017](https://www.scutum.jp/information/waf_tech_blog/2017/11/waf-blog-051.html)
[CORS: OPTIONSリクエスト（preflight request）を避ける](https://qiita.com/nnishimura/items/1f156f05b26a5bce3672#%E5%8D%98%E7%B4%94%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%81%A8%E3%81%AF)