見出し画像

railsチュートリアル挑戦記 第8章 基本的なログイン機構

railsチュートリアルをやりながらメモしたことをそのまま記述しています。

第8章基本的なログイン機構


ログイン機能を実装していく

・8 1 セッション

httpは状態のないプロトコル
ログインするとcreateでセッションを作成して保存
ログアウトするとdestroyでセッションを破棄

トピックブランチを作成する

git checkout -b basic-login

・8 1 1 Sessionsコントローラ


Sessionsコントローラを生成する
----------------------------
rails generate controller Sessions new
----------------------------
リソースを追加して標準的なRESTfulアクションをgetできるようにする red
config/routes.rb
----------------------------
Rails.application.routes.draw do
 root   'static_pages#home'
 get    '/help',    to: 'static_pages#help'
 get    '/about',   to: 'static_pages#about'
 get    '/contact', to: 'static_pages#contact'
 get    '/signup',  to: 'users#new'
 get    '/login',   to: 'sessions#new'
 post   '/login',   to: 'sessions#create'
 delete '/logout',  to: 'sessions#destroy'
 resources :users
end
----------------------------
Sessionsコントローラのテストで名前付きルートを使うようにする green
test/controllers/sessions_controller_test.rb
----------------------------
require 'test_helper'
class SessionsControllerTest < ActionDispatch::IntegrationTest
 test "should get new" do
   get login_path
   assert_response :success
 end
end
----------------------------

HTTPリクエスト URL 名前付きルート アクション名 用途
GET /login login_path new 新しいセッションのページ (ログイン)
POST /login login_path create 新しいセッションの作成 (ログイン)
DELETE /logout logout_path destroy セッションの削除 (ログアウト)

ルーティングを確認するときは
rails routes

ログ
----------------------------
gin) $ rails routes
  Prefix Verb   URI Pattern               Controller#Action
    root GET    /                         static_pages#home
    help GET    /help(.:format)           static_pages#help
   about GET    /about(.:format)          static_pages#about
 contact GET    /contact(.:format)        static_pages#contact
  signup GET    /signup(.:format)         users#new
   login GET    /login(.:format)          sessions#new
         POST   /login(.:format)          sessions#create
  logout DELETE /logout(.:format)         sessions#destroy
   users GET    /users(.:format)          users#index
         POST   /users(.:format)          users#create
new_user GET    /users/new(.:format)      users#new
edit_user GET    /users/:id/edit(.:format) users#edit
    user GET    /users/:id(.:format)      users#show
         PATCH  /users/:id(.:format)      users#update
         PUT    /users/:id(.:format)      users#update
         DELETE /users/:id(.:format)      users#destroy
ec2-user:~/environment/sample_app (basic-login) $ 
----------------------------

・8 1 2 ログインフォーム

form_for(@user)
と書くと、フォームのactionは/usersというURLのPOSTであると判断するが、
セッションの場合は具体的に書く必要がある
form_for(:session, url: login_path)

ログインフォームのコード
app/views/sessions/new.html.erb
----------------------------
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
 <div class="col-md-6 col-md-offset-3">
   <%= form_for(:session, url: login_path) do |f| %>
     <%= f.label :email %>
     <%= f.email_field :email, class: 'form-control' %>
     <%= f.label :password %>
     <%= f.password_field :password, class: 'form-control' %>
     <%= f.submit "Log in", class: "btn btn-primary" %>
   <% end %>
   <p>New user? <%= link_to "Sign up now!", signup_path %></p>
 </div>
</div>
----------------------------

以下のようにHTMLが生成される

----------------------------
<form accept-charset="UTF-8" action="/login" method="post">
 <input name="utf8" type="hidden" value="&#x2713;" />
 <input name="authenticity_token" type="hidden"
        value="NNb6+J/j46LcrgYUC60wQ2titMuJQ5lLqyAbnbAUkdo=" />
 <label for="session_email">Email</label>
 <input class="form-control" id="session_email"
        name="session[email]" type="text" />
 <label for="session_password">Password</label>
 <input id="session_password" name="session[password]"
        type="password" />
 <input class="btn btn-primary" name="commit" type="submit"
      value="Log in" />
</form>
----------------------------

params[:session][:email]
params[:session][:password]

にそれぞれ値が入る

・8 1 3 ユーザーの検索と認証


Sessionsコントローラのcreateアクション (暫定版)
app/controllers/sessions_controller.rb
----------------------------
class SessionsController < ApplicationController
 def new
 end
 def create
   render 'new'
 end
 def destroy
 end
end
images/figures/initial_failed_login_3rd_edition
----------------------------

ハッシュは以下の通りになる
{ session: { password: "foobar", email: "user@example.com" } }

