読者です 読者をやめる 読者になる 読者になる

techium

このブログは何かに追われないと頑張れない人たちが週一更新をノルマに技術情報を発信するブログです。もし何か調査して欲しい内容がありましたら、@kobashinG or @muchiki0226 までいただけますと気が向いたら調査するかもしれません。

Rails Tutorial 5 Basic login

Rails Tutorial 5 Basic login

今回は Chapter 8/ Basic login | Ruby on Rails Tutorial (Rails 5) | Softcover.ioに沿って認証システムを導入し、ユーザーがログインとログアウトをできるように作り変えていく。

Sessions

トピックブランチで作業を進める。

$ git checkout -b basic-login

ログインとログアウトの要素を、Sessionsコントローラの特定のRESTアクションにそれぞれ対応付ける。

  • new アクションでログインのフォームを処理
  • create アクションへの POST リクエストでログイン
  • destroy アクションへの DELETE リクエストでログアウト

これらを実装していく。

まずは Sessions コントローラとnew アクションを生成する。

 $ rails generate controller Sessions new           
Running via Spring preloader in process 498610
      create  app/controllers/sessions_controller.rb
       route  get 'sessions/new'
      invoke  erb
      create    app/views/sessions
      create    app/views/sessions/new.html.erb
      invoke  test_unit
      create    test/controllers/sessions_controller_test.rb
      invoke  helper
      create    app/helpers/sessions_helper.rb
      invoke    test_unit
      invoke  assets
      invoke    coffee
      create      app/assets/javascripts/sessions.coffee
      invoke    scss
      create      app/assets/stylesheets/sessions.scss

なお、rails generateでnewアクションを生成すると、それに対応するビューも生成されます。createやdestroyには対応するビューが必要ないので、無駄なビューを作成しないためにここではnewだけを指定しています。

とある通り、new ではログインのフォームを表示するビューが必要なため上記で自動生成し、createとdestroyはビューが不要なため指定から外している。

次に名前付きルーティングを設定する。

config/routes.rb に以下を追記する。

  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'

上記に合わせ、自動生成されたテスト test/controllers/sessions_controller_test.rb を下記のように書き換える。

・
・
・
  test "should get new" do
    get login_path
    assert_response :success
  end
・
・
・

全ルーティングは下記の通り。

$ 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

Login form

ここからログインフォームを作成していく。

フォーム自体はユーザ登録フォームと大差ないが、 - 表示するフィールドが異なる - セッションは Active Record オブジェクトではないのでエラーは今回は flash で表示する

と言った違いがある。

  • モデルが無い場合のform_forヘルパーの使用方法
  • form から送信されたメールアドレス、パスワードと言ったユーザ認証に必要な情報を取り出す方法
  • flash は表示後リダイレクトで消すか再描画だけで消したいかで .now との使い分けが必要
  • “エラーをキャッチするテストを先に書いて、そのエラーが解決するようにコードを書く” の実践

と進んでいく。

Logging in

Rails で定義されている session メソッドを使って、ブラウザを終了すると期限が切れる一時 cookies での単純なログインを実装していく。

Sessions コントローラを生成した際に自動生成されているセッション用ヘルパーを、全コントローラで使用できるようにベースクラスにインクルードする。

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

次に Sessions コントローラに log_in 関数を定義する。

app/helpers/sessions_helper.rb

module SessionsHelper

  # Logs in the given user.
  def log_in(user)
    session[:user_id] = user.id
  end
end

sessionメソッドで作成した一時cookiesは自動的に暗号化され、リスト 8.14のコードは保護されます。そしてここが重要なのですが、攻撃者がたとえこの情報をcookiesから盗み出すことができたとしても、それを使って本物のユーザーとしてログインすることはできないのです。

ふむふむ

ただし今述べたことは、sessionメソッドで作成した「一時セッション」にしか該当しません。cookiesメソッドで作成した「永続的セッション」ではそこまで断言はできません。永続的なcookiesには、セッションハイジャックという攻撃を受ける可能性が常につきまといます。

ふむふむ? ここについては次の章で、だそうな。

