Web備忘録

雑多に書いています

Ruby on Railsで分間100万リクエストを捌くコードの書き方

Rails効率化に関する記事を読んだので、ポイントだけまとめておこうと思います。

engineering.shopify.com

ActiveRecordのパフォーマンス改善

SQLが実行されるタイミングを知る

たとえば下のようなコードは、すぐにクエリが発行されてしまう。

post.comments << user.comments.build

#    (0.1ms)  begin transaction
#  Comment Create (0.4ms)
#  INSERT INTO "comments" ("created_at", "updated_at", "post_id", "user_id")
#  VALUES (?, ?, ?, ?)  [["created_at", "2019-07-11 22:16:43.818858"], ["updated_at", "2019-07-11 22:16:43.818858"], ["post_id", 1], ["user_id", 2]]

予期せぬクエリ発行はパフォーマンス低下につながるので、クエリ発行のタイミングにはいつも注意しよう。

いつ発行されるか知るためには、経験を積んで、あとドキュメントとかいっぱい読もう、とのこと。

出来るだけ少なく選択する

Blog.select(:id)
#   Blog Load (0.2ms)  SELECT "blogs"."id" FROM "blogs" LIMIT
# => #<ActiveRecord::Relation [#<Blog id: 1>, #<Blog id: 2>, #<Blog id: 3>, #<Blog id: 4>, ...]>

Blog.pluck(:id)
#   (1.2ms)  SELECT "blogs"."id" FROM "blogs"
# => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

このようにidだけほしいならselectよりpluck。ActiveRecordオブジェクトを作るコストが減る。

クエリキャッシュを忘れる

Railsは一つのリクエストで同じクエリを発行するとき、その内容を自動的にキャッシュする機能がある。

Blog Load (0.2ms)  SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
CACHE Blog Load (0.0ms)  SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
CACHE Blog Load (0.0ms)  SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]

でも、これは一つのリクエストの話。リクエストをまたがってクエリを出したとしたら、この機能はべつに効率的に働かないので頼りすぎないこと。

インデックスのないカラムにSQLを打つことを避ける

インデックスなしのカラムを検索しようとすると、「フルテーブルスキャン(前表スキャン)」になって遅い。

だからよく検索で使うカラムにはインデックスをつけるのが大事。ただ、「ALTER TABLE」で後からインデックスを付けようとすると、何百万レコードとあるテーブルだった場合、半端ではない遅さになるそう。あとDBがロックされてその間書き込みできないらしい(このへんは自分のDBへの知識不足で、詳細不明)

なので俺達のチームでは自作Gemを使ってるぜ! とのことです。

https://github.com/shopify/lhm

Railsのパフォーマンス改善

全てをキャッシュする

Rals.cache.fetchメソッドを使うことで、「キーとバリューの組み合わせ」を、キャッシュとして保存できる(たぶんRedisとかに)。

もう一度fetchすれば、クエリを発行せずにキャッシュからデータを引っ張ってこれる。

expires_inで、有効期限の設定も可能。

Rails.cache.fetch("plan_names") do
  Subscription::Plan.all.map(&:name)
end

Rails.cache.fetch("blog_#{blog.id}_posts_#{posts.max(:updated_at)}") do
  blog.posts.as_json
end

Rails.cache.fetch("comment_count", expires_in: 5.minutes) do
  Comment.approved.count
end

これ、うまく使えばめちゃくちゃ効率的だなあ、と思った。クエリ発行しないから、「データの厳密性が必要なとき」は、使うべきじゃないけど、それを差し引いても有用に見えるからどんどん使ってみたい。

ボトルネックを調整する

キャッシュできないものに対する対策。

rack-attackというGemを使うことで、Railsに辿り着く前に、特定のIPによる連続リクエストを拒否する。

Rack::Attack.throttle("limit login by IP", limit: 10, period: 15.minutes) do |request|
  if request.path == "/admin/sign_in" && request.post?
    request.ip
  end
end