ユーザー認証に必要なあらゆる情報をparamsハッシュから簡単に取り出せる

Active Recordが提供する
User.find_byメソッドでデータベースからユーザーを探し

has_secure_passwordが提供する
authenticateメソッドでパスワードをチェックする

authenticateメソッドは認証に失敗したときにfalseを返す

それを利用して下記の通り

ユーザーをデータベースから見つけて検証する
app/controllers/sessions_controller.rb
----------------------------
class SessionsController < ApplicationController
 def new
 end
 def create
   user = User.find_by(email: params[:session][:email].downcase)
   if user && user.authenticate(params[:session][:password])
     # ユーザーログイン後にユーザー情報のページにリダイレクトする
   else
     # エラーメッセージを作成する
     render 'new'
   end
 end
 def destroy
 end
end
----------------------------

・8 1 4 フラッシュメッセージを表示する


ログイン失敗時の処理を扱う (誤りあり)
app/controllers/sessions_controller.rb
----------------------------
class SessionsController < ApplicationController
 def new
 end
 def create
   user = User.find_by(email: params[:session][:email].downcase)
   if user && user.authenticate(params[:session][:password])
     # ユーザーログイン後にユーザー情報のページにリダイレクトする
   else
     flash[:danger] = 'Invalid email/password combination' # 本当は正しくない
     render 'new'
   end
 end
 def destroy
 end
end
----------------------------

実はこのままだとエラーが残り続ける

・8 1 5 フラッシュのテスト

統合テストを作成する
rails generate integration_test users_login

以下の流れでテストコードを実装していく
ログイン用のパスを開く
新しいセッションのフォームが正しく表示されたことを確認する
わざと無効なparamsハッシュを使ってセッション用パスにPOSTする
新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されることを確認する
別のページ(Homeページなど)にいったん移動する
移動先のページでフラッシュメッセージが表示されていないことを確認する

フラッシュメッセージの残留をキャッチするテストred
test/integration/users_login_test.rb
----------------------------
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
 test "login with invalid information" do
   get login_path
   assert_template 'sessions/new'
   post login_path, params: { session: { email: "", password: "" } }
   assert_template 'sessions/new'
   assert_not flash.empty?
   get root_path
   assert flash.empty?
 end
end
----------------------------

rails testの引数にテストファイルを与えると、そのテキストファイルだけを実行することができる
rails test test/integration/users_login_test.rb

