8章です。
記憶定着 & どこまで思い出せるかチャレンジみたいなものですので、超個人向けまとめになります。
8章 基本的なログイン機構
8章でやること(ざっくり)
- ログイン画面を作る
- ログイン画面の入力内容からユーザーを特定し、それをブラウザのsessionに保存する
- session情報から、ユーザーがログイン中か?を判定できるようにする
- ログイン中かの判定により、表示内容を分岐させる
- 多少のjs操作
- テストのリファクタリング(任意)
など
そもそも「ログイン機能を作る」とはなにをしたいのか?
まず、章のまとめに入る前に、ログイン機能とはどのような機能を作りたいのか、について確認しておく。
というのも、ユーザー作成などはUser.saveなどで作成していることがとてもわかりやすいが、「ログイン機能」と言われても、いったい何をすれば良いのかわからず戸惑った気がするからである。
この章で、システム的にやりたいこと
誤解を恐れずいうと、「ブラウザにユーザーの情報を保存してもらう」処理を書くことをしたい。
どうしてこのようなことが必要かといえば、httpがステートレスなプロトコルだからである。
例えば、ログイン画面からユーザー情報が送信され、パスワードとの整合性も取れたとする。
その場合、このユーザーには登録者向けの機能やuiを表示したいのだが、そのまま何もしないと、次回以降のやり取りで、サーバーはそのユーザー情報を忘れてしまう。
そこで、(暗号化して)ログインユーザーのidなどを情報をブラウザ側に保存してもらい、それを次回以降のリクエストに含めてもらうようにする。
すると、ユーザーidの情報がある = ログインしていると判定できる + ユーザーを特定できる ということになり、
まるでステートがあるような(というのは言い過ぎかもしれないが)アプリケーションでの体験ができる、というわけ。
よって、この章でやりたい主なことは、「ユーザーの情報がdbと一致した場合、sessionにそれを保存する」ということだ。
それを踏まえて、この章をみていく。
ログイン機構
Sessions_controller & route作成
セッションは、先述の通りrails側のdbで何かを保存しても仕方なく、ブラウザ側に、railsで発行したsession情報を保存してもらう、という形になる。
よって、Userのようなモデルの作成はしないのだが、sessionの作成や削除などはrestfulな設計に則って行うことができる。
そのため、この章ではControllerを作成した。また、「session詳細」などのページは必要ないため、newページのみgenerateに指定した。
rails g controller Sessions new
ついでに、routesの編集もする。
Usersのようにresuorces指定でも悪くないが、編集や一覧など利用しないものも多いため、今回は直に指定する形だった。
<要確認>routesの記載方法を忘れがち。httpメソッド パス, controller#メソッド
になる。
こう見ると、最後のto: <>
は最後のハッシュ引数パターン。
+ get '/login', to: "sessions#new"
+ post '/login', to: "sessions#create"
+ delete '/logout', to: "sessions#destroy"
sessions#newに対応するview
要するにログイン画面を作成する。
generateコマンドでnewを指定したので、newページはすでにあり、それを編集する。
styleなどは省略するが、modelがない場合のform_withが出てくる。
<要確認>form_withに渡す引数忘れがち。
また、submitの場合第一引数には表示したい文字列を入れる。他はリソースの属性シンボルなので注意したい。
<%= form_with(url: login_path, scope: :session) do |f| %>
<%= f.lavel :email %>
<%= f.email_field :email, class: "" %>
<%= f.lavel :password %>
<%= f.password_field :password %>
<%= f.submit "Log in", class: "" %>
<% end %>
上記のように、urlはヘルパーを利用しつつ直接指定し、scope: :session
と、scopeにて指定する。
Usersの時から推測できるかもだが、以下のようになる。
取り出しもハッシュのように扱えば良い。
params: { session: { email: '' } }
# 取り出し
params[:session][:email]
(erbもUserと同じような形だと想像がつく)
<input name="session[email]">
↑このnameに入る
(余談)この辺りで、「今までの章をやっていれば個々の実装もわかるようなつくりになっている」という旨の記載がされていた気がしており、確かにそうかもと思った気がする。
sessions controller の実装
viewからのログインリクエストを受け取ることができるようになったため、コントローラーでの処理(sessions#create)を実装していく。※newメソッドはテンプレート表示だけなのでそのまま
手順は以下
- 全体の大枠を作る(成功 / 失敗分岐など)
- 失敗した時の処理を書く
- 成功した場合の処理を書く
1. 全体概要
まず、createメソッドの全体像から。
パラメーターのemailを利用し、Userを検索する。
そして、userが見つかる & 認可処理がOKであれば、成功時の処理をする。
def create
user = User.find_by(email: params[:session][:email]).downcase
if user && user.authenticate(params: [:session][:password])
# 成功時の処理
else
# 失敗時の処理
end
end
条件式を詳細に見る。
user &&
となっているが、rubyではnil or falseの場合以外はtrueとなる。
find_byメソッドは、取得できなかった場合nilが返されるので、この処理は思っている通りに動作する。※これ割と大事で、rubyでは0もtrueと判定されたりする。割と間違いやすい。
そして、user.authenticate(params: [:session][:password])
となる。
ここで急にauthenticate
なるものが利用できているように感じるが、前回の章でhas_secure_password
をUserモデルに実装したため利用できている。userの情報とパスワードの情報が一致しない場合falseとなる。
概要はここまでであるが、疑問に思ったことと、そうしない予想について少し。
userのcreate処理では、ストロングパラメータ(user_paramsと定義し、属性に許可をしていた)を利用していたが、ここでは利用しないのか?ということである。
sessionの場合は、そもそもモデルがなく、DBに残るデータを作成するわけではない。
よって、Model.new(params)のようなことはせず、直接パラメータを指定して取り出している。
そのため、ストロングパラメータを定義する必要がない。
一方、userの場合はUser.new(attibutes)という形でDBに残るデータを作成する。
この時、ユーザーが操作してはいけない値なのにUserモデルに存在する属性が指定されていると、それでデータが作成されてしまうので絞る必要があるよね、というところだろう。
2.失敗時の処理
完成系から載せてしまう。
def create
user = User.find_by(email: params[:session][:email]).downcase
if user && user.authenticate(params: [:session][:password])
# 成功時の処理
else
flash.now[:danger] = "メールアドレスかパスワードが違う的なメッセージ"
render 'new', status: :unprocessable_entity
end
end
ここで特筆すべきは、flash.now
という記載だろう。
(dangerは、bootstrapに乗っかるためdangerとしているのみ)
User作成時は、flash[:success] =
という記載だった。違いは、最後にredirectするかどうかみたいな。
今まで曖昧な認識だったが、redirectは2回リクエストが発生し、通常のflashではその2回目まで値が利用できるようになっている。
これは調べたところ、sessionに値を保存しておき、2回目までそれが利用できるようになっているよう。ただ、実際に開発者ツールで見てみるとわかるが、新しいsessionオブジェクトが作成されるのではなく、そのページのセッションオブジェクトに情報が追加される形となっている。
逆にflash.nowを利用した場合はセッションに保存されず、一度きりのリクエストで利用できる値として存在する。よって、今回はflash.nowを利用する。
余談で、flash.now[:danger]
問い記述がなんとも不思議だと感じた。
というのも、通常のハッシュであればhash[key]
という形で値を取り出せるため、なんとなく上記の記法も似たように見える。ただ、今回は明らかに.now
とメソッドを呼び出し、その後に[key]
と続くものだから、違和感があるのだと思う。
それに加え、メソッド + []
というような、[]というメソッド呼び出しも不思議である。
ただ、これもparamsのように、ハッシュのように扱えるだけでハッシュではないということと、string[]
というのも配列にアクセスしているようで、[]というメソッドを呼び出しているということを考えると、意外とそれっぽく見えてくる気がする。
<要確認>[]というメソッド呼び出しは、rubyでどう定義されているのか?
失敗時 - テスト
このタイミングでテストを書いたかあまり覚えていないが、おそらく最後にまとめてとかではなかったと思うので、ユーザーログインに対する統合テスト(integration test)を作成する。
まずはgenerate。
rails g integration_test users_login
そしてここで、「dbにユーザーがいる」という状態でテストをしなければならないことを思い出した。
そういえば、一旦不要ということでymlから消していた。
それを復活させ、テストファイルの最初で呼び出す。(この記載方法は忘れていたので確認)
データは以下、test内のfixturesディレクトリにyamlファイルがある。
user1というのは任意で、どのような名前でテストファイルで呼び出すかの定義くらいの感じ。
そこに、yamlスタイルで属性を記載していく。
user1:
name: user1
email: test@example.com
password_digest: <%= User.digest('password') %>
↓のように、<複数形>(シンボル)という記載で呼び出せる。
シンボルなんだ、と思い試しに文字列でアクセスしてみたところ、それも問題なく動いた。
これはいかにもrubyっぽい動きだが、あえて文字列を利用する意味もないため全く余分な確認である。
# 新規記載部のみ抜粋
def setup
@user = users(:user1)
end
# 以下でも動くは動く
def setup
@user = users('user1')
end
test "login with invalid params" do
get login_path
post login_path, params: { session: { email: @user.email,
password: 'invalid' } }
assert_template 'sessions/new'
assert_responce :unprocessable_entity
assert_not flash.empty?
get root_path
assert flash.empty?
end
ここで、そういえばflashの確認演習みたいなものが7章であった気がするが、.empty?
というものを使っていればとりあえず正解だった、という確認もできたりしてそう。
さて、記事ではflashを使っていたためテストが落ち、flash.nowに直して通ることを確認する、という手順だったと思うが、すでにやっているのでOK。
3.成功時の処理を書く
やっとここで今回のメイン処理であるログイン機能を実装していく。
最初に確認した通り、やりたいことは「ブラウザのセッションにユーザー情報を記録する」ことだった。
よってまずその処理を書くのだが、ここで、アプリケーション内で同じログインメソッドを使いまわせるようにするため、ヘルパーにlog_in
メソッドを定義し、それをApplicationControllerで読み込むことにしていた。
def log_in(user)
session[:user_id] = user.id
end
~~略~~
include SessionsHelper
~~略~~
まあ割とこれだけといえばこれだけである。
が、これでこのアプリケーションのsessionにユーザーの情報を保存できたことになる。
よって、rails側は一リクエストごとユーザーを覚えてはいないが、ブラウザが覚えてくれているのでそのユーザーに対する適切なviewを表示できるというわけ。
また、sessionはブラウザを閉じると消えてしまう揮発性の高いものである。
cookiesを利用した、リメンバー機能を9章で作成していくとのことである。
さて、ひとまずloginメソッドの作成が済んだので、成功時の処理を書いていく。
def create
user = User.find_by(email: params[:session][:email]).downcase
if user && user.authenticate(params[:session][:password])
reset_session # 必ず呼び出す
log_in(user)
redirect_to user
else
flash.now[:danger] = "メールアドレスかパスワードが違う的なメッセージ"
render 'new', status: :unprocessable_entity
end
end
ここで、reset_session
というメソッドを呼び出しているが、これはその名の通りsession情報をリセットするものである。これは、セッション固定と呼ばれる攻撃からアプリケーションないしユーザーを守るためである。
セッション固定というのは、ユーザーがすでに持っているセッションidを別ユーザーに利用させることで、意図しないリクエストを発生させる攻撃である。
セッションにユーザーidを保存する直前にリセットすることで、その脅威を回避している。
ここで、少し内容は逸れるかもしれないが、form_withが最初に登場した際、hiddenタグが挿入されていることについても触れておきたい。(多分、9章で触れられる気がするけど)
以下のことを指している。
あのhiddenタグはなんだったのだろうか?ということだが、これはcsrf(cross site request forgeries)対策のために生成されている。
submitをすると自動でこの値がバックエンドに送信され、攻撃を防いでいるらしい。
では、csrfがどのような攻撃で、どうしてこのhiddenタグで防げるのかみていく。
railsのcsrf対策
csrf攻撃とは、ざっくりいうと「偽造した情報でリクエストさせ、悪意のある動作を引き起こす」攻撃、という感じかなと思う。※理解半分なので、あまりうまく言語化できていない気がする。
よくわからないので、具体例を見てみた。
- ユーザーがアプリにログインする
- ユーザーを罠サイトに誘導するなど、攻撃のためのサイトを踏ませる(メールや埋め込みなど?)
- リンククリックなどの操作により、罠サイトから本当のサイトへリクエストを送信する
- →その際、sessionに保存されたuserIdも送信されるため、ユーザーが意図しないリクエストをユーザーになりすまして送信できてしまう
という感じ。なかなかざっくりとであるが、大きく誤ってはいないだろうと思う。
では、あのhiddenタグがどうしてそれから守ってくれるのだろうか?
実は、sessionにはこのhiddenタグに含まれたトークンと対応する値が入っているらしい。
※同じ値ではなく、暗号化されている。
もし、正常なリクエストだった場合、そのsessionに保存された値を復号し、hiddenタグによって送信された値と照らし合わせる。これが一致した場合、このリクエストは正常なものだと判定できる。
では、意図しないリクエストをアプリ外部から送信した場合はどうなるか。
サーバーで、sessionからcsrf向けの箇所を復号はされる。しかし、hiddenで本来送られてくるはずの値はない。よって、このリクエストは不正なものだと判断できるのだ。
(sessionに入っている値は暗号化されているので、そのままhiddneの内容として送られても問題ない)
じゃあ、「そのhiddenタグの内容が窃取されないのか?」と思うかもしれない。
これは、同一オリジンポリシー(Same-Origin Policy)というもので保護されている。
htmlは、別ページのdom同士で干渉できなくなっている。よって、別のサイトが本来のサイトの情報を抜き出して利用する、ということはできないのである。
このような仕組みで、アプリケーションはcsrfからユーザー内しアプリケーションを守っているということである。これを意識しなくても開発できるのは、フレームワークのすごいところだろうか。
ログインユーザー向けのui
そして、最後の方にはユーザーの状態に合わせた表示をするなどの変更をした。
ユーザーの状態により切り替えたのは以下の三つ。
- ログインボタンの表示/非表示
- ログアウトボタンの表示/非表示
- ユーザー詳細ページリンクの表示/非表示
他、以下のようなことも行なったので後ほど触れる。
- スマートフォン等小さい画面に向けたスタイル(レスポンシブスタイル)
- javascriptを利用したトグルボタン(ハンバーガーメニュー)の作成
さて、まずはログインユーザー/そうでないユーザーで表示を分けるところから始める。
そのために、erbで現在操作しているユーザーがログイン状態なのか、そうでないのかを判定できると嬉しい。
そこで、現在のユーザーを保持するcurrent_user
というメソッドを定義し、そのメソッドを利用することでログイン状態かを判定する logged_in?
メソッドを作成する。
log_inメソッドと同様、SessionHelperに追加していく。
def current_user
@current_user ||= User.find_by(id: session[:user_id])
end
def logged_in?
!current_user.nil?
end
最初のcurrent_userから。
これは、インスタンス変数である@current_userがあるならばそれを、なければセッション情報からdbアクセスしてuserを探すというものである。
ここで重要なのが、find_by
の挙動である。idで検索するのであれば、User.find(id)
も利用できそうな気がする。しかし、findメソッドはデータがない場合エラーを返すものである。
それが必要な時はそれで良いが、今回はユーザーがいなけれければ、ログインしていないユーザーとして処理をすれば良いだけであるため、エラーはかえさなくて良い。
そこで、find_byを利用する。これは、データが見つからない場合nil
を返してくれる。ということは、@current_user ||= User.find_by(id: session[:user_id])
という判定は、@current_userがない(nilなど)場合、かつdbからも見つからない場合はnilが返される。
するとどうだろう。logged_in?
でcurrent_user.nil?という判定にピッタリハマるのだ。
これを利用して、ログインユーザーかどうか判定ができるようになった
erbは以下のように修正する。(めちゃめちゃざっくりです)
<% if logged_in? %>
// ユーザー詳細ページへのパスなど
<% else %>
// ログインページへのパスなど
<% end %>
ここで面白いのは、sessionHelperはcontrollerにincludeしているだけなのにも関わらず、erb側(view側)でも利用できてしまうという点である。
react + railsなど、バックエンドとフロントエンドを分ける場合、フロント側でユーザー情報をバックエンドから取り出して利用するというような形になる。
しかし、railsのような両方が合わさったフレームワークはhtmlを基本的にサーバーサイドで構築し、htmlとして返却する。
そのため、erbもサーバー側で処理されるため、このような動作が可能なのである。
※hotwriteなどは雰囲気しか知らない状況です。このチュートリアルはその辺りもカバーしてくれているそうなので、後々入門します。
他修正
ここは省略してしまうが、jsを利用したトグルボタンの設置やレスポンシブデザインといった修正もした。
ただ、そこで一点疑問に思ったことについて取り上げておく。navbar-toggle
というクラス名である。これはbootstrap(3)のクラス名である。
何が疑問だったのかというと、ここの修正で該当のボタンを押下した際に、メニューの表示/非表示の切り替えをjsにおけるtoggleというもので実現していた。
じゃあ、このnavbar-toggle
というものは何をtoggleするのか?と思ったという次第である。
これはレスポンシブの一環で、表示画面が一定の値より小さくなると現れる、という表示/非表示のtoggleをしているのであった。
本チュートリアルではそのボタンを押した際に、こちらで定義したメニューの表示・非表示の切り替えも行なっているというわけである。
成功時のテスト
最後に、成功時のテストを書く。
その前に、ユーザーがログインしているかを判定するテストようのhelperメソッドを用意した。
def is_logged_in?
!session[:user_id].nil?
end
そもそもこのヘルパーを定義するのは、ログイン状態のテストで毎回session情報を参照して、という同じ処理を繰り返さないためだというのと、何を確認したいのかがとても明確になることが利点だろうと思う。
では、どうしてsession_helper
側ではlogged_in?
メソッドだったのに名前が違うのか。
それは、テスト側と通常の環境側で棲み分けをしたいからである。
本チュートリアルでは、筆者が同じ名前のlogged_in?というヘルパーをテスト側でも作成していた際、誤って通常環境の方を削除したのにも関わらずテストが通ってしっていたという事例が紹介されている。こういった規約的なことにはちゃんと乗っかれるようにしたいなぁと思う。
// まだ途中ですが、内容が濃くて多くなりすぎるのと脱線のしすぎで力尽きたので、ひとまずここまでとします