Railsに辿り着く前に拒否できるから、パフォーマンスよし、とのこと。

(Jobを使って)後で実行する

重い処理はJobで切り出して、他のプロセスに処理を委譲しようぜ! とのこと。

依存関係をダイエットする

Gemを入れすぎると、依存関係がやばくなるから注意。Dependabot入れると、依存パッケージがバージョンアップすると、プルリクでお知らせしてくれるから便利だよ、とのこと。

Rubyのパフォーマンス改善

メタプログラミングは少しだけ使う

メタプロ(動的なメソッド定義など)は遅いから自分でやるときは、本当に必要なときだけにしようぜとのこと。

O(1)とO(n)の差を知る

serializers = [UserSerializer]
serializers.find do |serializer|
  serializer.accepts?(object.class)
end

serializers = { User => UserSerializer }
serializers[object.class]

上の例のように、O(1)にしたほうが効率いいから、そう設計していこうぜとのこと。

割りあて(Allocation)は少なく

Rubyではよく「!」がついた類の破壊的メソッドは危険だから、あんまり使うなって言われるけど、使い方しだいでは、効率化につながる。

arr = %i(a a b c)

#これより
new_arr = arr.uniq

#こっちのが効率いい
arr.uniq!

既存のオブジェクトを破壊して、新しいオブジェクトを作るほうが、割当てが減って効率がいい、とのこと。もちろん、破壊的変更は使い方を間違えると危ないので注意とのこと。

なるほどねえ。。使っていきたい。

間接化は最小限に

No code is faster than no code.

(ノーコードに優るコードはない)

間接的に複雑な処理を行えば行うほど、パフォーマンスは落ちてしまう。

即座に最適化には結びつかないけど、リファクタリングに大事な考えだから、覚えておくほうがいいとのこと。

最後に

ソフトウェア開発にはトレードオフがいっぱい。速さがいつも「開発で一番大事にしないといけないこと」ではない。

Shopifyでは「速さは機能の一つ」としか考えてなくて、それは「ユーザー体験の向上」と、「サーバー請求額の減額」にはつながるけど、開発者の幸福より優先されて取り組むことではないと思っている。

楽しんで早くしていこうぜ!

感想