このヘルパーメソッドを使用してユーザーログインを行ってセッションのcreateアクションを完了し、ユーザーのプロフィールページにリダイレクトする。

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の情報を調べると。。? 後でやってみよう。

ログイン済みのユーザ名を表示するためにヘルパーにメソッドを追加する。

app/helpers/sessions_helper.rb

module SessionsHelper

  # Logs in the given user.
  def log_in(user)
    session[:user_id] = user.id
  end

  # Returns the current logged-in user (if any).
  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end
end

ログイン中とそうでないときでレイアウトを変更する。

ログイン中には以下のリンクを表示するようにする。 - ログアウト - ユーザー設定 - ユーザー一覧 - プロフィール表示

統合テストを先に書くことが望ましいかもしれないがここではまた新しいことを学ぶので先に実装してしまいましょう、だそうな。臨機応変、臨機応変。

まずログイン中かどうかを返すメソッドをヘルパーに追加する。

app/helpers/sessions_helper.rb

  # Returns true if the user is logged in, false otherwise.
  def logged_in?
    !current_user.nil?
  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>

ドロップダウンメニューが使用されている。

これらのドロップダウンの機能を有効にするため、Railsのapplication.jsファイルを通して、アセットパイプラインにBootstrapのカスタムJavaScriptライブラリをインクルードするように指示します

ほうほう。

app/assets/javascripts/application.js

//= require jquery
//= require jquery_ujs
//= require bootstrap
//= require turbolinks
//= require_tree .

これでログイン中とそうでないときでレイアウトを変更する実装が(まだ途中だが)できたのでテストを作成していく。

ログイン時の動作を確認するためには有効なユーザが登録されている必要があるので fixture を使う。

まず app/models/user.rb に fixture 向け digest メソッドを追加する。

  # Returns the hash digest of the given string.
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

上記を使った fixture を作成する。
test/fixtures/users.yml

michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

これでテストは GREEN になる。

Login upon signup

ユーザ登録が終わったユーザにログインさせては手間なので、ユーザ登録完了時に自動でログインするよう実装を変更する。
と言ってもUsersコントローラのcreateアクションにlog_inを足すだけだそうな。

app/controllers/users_controller.rb

  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else

この動作を確認するようテストを変更する。
ヘルパーメソッドはテストから呼び出しできないのでsessionメソッドを使ってテストヘルパーを作成すると良いそうな。

test/test_helper.rb

  # Returns true if a test user is logged in.
  def is_logged_in?
    !session[:user_id].nil?
  end

これで、ユーザ登録完了時にログイン状態になっているかどうかを以下のようにテストできるようになる。
test/integration/users_signup_test.rb

    assert is_logged_in?

Logging out

ログアウト機能を実装する。

app/helpers/sessions_helper.rb に log_out メソッドを定義する。

  # Logs out the current user.
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end

これを Sessions コントローラの destroy アクションから呼び出す。

app/controllers/sessions_controller.rb

  def destroy
    log_out
    redirect_to root_url
  end

これで実装は完了。テストを修正していく。

test/integration/users_login_test.rb

  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

自動テストが走り、結果全テストスイートが GREEN となっていることが確認できる。