ログ
----------------------------
ec2-user:~/environment/sample_app (basic-login) $ rails test test/integration/users_login_test.rb
Running via Spring preloader in process 5381
Started with run options --seed 39292
FAIL["test_login_with_invalid_information", UsersLoginTest, 0.45197109099990485]
test_login_with_invalid_information#UsersLoginTest (0.45s)
       Expected false to be truthy.
       test/integration/users_login_test.rb:11:in `block in <class:UsersLoginTest>'
 1/1: [========================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.45428s
1 tests, 4 assertions, 1 failures, 0 errors, 0 skips
ec2-user:~/environment/sample_app (basic-login) $ 
----------------------------
ログイン失敗時の正しい処理 green
app/controllers/sessions_controller.rb
----------------------------
class SessionsController < ApplicationController
 def new
 end
 def create
   user = User.find_by(email: params[:session][:email].downcase)
   if user && user.authenticate(params[:session][:password])
     # ユーザーログイン後にユーザー情報のページにリダイレクトする
   else
     flash.now[:danger] = 'Invalid email/password combination'
     render 'new'
   end
 end
 def destroy
 end
end
----------------------------
ログ
----------------------------
ec2-user:~/environment/sample_app (basic-login) $ rails test test/integration/users_login_test.rb
Running via Spring preloader in process 5420
Started with run options --seed 33833
 1/1: [========================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.45133s
1 tests, 4 assertions, 0 failures, 0 errors, 0 skips
ec2-user:~/environment/sample_app (basic-login) $ rails test
Running via Spring preloader in process 5436
Started with run options --seed 59391
 23/23: [======================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.58676s
23 tests, 48 assertions, 0 failures, 0 errors, 0 skips
ec2-user:~/environment/sample_app (basic-login) $ 
----------------------------


・8 2 ログイン

cookiesを使った一時セッションでユーザーをログインできるようにする。

めっちゃ修正必要なはずだけど、railsなら以下をやるだけでおk

ApplicationコントローラにSessionヘルパーモジュールを読み込む
app/controllers/application_controller.rb
----------------------------
class ApplicationController < ActionController::Base
 protect_from_forgery with: :exception
 include SessionsHelper
end
----------------------------

・8 2 1 log_inメソッド

同じログイン手法を様々な場所で使いまわせるようにするために、
sessionsヘルパーにlog_inという名前のメソッドを定義

log_inメソッド
app/helpers/sessions_helper.rb
----------------------------
module SessionsHelper
 # 渡されたユーザーでログインする
 def log_in(user)
   session[:user_id] = user.id
 end
end
----------------------------
ユーザーにログインする
app/controllers/sessions_controller.rb
----------------------------
class SessionsController < ApplicationController
 def new
 end
 def create
   user = User.find_by(email: params[:session][:email].downcase)
   if user && user.authenticate(params[:session][:password])
     log_in user
     redirect_to user
   else
     flash.now[:danger] = 'Invalid email/password combination'
     render 'new'
   end
 end
 def destroy
 end
end
----------------------------

cookies、Expires

・8 2 2 現在のユーザー

rubyだと||=が使える

セッションに含まれる現在のユーザーを検索する
app/helpers/sessions_helper.rb
----------------------------
module SessionsHelper
 # 渡されたユーザーでログインする
 def log_in(user)
   session[:user_id] = user.id
 end
 # 現在ログイン中のユーザーを返す (いる場合)
 def current_user
   if session[:user_id]
     @current_user ||= User.find_by(id: session[:user_id])
   end
 end
end
----------------------------


・8 2 3 レイアウトリンクを変更する

logged_in?ヘルパーメソッド
app/helpers/sessions_helper.rb
----------------------------
module SessionsHelper
 # 渡されたユーザーでログインする
 def log_in(user)
   session[:user_id] = user.id
 end
 # 現在ログイン中のユーザーを返す (いる場合)
 def current_user
   if session[:user_id]
     @current_user ||= User.find_by(id: session[:user_id])
   end
 end
 # ユーザーがログインしていればtrue、その他ならfalseを返す
 def logged_in?
   !current_user.nil?
 end
end
----------------------------

ヘッダーを弄る

ログイン中のユーザー用のレイアウトのリンクを変更する
app/views/layouts/_header.html.erb
----------------------------
<header class="navbar navbar-fixed-top navbar-inverse">
 <div class="container">
   <%= link_to "sample app", root_path, id: "logo" %>
   <nav>
     <ul class="nav navbar-nav navbar-right">
       <li><%= link_to "Home", root_path %></li>
       <li><%= link_to "Help", help_path %></li>
       <% if logged_in? %>
         <li><%= link_to "Users", '#' %></li>
         <li class="dropdown">
           <a href="#" class="dropdown-toggle" data-toggle="dropdown">
             Account <b class="caret"></b>
           </a>
           <ul class="dropdown-menu">
             <li><%= link_to "Profile", current_user %></li>
             <li><%= link_to "Settings", '#' %></li>
             <li class="divider"></li>
             <li>
               <%= link_to "Log out", logout_path, method: :delete %>
             </li>
           </ul>
         </li>
       <% else %>
         <li><%= link_to "Log in", login_path %></li>
       <% end %>
     </ul>
   </nav>
 </div>
</header>
----------------------------
application.jsにBootstrapのJavaScriptライブラリを追加する
app/assets/javascripts/application.js
----------------------------
//= require rails-ujs
//= require jquery
//= require bootstrap
//= require turbolinks
//= require_tree .
----------------------------

・8 2 4 レイアウトの変更をテストする

以下の手順でテストを作成する
ログイン用のパスを書く
セッション用パスに有効な情報をpostする
ログイン用リンクが表示されなくなったことを確認する
ログアウト用リンクが表示されていることを確認する
プロフィール用リンクが表示されていることを確認する

登録済みユーザーとしてログインしておく必要がある


fixture向けのdigestメソッドを追加する
app/models/user.rb
----------------------------
class User < ApplicationRecord
 before_save { self.email = email.downcase }
 validates :name,  presence: true, length: { maximum: 50 }
 VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
 validates :email, presence: true, length: { maximum: 255 },
                   format: { with: VALID_EMAIL_REGEX },
                   uniqueness: { case_sensitive: false }
 has_secure_password
 validates :password, presence: true, length: { minimum: 6 }
 # 渡された文字列のハッシュ値を返す
 def User.digest(string)
   cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                 BCrypt::Engine.cost
   BCrypt::Password.create(string, cost: cost)
 end
end
----------------------------
ユーザーログインのテストで使うfixture
test/fixtures/users.yml
----------------------------
michael:
 name: Michael Example
 email: michael@example.com
 password_digest: <%= User.digest('password') %>
----------------------------

<%= User.digest('password') %>でテストユーザー用の有効なパスワードを作成できる

有効な情報を使ってユーザーログインをテストする green
test/integration/users_login_test.rb
----------------------------
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
 def setup
   @user = users(:michael)
 end
 .
 .
 .
 test "login with valid information" do
   get login_path
   post login_path, params: { session: { email:    @user.email,
                                         password: 'password' } }
   assert_redirected_to @user
   follow_redirect!
   assert_template 'users/show'
   assert_select "a[href=?]", login_path, count: 0
   assert_select "a[href=?]", logout_path
   assert_select "a[href=?]", user_path(@user)
 end
end
----------------------------


assert_redirected_to @user
はリダイレクト先が正しいかどうかをチェックしている。

テストで問題ないことを確認!

・8 2 5 ユーザー登録時にログイン

ユーザー登録時にログアウトしたままだと戸惑うので、
ログイン状態にしておく

ユーザー登録中にログインする
app/controllers/users_controller.rb
----------------------------
class UsersController < ApplicationController
 def show
   @user = User.find(params[:id])
 end
 def new
   @user = User.new
 end
 def create
   @user = User.new(user_params)
   if @user.save
     log_in @user
     flash[:success] = "Welcome to the Sample App!"
     redirect_to @user
   else
     render 'new'
   end
 end
 private
   def user_params
     params.require(:user).permit(:name, :email, :password,
                                  :password_confirmation)
   end
end
----------------------------
テスト中のログインステータスを論理値で返すメソッド
test/test_helper.rb
----------------------------
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
 fixtures :all
 # テストユーザーがログイン中の場合にtrueを返す
 def is_logged_in?
   !session[:user_id].nil?
 end
end
----------------------------
ユーザー登録後のログインのテスト green
test/integration/users_signup_test.rb
----------------------------
require 'test_helper'
class UsersSignupTest < ActionDispatch::IntegrationTest
 .
 .
 .
 test "valid signup information" do
   get signup_path
   assert_difference 'User.count', 1 do
     post users_path, params: { user: { name:  "Example User",
                                        email: "user@example.com",
                                        password:              "password",
                                        password_confirmation: "password" } }
   end
   follow_redirect!
   assert_template 'users/show'
   assert is_logged_in?
 end
end
----------------------------

テストOK

・8 3 ログアウト

log_outメソッド
app/helpers/sessions_helper.rb
----------------------------
module SessionsHelper
 # 渡されたユーザーでログインする
 def log_in(user)
   session[:user_id] = user.id
 end
 .
 .
 .
 # 現在のユーザーをログアウトする
 def log_out
   session.delete(:user_id)
   @current_user = nil
 end
end
----------------------------
セッションを破棄する (ユーザーのログアウト)
app/controllers/sessions_controller.rb
----------------------------
class SessionsController < ApplicationController
 def new
 end
 def create
   user = User.find_by(email: params[:session][:email].downcase)
   if user && user.authenticate(params[:session][:password])
     log_in user
     redirect_to user
   else
     flash.now[:danger] = 'Invalid email/password combination'
     render 'new'
   end
 end
 def destroy
   log_out
   redirect_to root_url
 end
end
ログアウト
----------------------------
ユーザーログアウトのテスト green
test/integration/users_login_test.rb
----------------------------
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
 .
 .
 .
 test "login with valid information followed by logout" do
   get login_path
   post login_path, params: { session: { email:    @user.email,
                                         password: 'password' } }
   assert is_logged_in?
   assert_redirected_to @user
   follow_redirect!
   assert_template 'users/show'
   assert_select "a[href=?]", login_path, count: 0
   assert_select "a[href=?]", logout_path
   assert_select "a[href=?]", user_path(@user)
   delete logout_path
   assert_not is_logged_in?
   assert_redirected_to root_url
   follow_redirect!
   assert_select "a[href=?]", login_path
   assert_select "a[href=?]", logout_path,      count: 0
   assert_select "a[href=?]", user_path(@user), count: 0
 end
end
----------------------------

ここでtestしてOKなことを確認!


・8 4 最後に

できた!

https://jun8.herokuapp.com/

・8 4 1 本章のまとめ

Railsのsessionメソッドを使うと、あるページから別のページに移動するときの状態を保持できる。
一時的な状態の保存にはcookiesも使える
ログインフォームでは、ユーザーがログインするための新しいセッションが作成できる
flash.nowメソッドを使うと、描画済みのページにもフラッシュメッセージを表示できる
テスト駆動開発は、回帰バグを防ぐときに便利
sessionメソッドを使うと、ユーザーIDなどをブラウザに一時的に保存できる
ログインの状態に応じて、ページ内で表示するリンクを切り替えることができる
統合テストでは、ルーティング、データベースの更新、レイアウトの更新が正しく行われているかを確認できる


この記事が気に入ったらサポートをしてみませんか?