Railsのキャッシュとかマジで全く知らなかったから、チャンスがあれば使ってみたいなあ、と思った。(コナミ

あとは、Rubyでの破壊的変更の利用とかも、意識すればすぐに実務で使っていけそうだから、これも覚えておきたい。

未経験からエンジニアになるための学習ロードマップ

完全に未経験から、エンジニアになりたい人向けの記事です。

自分がRailsで就職したので、このロードマップ自体はRailsエンジニア(バックエンドエンジニア)になることに特化していますが、Ruby以外の言語や、フロントエンドへの転職を希望している場合も、Rubyの部分がPHPJavaになったり、JavaScriptへの勉強量が変わるぐらいで、そこまで大きな違いはないと思います。

また、あくまでも自分がこういう風に学習してよかった、というたぐいのものですので、当たり前にサンプル数n=1ですし、そこはご了承ください。

ロードマップ

勉強期間は1ヶ月240時間以上、それを半年間続ける、という流れで考えています。ちょっと長めに見えるかもしれませんが、このぐらいの勉強期間をとって、就職後にいち早くチームの一員として認められる道を、自分としてはオススメします。

大まかな技術範囲については、以下記事がとても参考になるので、確認しておきましょう。

RubyとRailsの学習ガイド2019年版

ここに乗っている技術範囲を、どういう風に勉強していくか、というのが、目下の目標になります。

まずは大雑把に書いてます。詳しい教材内容については、最後に一気にまとめるので、参考にしてください。

1ヶ月目

HTML・CSSを知る

  • Progate
  • Udemy
  • ドットインストール

などで、基礎講座を受けましょう。

プログラミング言語(Ruby)に触れる

まずは、プログラミング言語(今回の場合はRuby)を知りましょう。プログラミング言語は、それ自体で完結している上に、最初から最後まで最も重要です。

ゼロからわかる Ruby 超入門 (かんたんIT基礎講座)

ゼロからわかる Ruby 超入門 (かんたんIT基礎講座)

Ruby勉強の最初の1冊として、上の本がおすすめです。チェリー本は初学者には難しいので、いきなり手を出してはいけません。

フレームワーク(Rails)に触れる

最初の1ヶ月目に触れておきましょう。もちろんこの時点では何がなんだかわからないと思いますので、「これをしたらこう動くんだ、へえ」ぐらいの理解で大丈夫です。

ネットワーク基礎を知る

ネットワークについては、学習の初期から当たり前にずっと触れていく部分になるので、早めに学んでおきましょう。

2ヶ月目

HTML・CSSを使う

デプロイしなくてもいいので、ローカルで学習継続。

Rubyを知る

  • paiza
  • Codewar

などのサイトで、Rubyの問題を解きましょう。また、「たのしいRuby」で、Rubyの基礎について学びましょう。(IOクラス、Fileクラス、Dirクラス、Encodingクラスの章については、現時点では飛ばして大丈夫です。)

Railsに触り続ける

ハンズオン形式の動画や、初心者本などで学びながら、Railsの挙動を覚えていきましょう。ただ、Viewの機能(クライアント層)については、今はRailsで担保せず、別でサーバーを立ててすることも多くなっているので、そこだけは薄めに勉強しても大丈夫かもしれません。

SQLに触る

バックエンドエンジニアを目指す場合は、SQLの理解は不可欠です。Railsの「ActiveRecord」が、SQL文の発行を代行してくれるとはいえ、自分も知っておく必要があるので、基礎文法は早めに学びましょう。

Gitに触る

Progateで学んでいるとは思いますが、チームで使う上に、ミスが起こるとチームメンバーに迷惑をかかることが多いので、早めに、深く学んでおきましょう。「仕組み」自体の理解はそこまで深堀りしなくてもいいですが、「挙動」と「コマンド」については、絶対的な自信を持つぐらいにやっておくことをオススメします。

HTTPについて学ぶ

バックエンドはHTTPリクエストを受け取り、処理を行うことが主な仕事の一つです。HTTPリクエストについて、学んでおきましょう。

Webを支える技術に触れる

抽象的な概念が多いので、気合を入れて取り組みましょう。「なんかよくわからん…」と思った場合は、立ち止まってググりましょう。または、知り合いのエンジニアや、メンターの方に質問してもいいかもしれません。

3ヶ月目

Rubyを使う

問題を解きつつ、チェリー本といった、少しレベルの上がった実践的な本にチャレンジしてみましょう。「こんなことが出来るんだ」、「やってて楽しいな」というレベル感を持ててるのであれば、よいペースです。

Railsを知る

たぶん、まだRailsブラックボックスだと思います。触り続けましょう。また、今まで学んだRailsアプリを自己流に改造したりして、挙動を試してみましょう。

JavaScriptに触る

このあたりで、JSについて触っておきましょう。Rubyを学ぶ方法と同じで大丈夫です。

Linuxについて知る

Shellの仕組みやコマンドについて学んでおきましょう。

セキュリティ・暗号技術について知る

基本的なセキュリティ対策や、暗号技術について勉強しましょう。実務上、すぐに必須というわけではないですが、とても大事な分野ですし、未経験のうちに頭に概観とインデックスを作っておくのは大切です。

アルゴリズムを知る

基本的なアルゴリズムを知っておきましょう。

復習する

1,2ヶ月目の内容を復習して起きましょう。

4ヶ月目

RubyRails、JSを学び続ける

学習を続けましょう。かんたんでもいいので、1つ目のポートフォリオも、そろそろ作っておくとよいです。また、開発でよく使うGemについて、ドキュメントなどを読む練習をしておきましょう。

Dockerについて知る

開発環境を素早く安定的に構築したり、ミドルウェアの依存関係を管理しておく上で、Dockerの技術は必須で、現場でもよく使います。コマンドだけでなく、Dockerが保証している内容や、Dockerの仕組みついても学びましょう。

AWS(or GCP)、Herokuに触る

ポートフォリオをデプロイするときに触ると思いますので、やっておきましょう。また、NginxやUnicorn、Rackについても、調べておきましょう。

設計について知る

オブジェクト指向設計実践ガイド」で、どうして設計が重要か、機能の追加・変更が容易なアプリケーションを作るためにはどうすればいいか、について学びましょう。

Rubyをもっと知る

メタプログラミングRuby」やりましょう!

JavaScriptフレームワークに触る(React、Vue等)

バックエンド志望の場合でもちゃんと触って、ある程度の挙動(クライアント層で解決したい課題とは何か)について知っておきましょう。フロントエンド志望の方は、この時点で、こちら側に勉強をシフトしましょう。(もっと早く移動してもいいかも。)

Githubについて知る

プルリクエストの作り方だったりのGithub Flowを勉強しておきましょう。チーム開発で必要になります。

5ヶ月目

ポートフォリオを作る

学習と並行して、ちゃんと作っておきましょう。

DB設計について知る

DB構造の基本的なアンチパターンについて学んで起きましょう。

テストについて知る

どうしてテストが必要なのか、またRSpecのテストとはどのようなものか、について学びましょう。

どのようなエンジニアになりたいか考える

たぶんもっと前から考えていると思いますが、決まっていない場合は、考えておきましょう。

6ヶ月目

ポートフォリオを完成させる

基本機能については、実装を完了させデプロイしておきましょう。

就活を行う

インターンでもいいです。

技術を学び続ける

入社したい企業が使ってる技術について、学んでおきましょう。(例: CircleCI、OpenAPI、Swagger、Firebase、Lambda、Kubernetesなど)

また、余裕があれば低レイヤーの知識(CPU、OSなど)も、学んでおきましょう。

以上のことを学べば、「未経験なのによく勉強しているね」という水準には確実に達しています。

もちろん、勉強期間を3ヶ月にして、「インターンとして働きながら勉強する」といった戦略に切り替えたりするのもよいと思いますので、そこは柔軟に行動してください。

心構え

上に挙げた学習ロードマップは、あくまでも「順調に学習をこなせた場合」です。かなり量がありますし、ぶっちゃけ、結構お腹いっぱいになって、「とても終わらねえよ」、「つらみが深い」という状況になることも予想されます。

ただ、出来る限り、以下のような心構えで、頑張って欲しいと思います。(これはぼくが考えていたことなので、心構えについては、各々で設定していいと思います。)

勉強時間にこだわる

「プログラミングに投資した時間」を、いかに伸ばしていくか。質も大事ですが、前提として量は必要不可欠です。

習慣を作る

おそらく、最低でも一日に8時間勉強が必要になると思いますが、この時間数を実際に達成するのは実はかなり難しいです。意思の力に頼っていては無理で、かつ、がむしゃらに気合でクリアできるというものでもありません。人によってクリアの仕方は異なるとは思いますが、「モチベーション」に頼って勉強時間を増やそうとすると挫折しやすいので、習慣化して達成を目指すといいと思います。

ちなみに、習慣化したあとに「プログラミングが辛すぎる」と思った場合、無理をしていることが多いので、一度休んだほうがいいです。自分のキャパシティを超えたり、背伸びをしすぎた学習を行っている場合があります。(掛け算を知らないのに、因数分解を解いているような状況)。そういう心の声を無視すると、あとで歪みが出るし、そもそもの効率が悪くなります。

僕の場合は幸いにも「プログラミングが辛い」と思うことはほとんどなかったですが、エラーに襲われているスクールの同期の方などは、わりとつらみマシマシな状況に見えたので、そういう場合は、出来る限り自分を客観視して、「なぜ辛いのか」を探るのがいいと思います。

だましだましでもいいので、「学習って楽しいなあ」レベルを維持しましょう。自分のペースで勉強出来る、という状況で、「辛い」と思ってしまっているのは、もったいないです。

学習素材を知る

世の中にはたくさん媒体があります。本を読むのが辛いときは動画で、動画が辛いときはネット記事で、ネット記事が辛いときは、誰かと話して、誰とも話したくないときは、Twitterで情報収集して、それが嫌ならPodCastでラジオ聞いて…。

選択肢をたくさん持ちましょう。少ないとすぐに飽きます。

教材に出し惜しみをしない

本や動画は、欲しくなったら惜しまずバンバン買いましょう。2000~3000円の出費を躊躇していてはいけません。もちろん、買うだけでやらないのは無駄なので、買ったら出来る限り早く消化しましょう。

理解できないとき、悪いのは「教材」(と思おう。)

「あなたが相対性理論を理解できないとき、悪いのはあなたではなく、相対性理論を説明している教材です。」

この意見は議論を呼ぶかもしれませんが、とにかく自分が伝えたいことはひとつ。学習初期の段階で、自分の才能を疑うことはしてはいけないということです。

どんなに頑張っても理解できないとき、悪いのは自分ではない、と思い込んで、教材レベルを落とすなどして対応しましょう。

掛け算の意味を全く理解していない状況で、九九を理解しようとしても、それは力になりません。まして、何も理解のないまま暗記などしても、結局、大事なところで使うことができません。

おわりに

とても長くなってしまいましたが、自分も独学を続けていたとき、「いったい何をやればいいんだ」と悩んでいた経験があったので、そういう方に向けて、役に立つ記事になっていればと思います。

たのしみましょう!

自分が主にお世話になった文献・記事・動画など

Ruby

書籍
サイト

Rails

書籍
  • Ruby on Rails5 アプリケーションプログラミング
  • 現場で使える Ruby on Rails 5速習実践ガイド
動画
  • フルスタックエンジニアが教える 即戦力Railsエンジニア養成講座(Udemy)
サイト

JavaScript

書籍
サイト

SQL・DB

  • SQL ゼロから始めるデータベース操作
  • 失敗から学ぶRDBの正しい歩き方

ネットワーク系

  • 3分間ネットワーク基礎講座
  • プロになるためにWeb技術入門
  • Webを支える技術
  • Real World HTTP
  • Web API The Good Parts

以下、箇条書き

とりあえず、Kindleに入ってるのを書き出しました。

良い本・動画ばかりなので、ぜひやってみてください!

新しいMacを入手したときにやっておくべき環境構築

新品のMacを手に入れたとき、初期設定では色々と使いづらいので、いろいろとカスタマイズしていくと便利です。

そのカスタマイズの内容は多岐にわたるので、自分用も兼ねてメモしておこうと思います。

入社初日などをイメージしてます。基本的に、上から行うと時間のロスが少ないように記述しています。

Mac自体のシステム環境設定

トラックパッド

「軌跡の速さ」を最大にする。

キーボード

「キーのリピート」を最速に、「リピート入力認識までの時間」を最短にする。

アクセサシビリティ

「マウスとトラックパッド」のトラックパッドオプションより、「3本指のドラッグ」をオンにする。

デスクトップとスクリーンセーバー

スクリーンセーバで、「ホットコーナー」を設定する。(マウスカーソルを画面の4隅に移動させると、特殊な動作を行える設定)

(自分は左上を「misstion controller」に設定してます。)

ソフトウェアのインストール

あとからチマチマ入れるより最初にバンバンインストールしておく、これ大事。

Chrome

Safariさんバイバイ。

Google日本語入力

備え付けのapple日本語入力よりこっちのほうが性能がいいと思います。 自分はライブ変換もオフにしてます。(google日本語入力だとそもそもなかったかも)

Slack

コミュニケーションツール。落としておきましょう。

iTerm2

分割できたり、文字の色を変更できるターミナル(という認識であってるのだろうか)。

エディタ(VScodeVimAtomSublimeなど)

もちろん必須。入れれるならプラグインも入れてエディタを強化したい。

Docker

DockerIDの作成を要求されますが、Dockerのために我慢して作りましょう。

SQLクライアント、GUIツール(SequelProなど)

MySQLに強いSequelPro以外だと、無料でよいGUIツールを僕は知りませんが、使えるものがあるなら使うのがよさげ。

Postman

API開発をするなら便利です。HTTPリクエストをシンプルかつ柔軟に送れるGUIツールです。

zsh

bashから変えたい方はここで変える。ちなみに、Macアカウントのユーザー名などもあとから変えるのは破壊的な変更があって手間なので、今のうちに変えておくとよいかも。

zshよりgshellおすすめだよと社内の強いエンジニアの方に言われたので、そちらもいいかも?(ただ、ググっても情報は少なかった。))