[1] guard(main)> 
22:51:51 - INFO - Run all
22:51:51 - INFO - Running: all tests
Running via Spring preloader in process 198087
Started with run options --seed 64342

  22/22: [=========================================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.97809s
22 tests, 58 assertions, 0 failures, 0 errors, 0 skips

あとはいつも通り master にマージしてリモートにプッシュ。Heroku へのデプロイも行う。

furue:~/workspace/sample_app (basic-login) $ git add -A
kfurue:~/workspace/sample_app (basic-login) $ git commit -m "Implement basic login"                                                     
[basic-login 55d4b22] Implement basic login
 16 files changed, 160 insertions(+), 11 deletions(-)
 create mode 100644 app/assets/javascripts/sessions.coffee
 create mode 100644 app/assets/stylesheets/sessions.scss
 create mode 100644 app/controllers/sessions_controller.rb
 create mode 100644 app/helpers/sessions_helper.rb
 create mode 100644 app/views/sessions/new.html.erb
 create mode 100644 test/controllers/sessions_controller_test.rb
 create mode 100644 test/integration/users_login_test.rb
kfurue:~/workspace/sample_app (basic-login) $ git checkout master 
Switched to branch 'master'
Your branch is up-to-date with 'origin/master'.
kfurue:~/workspace/sample_app (master) $ git merge basic-login 
Updating 11c9daa..55d4b22
Fast-forward
 app/assets/javascripts/application.js        |  1 +
 app/assets/javascripts/sessions.coffee       |  3 +++
 app/assets/stylesheets/sessions.scss         |  3 +++
 app/controllers/application_controller.rb    |  5 +----
 app/controllers/sessions_controller.rb       | 21 +++++++++++++++++++++
 app/controllers/users_controller.rb          |  5 +++--
 app/helpers/sessions_helper.rb               | 23 +++++++++++++++++++++++
 app/models/user.rb                           |  7 +++++++
 app/views/layouts/_header.html.erb           | 23 ++++++++++++++++++++---
 app/views/sessions/new.html.erb              | 19 +++++++++++++++++++
 config/routes.rb                             |  3 +++
 test/controllers/sessions_controller_test.rb |  9 +++++++++
 test/fixtures/users.yml                      |  5 ++++-
 test/integration/users_login_test.rb         | 38 ++++++++++++++++++++++++++++++++++++++
 test/integration/users_signup_test.rb        |  1 +
 test/test_helper.rb                          |  5 ++++-
 16 files changed, 160 insertions(+), 11 deletions(-)
 create mode 100644 app/assets/javascripts/sessions.coffee
 create mode 100644 app/assets/stylesheets/sessions.scss
 create mode 100644 app/controllers/sessions_controller.rb
 create mode 100644 app/helpers/sessions_helper.rb
 create mode 100644 app/views/sessions/new.html.erb
 create mode 100644 test/controllers/sessions_controller_test.rb
 create mode 100644 test/integration/users_login_test.rb
kfurue:~/workspace/sample_app (master) $ rails test
Running via Spring preloader in process 198402
Started with run options --seed 40205

  22/22: [=========================================================================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.05401s
22 tests, 58 assertions, 0 failures, 0 errors, 0 skips

kfurue:~/workspace/sample_app (master) $ git push 
Warning: Permanently added 'bitbucket.org,104.192.143.2' (RSA) to the list of known hosts.
Counting objects: 32, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (31/31), done.
Writing objects: 100% (32/32), 4.64 KiB | 0 bytes/s, done.
Total 32 (delta 17), reused 0 (delta 0)
To git@bitbucket.org:kfurue/sample_app.git
   11c9daa..55d4b22  master -> master
kfurue:~/workspace/sample_app (master) $ git push heroku 
Counting objects: 32, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (31/31), done.
Writing objects: 100% (32/32), 4.64 KiB | 0 bytes/s, done.
Total 32 (delta 17), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote: 
remote: -----> Ruby app detected
remote: -----> Compiling Ruby/Rails
remote:        Your app was upgraded to bundler 1.13.7.
remote:        Previously you had a successful deploy with bundler 1.13.6.
remote:        
remote:        If you see problems related to the bundler version please refer to:
remote:        https://devcenter.heroku.com/articles/bundler-version
remote: -----> Using Ruby version: ruby-2.2.4
remote: -----> Installing dependencies using bundler 1.13.7
remote:        Running: bundle install --without development:test --path vendor/bundle --binstubs vendor/bundle/bin -j4 --deployment
remote:        Fetching gem metadata from https://rubygems.org/.........
remote:        Fetching version metadata from https://rubygems.org/..
remote:        Fetching dependency metadata from https://rubygems.org/.
remote:        Using rake 11.2.2
remote:        Using i18n 0.7.0
remote:        Using minitest 5.9.0
remote:        Using concurrent-ruby 1.0.2
remote:        Using thread_safe 0.3.5
remote:        Using builder 3.2.2
remote:        Using erubis 2.7.0
remote:        Using mini_portile2 2.1.0
remote:        Using pkg-config 1.1.7
remote:        Using rack 2.0.1
remote:        Using nio4r 1.2.1
remote:        Using websocket-extensions 0.1.2
remote:        Using mime-types-data 3.2016.0521
remote:        Using arel 7.1.1
remote:        Using execjs 2.7.0
remote:        Using bcrypt 3.1.11
remote:        Using sass 3.4.22
remote:        Using coffee-script-source 1.10.0
remote:        Using method_source 0.8.2
remote:        Using thor 0.19.1
remote:        Using multi_json 1.12.1
remote:        Using pg 0.18.4
remote:        Using puma 3.4.0
remote:        Using bundler 1.13.7
remote:        Using tilt 2.0.5
remote:        Using turbolinks-source 5.0.0
remote:        Using tzinfo 1.2.2
remote:        Using rack-test 0.6.3
remote:        Using sprockets 3.7.0
remote:        Using websocket-driver 0.6.4
remote:        Using mime-types 3.1
remote:        Using autoprefixer-rails 6.5.0.1
remote:        Using uglifier 3.0.0
remote:        Using nokogiri 1.6.8
remote:        Using turbolinks 5.0.1
remote:        Using activesupport 5.0.0.1
remote:        Using mail 2.6.4
remote:        Using bootstrap-sass 3.3.6
remote:        Using coffee-script 2.4.1
remote:        Using loofah 2.0.3
remote:        Using rails-dom-testing 2.0.1
remote:        Using globalid 0.3.7
remote:        Using activemodel 5.0.0.1
remote:        Using jbuilder 2.4.1
remote:        Using rails-html-sanitizer 1.0.3
remote:        Using activejob 5.0.0.1
remote:        Using activerecord 5.0.0.1
remote:        Using actionview 5.0.0.1
remote:        Using actionpack 5.0.0.1
remote:        Using actioncable 5.0.0.1
remote:        Using actionmailer 5.0.0.1
remote:        Using railties 5.0.0.1
remote:        Using sprockets-rails 3.2.0
remote:        Using coffee-rails 4.2.1
remote:        Using jquery-rails 4.1.1
remote:        Using rails 5.0.0.1
remote:        Using sass-rails 5.0.6
remote:        Bundle complete! 22 Gemfile dependencies, 57 gems now installed.
remote:        Gems in the groups development and test were not installed.
remote:        Bundled gems are installed into ./vendor/bundle.
remote:        Bundle completed (4.11s)
remote:        Cleaning up the bundler cache.
remote:        Removing bundler (1.13.6)
remote: -----> Detecting rake tasks
remote: -----> Preparing app for Rails asset pipeline
remote:        Running: rake assets:precompile
remote:        I, [2017-01-26T22:56:19.431707 #422]  INFO -- : Writing /tmp/build_506d95587b2555e8da043d094b950b40/public/assets/application-0818f0786393846d9323fc0d27121442c638ab69ec67b3ebe5e0050e00c18c01.js
remote:        I, [2017-01-26T22:56:19.468053 #422]  INFO -- : Writing /tmp/build_506d95587b2555e8da043d094b950b40/public/assets/application-0818f0786393846d9323fc0d27121442c638ab69ec67b3ebe5e0050e00c18c01.js.gz
remote:        Asset precompilation completed (11.56s)
remote:        Cleaning assets
remote:        Running: rake assets:clean
remote:        I, [2017-01-26T22:56:25.901020 #433]  INFO -- : Removed application-db5fed084311c4f37b7088efa1db9925ddf5cd86e135ad019fea6c0bc4abebae.js
remote: 
remote: ###### WARNING:
remote:        You have not declared a Ruby version in your Gemfile.
remote:        To set your Ruby version add this line to your Gemfile:
remote:        ruby '2.2.4'
remote:        # See https://devcenter.heroku.com/articles/ruby-versions for more information.
remote: 
remote: -----> Discovering process types
remote:        Procfile declares types     -> web
remote:        Default types for buildpack -> console, rake, worker
remote: 
remote: -----> Compressing...
remote:        Done: 30.2M
remote: -----> Launching...
remote:        Released v11
remote:        https://fierce-wave-40771.herokuapp.com/ deployed to Heroku
remote: 
remote: Verifying deploy... done.
To https://git.heroku.com/fierce-wave-40771.git
   11c9daa..55d4b22  master -> master