ID・アカウントの作成

Googleアカウント

新しいものがほしければ作りましょう。

Appleアカウント

こちらも新しいものがほしければ作りましょう。アプリをインストールする上で必要になったりします。

おわりに

思ったより少ないですね。あとは、ふだん使う言語に応じてローカルに環境を作ったり、ってところでしょうか。

他によいものがあれば、ぜひ教えてください。

おわり。

SPAのTwitterログイン認証(OAuth)

SPA(RailsAPI + Vue.js)でのTwiiterログインを実装したので、自分の実装方法をまとめておきます。

概要図(ステップ)

f:id:fujiten3:20190801111818p:plain

実装の概要

まずはじめに、 実装を完走した感想ですがSPAで外部のAPIを利用したログインは基本的に辛みが深いように感じました。

自分の場合は、クライアント側(Vue.js) → バックエンド側(RailsAPI) → 外部API(TwitterAPI) というリクエストの遷移を基本にして実装したわけですが、「バックエンド → 外部API」の結果手に入れた認証情報をクライアント側に返す処理をどのように行うかというところで参考になるサイトがとても少なかったことでなかなか苦戦を強いられたからです。

調べたところクッキーを利用する以外にズバッと解決できそうなものを見つけられなかったので、今回はクッキーを使っています。

つまり、「バックエンド側(RailsAPI) → 外部API(TwitterAPI)」のやり取りの結果の一部をクッキーでクライアント側に返し、クライアント側でそれを受け取ったあと、そのクッキー情報に基づいてクライアントが再びRailsAPIを叩き、完全なログインを実現するという流れです。

ベストプラクティスかどうかはわかりませんが、XSS対策への対応は出来ています。

ログイン認証の大本は、以下の記事通りに作成しています。

fujiten3.hatenablog.com

ステップ1

(簡略化してポイントだけ書いてます。)

VueからRailsAPIを叩き、ステップ5を行うためのコールバックURLを貰う。

signinWithTwitter () {
  this.$http.secured.get('/authenticate')
    .then(response => location.href = response.data.oauth_url )
    .catch(error => this.signinFailed(error))

location.href = response.data.oauth_urlで、認可のためのコールバックURLを貰って、Twitterの承認画面に切り替えるという挙動になります。

ステップ2〜5

コールバックURLを返すための処理です。

また、クライアントサイドに「リクエストークン」をクッキーで返しています。(アクセストークンの取得に必要)

  def authenticate

    consumer ||= OAuth::Consumer.new(
      ENV['TWITTER_CONSUMER_KEY'],
      ENV['TWITTER_CONSUMER_KEY_SECRET'],
      { :site => "https://api.twitter.com" }
    )

    request_token = consumer.get_request_token(
      oauth_callback: ENV['TWITTER_CALLBACK_URL']
    )

    response.set_cookie("request_token",
      value: request_token.token,
      httponly: true,
      secure: Rails.env.production?)

    response.set_cookie("request_token_secret",
      value: request_token.secret,
      httponly: true,
      secure: Rails.env.production?)

    rtn = {}
    rtn["status"] = 'return_callback_url'
    rtn["oauth_url"] = request_token.authorize_url

    render json: rtn
  end

ステップ6〜7

認証に成功した場合、アクセストークンをクッキーで返答します。

def twitter

  request_token = OAuth::RequestToken.new(
    consumer,
    request.cookies['request_token'],
    request.cookies['request_token_secret']
  )

  access_token = request_token.get_access_token(
    {},
    :oauth_token => params[:oauth_token],
    :oauth_verifier => params[:oauth_verifier]
  )

  twitter_response = consumer.request(
    :get,
    '/1.1/account/verify_credentials.json',
    access_token, { :scheme => :query_string }
  )

  case twitter_response
  when Net::HTTPSuccess
    user_info = JSON.parse(twitter_response.body)

    if user_info["screen_name"]

      user = User.find_or_create(user_info, 'twitter')

      if user.persisted?

        payload = { user_id: user.id }
        session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
        tokens = session.login
        cookie_key_value_pairs = {

          'oauth_token2' => access_token.params["oauth_token"],
          'oauth_token_secret' => access_token.params["oauth_token_secret"],
          'csrf' => tokens[:csrf],
          'ac_token' => tokens[:access]
        }

        set_cookies_at_once(response, cookie_key_value_pairs)

        response.set_cookie('signedIn',
        value: true,
        domain: ENV["BASE_DOMAIN"],
        path: "/",
        secure: Rails.env.production?)

        set_cookie_at_token_validness(true)

        redirect_to ENV['CLIANT_SIDE_TOP_PAGE']

      else

        redirect_to ENV['CLIANT_SIDE_TOP_PAGE']

      end

    else
      # 認証失敗
    end

  else
    # OAuthを通してユーザ情報取得失敗
  end

end

ステップ8

クライアントにリダイレクトした後、実行されるメソッドです。

クッキーのsignedInに値が入っていた場合、RailsAPIを叩きます。

    checkSignedInWithCookie () {
      const cookies = document.cookie.split('; ')
      let obj = {}
      cookies.forEach(cookie => {
        let arr = cookie.split('=')
        obj[arr[0]] = arr[1]
      })
      if (obj.signedIn) {
        this.$http.secured.get(`/api/v1/users/show_me`)
          .then(response => {
            localStorage.signedIn = true
            localStorage.csrf = response.data.csrf

            this.$router.replace('/')
          })
          .catch(error => {
            this.setError(error, 'ログインエラー: なにかがおかしいです。')
          })
      }

ステップ9

tokenをAuthorizionヘッダーに格納したのち、 authorize_access_request!メソッドで、ステップ6〜7で発行したアクセストークンかどうかの認証を行っています。

認証に成功すれば、必要なユーザー情報やCSRFトークンを返答します。

def show_me
  ac_token = request.cookies['ac_token']
  request.headers['Authorization'] = "Bearer #{ac_token}"
  authorize_access_request!

  response.set_cookie('ac_token',
  value: nil,
  httponly: true,
  domain: ENV["BASE_DOMAIN"],
  path: "/",
  secure: Rails.env.production?)

  response.set_cookie('signedIn',
  value: nil,
  domain: ENV["BASE_DOMAIN"],
  path: "/",
  secure: Rails.env.production?)

  response.set_cookie(JWTSessions.access_cookie,
  value: request.cookies['ac_token'],
  httponly: true,
  secure: Rails.env.production?) 

  render json: { csrf: request.cookies['csrf'],
                  my_avatar: current_user.avatar.encode(:icon), 
                  uid: current_user.id  }
end

感想

ほとんど全ての記述をコントローラに書いてしまっているので、できればモデル層にコードを移したいなと思っています。

認証のためのクラスを作って、そこに処理を任せる形が一番きれいにリファクタリングできそうかな…。

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」とかぼんやりしたものになって、なかなか欲しい情報にたどり着けない)

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