学習日記

Ruby on Rails勉強してます

今日やったこと11

第11章 アカウントの有効化

アカウントを有効化するステップを新規登録の途中に差し込むことで、本当にそのメールアドレスの持ち主なのかどうかを確認できるようにする。

アカウントを有効化する手順

1.ユーザーの初期状態は「有効化されていない」(unactivated) にしておく。

2.ユーザー登録が行われたときに、有効化トークンと、それに対応する有効化ダイジェストを生成する。

3.有効化ダイジェストはデータベースに保存しておき、有効化トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく。

4.ユーザーがメールのリンクをクリックしたら、アプリケーションはメールアドレスをキーにしてユーザーを探し、データベース内に保存しておいた有効化ダイジェストと比較することでトークンを認証する。

5.ユーザーを認証できたら、ユーザーのステータスを「有効化されていない」から「有効化済み」(activated) に変更する。

11.1 AccountActivationsリソース

新機能用のトピックブランチを作成。

$ git checkout -b account-activation

まずはAccountActivationsコントローラを生成。

$ rails generate controller AccountActivations

ルーティングにアカウント有効化用のresources行を追加。

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
  resources :account_activations, only: [:edit]
end

Userモデルに3つの属性を追加する。

$ rails generate migration add_activation_to_users \
> activation_digest:string activated:boolean activated_at:datetime

マイグレーションファイルでactivated属性のデフォルトの論理値をfalseに設定して実行。

db/migrate/[timestamp]_add_activation_to_users.rb
 class AddActivationToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :activation_digest, :string
    add_column :users, :activated, :boolean, default: false
    add_column :users, :activated_at, :datetime
  end
end
$ rails db:migrate

Userモデルにアカウント有効化のコードを追加する。

app/models/user.rb

 class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token
  before_save   :downcase_email

  # オブジェクトが作成されたときだけコールバックを呼び出す。
  before_create :create_activation_digest

  validates :name,  presence: true, length: { maximum: 50 }
  .
  .
  .
  private

    # メールアドレスをすべて小文字にする
    def downcase_email
      self.email = email.downcase
    end

    # 有効化トークンとダイジェストを作成および代入する
    def create_activation_digest
      self.activation_token  = User.new_token
      self.activation_digest = User.digest(activation_token)
    end
end

サンプルデータとfixtureを更新し、テスト時のサンプルとユーザーを事前に有効化しておく。

db/seeds.rb

 User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin:     true,
             activated: true,
             activated_at: Time.zone.now)

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
              email: email,
              password:              password,
              password_confirmation: password,
              activated: true,
              activated_at: Time.zone.now)
end
test/fixtures/users.yml
 michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true
  activated: true
  activated_at: <%= Time.zone.now %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

malory:
  name: Malory Archer
  email: boss@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>
<% end %>

データベースを初期化して、サンプルデータを再度生成し直し、上記の変更を反映する。

$ rails db:migrate:reset
$ rails db:seed


11.2 アカウント有効化のメール送信

Action Mailerライブラリを使ってUserのメイラーを追加する。
メイラーはUsersコントローラのcreateアクションで有効化リンクをメール送信するために使う。
メールのテンプレートの中に有効化トークンとメールアドレス (= 有効にするアカウントのアドレス) のリンクを含めて、ビューと同じ要領で定義できる。

rails generateでメイラーを生成。

$ rails generate mailer UserMailer account_activation password_reset

生成されたメイラーのテンプレートを使えるように変更する。

app/mailers/application_mailer.rb

 class ApplicationMailer < ActionMailer::Base
  default from: "noreply@example.com"
  layout 'mailer'
end
app/mailers/user_mailer.rb

class UserMailer < ApplicationMailer

  def account_activation(user)
    @user = user
    mail to: user.email, subject: "Account activation"
  end

  def password_reset
    @greeting = "Hi"

    mail to: "to@example.org"
  end
end

生成されたビューのテンプレートの2つを変更。

app/views/user_mailer/account_activation.text.erb

 Hi <%= @user.name %>,

Welcome to the Sample App! Click on the link below to activate your account:

<%= edit_account_activation_url(@user.activation_token, email: @user.email) %>
app/views/user_mailer/account_activation.html.erb

 <h1>Sample App</h1>

<p>Hi <%= @user.name %>,</p>

<p>
Welcome to the Sample App! Click on the link below to activate your account:
</p>

<%= link_to "Activate", edit_account_activation_url(@user.activation_token,
                                                    email: @user.email) %>
演習 11.2.1.1
>> CGI.escape("Don't panic!")
=> "Don%27t+panic%21"

送信メールのプレビュー

Railsでは、特殊なURLにアクセスするとメールのメッセージをその場でプレビューすることができる。これを利用するために、アプリケーションのdevelopment環境の設定に手を加える。

config/environments/development.rb

 Rails.application.configure do
  .
  .
  .
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :test
  host = 'localhost:3000' #  自分の環境に合わせて変更する。
  config.action_mailer.default_url_options = { host: host, protocol: 'https' }
  .
  .
  .
end

developmentサーバーを再起動して上記の設定を読み込み、自動生成したUserメイラーのプレビューファイルを更新する。

test/mailers/previews/user_mailer_preview.rb

 # Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/account_activation
  def account_activation
    user = User.first
    user.activation_token = User.new_token
    UserMailer.account_activation(user)
  end

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/password_reset
  def password_reset
    UserMailer.password_reset
  end
end

下記のURLでアカウント有効化メールの内容を確認。

http://localhost:3000/rails/mailers/user_mailer/account_activation
http://localhost:3000/rails/mailers/user_mailer/account_activation.txt

Railsによる自動生成された送信メールのテストを変更。

test/mailers/user_mailer_test.rb

 require 'test_helper'

class UserMailerTest < ActionMailer::TestCase

  test "account_activation" do
    user = users(:michael)
    user.activation_token = User.new_token
    mail = UserMailer.account_activation(user)
    assert_equal "Account activation", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.name,               mail.body.encoded
    assert_match user.activation_token,   mail.body.encoded
    assert_match CGI.escape(user.email),  mail.body.encoded
  end
end

テストファイル内のドメイン名を正しく設定する。

config/environments/test.rb

Rails.application.configure do
  .
  .
  .
  config.action_mailer.delivery_method = :test
  config.action_mailer.default_url_options = { host: 'example.com' }
  .
  .
  .
end


ユーザーのcreateアクションを更新する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      UserMailer.account_activation(@user).deliver_now
      flash[:info] = "Please check your email to activate your account."
      redirect_to root_url
    else
      render 'new'
    end
  end
  .
  .
  .
end


11.3 アカウントを有効化する

authenticated?メソッドを抽象化する。

app/models/user.rb

 class User < ApplicationRecord
  .
  .
  .
  # トークンがダイジェストと一致したらtrueを返す
  def authenticated?(attribute, token)
    digest = send("#{attribute}_digest")
    return false if digest.nil?
    BCrypt::Password.new(digest).is_password?(token)
  end
  .
  .
  .

editアクションでユーザーを有効化する。

app/controllers/account_activations_controller.rb

class AccountActivationsController < ApplicationController

  def edit
    user = User.find_by(email: params[:email])
    if user && !user.activated? && user.authenticated?(:activation, params[:id])
      user.update_attribute(:activated,    true)
      user.update_attribute(:activated_at, Time.zone.now)
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  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])
      if user.activated?
        log_in user
        params[:session][:remember_me] == '1' ? remember(user) : forget(user)
        redirect_back_or user
      else
        message  = "Account not activated. "
        message += "Check your email for the activation link."
        flash[:warning] = message
        redirect_to root_url
      end
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

アカウント有効化の統合テストを追加する。

test/integration/users_signup_test.rb

require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear # deliveriesは変数なので、setupメソッドでこれを初期化
  end

  test "invalid signup information" do
    get signup_path
    assert_no_difference 'User.count' do
      post users_path, params: { user: { name:  "",
                                         email: "user@invalid",
                                         password:              "foo",
                                         password_confirmation: "bar" } }
    end
    assert_template 'users/new'
    assert_select 'div#error_explanation'
    assert_select 'div.field_with_errors'
  end
    
  test "valid signup information with account activation" 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
    assert_equal 1, ActionMailer::Base.deliveries.size
    user = assigns(:user) #assignsメソッドを使うと対応するアクション内のインスタンス変数にアクセスできる
    assert_not user.activated?
    # 有効化していない状態でログインしてみる
    log_in_as(user)
    assert_not is_logged_in?
    # 有効化トークンが不正な場合
    get edit_account_activation_path("invalid token", email: user.email)
    assert_not is_logged_in?
    # トークンは正しいがメールアドレスが無効な場合
    get edit_account_activation_path(user.activation_token, email: 'wrong')
    assert_not is_logged_in?
    # 有効化トークンが正しい場合
    get edit_account_activation_path(user.activation_token, email: user.email)
    assert user.reload.activated?
    follow_redirect!
    assert_template 'users/show'
    assert is_logged_in?
  end
end

リファクタリングのため、Userモデルにユーザー有効化メソッドを追加する。
activateメソッドはユーザーの有効化属性を更新し、send_activation_emailメソッドは有効化メールを送信する。

app/models/user.rb

class User < ApplicationRecord
  .
  .
  .
  # アカウントを有効にする
  def activate
    update_attribute(:activated,    true)
    update_attribute(:activated_at, Time.zone.now)
  end

  # 有効化用のメールを送信する
  def send_activation_email
    UserMailer.account_activation(self).deliver_now
  end

  private
    .
    .
    .
end

作成したメソッドを各アクションで使用する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      @user.send_activation_email # send_activation_emailメソッド
      flash[:info] = "Please check your email to activate your account."
      redirect_to root_url
    else
      render 'new'
    end
  end
  .
  .
  .
end
app/controllers/account_activations_controller.rb

class AccountActivationsController < ApplicationController

  def edit
    user = User.find_by(email: params[:email])
    if user && !user.activated? && user.authenticated?(:activation, params[:id])
      user.activate # activateメソッド
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  end
end

rails server実行時にエラーがでて起動しない

rails s

上記のコマンドを実行

ec2-user:~/environment/sample_app (updateing-users) $ rails s
=> Booting Puma
=> Rails 5.1.4 application starting in development 
=> Run `rails server -h` for more startup options
[12158] Puma starting in cluster mode...
[12158] * Version 3.9.1 (ruby 2.4.1-p111), codename: Private Caller
[12158] * Min threads: 5, max threads: 5
[12158] * Environment: development
[12158] * Process workers: 2
[12158] * Preloading application
[12158] * Listening on tcp://localhost:8080
Exiting

といったログが出てサーバが起動しなくなりました


コマンドでプロセスを確認してみると

ps aux | grep puma

pumaのプロセスが2つあるのがいけないのかな

ec2-user  4090  0.0 11.2 875716 114244 ?       Sl   00:45   0:01 puma: cluster worker 0: 4081 [sample_app]            
ec2-user  4997  0.0 17.0 939724 172476 ?       Sl   01:52   0:03 puma: cluster worker 1: 4081 [sample_app]            
ec2-user 12324  0.0  0.2 110520  2072 pts/1    S+   05:31   0:00 grep --color=auto puma

一応killコマンドで2つとも排除します

kill -9 4090
kill -9 4997
rails s

再び上記コマンドを実行して無事起動できました

今日やったこと10

第10章ユーザーの更新・表示・削除  続きから



フレンドリーフォワーディングを実装する。
ユーザーを希望のページに転送するようにさせるため、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある。
Sessionsヘルパーでstore_locationとredirect_back_orの2つのメソッドを定義して使用する。

app/helpers/sessions_helper.rb

 module SessionsHelper
  .
  .
  .
  # 記憶したURL (もしくはデフォルト値) にリダイレクト
  def redirect_back_or(default)
    # リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクト
    redirect_to(session[:forwarding_url] || default)
    # 転送用のURLを削除
    session.delete(:forwarding_url)
  end

  # アクセスしようとしたURLを覚えておく
  def store_location
    # GETリクエストが送られたときだけrequest.original_urlでリクエスト先を取得しsessionに保存
    session[:forwarding_url] = request.original_url if request.get?
  end
end
app/controllers/users_controller.rb

def logged_in_user
    unless logged_in?
      store_location #ログインユーザー用beforeフィルターにstore_locationを追加
      flash[:danger] = "Please log in."
      redirect_to login_url
    end
end
app/controllers/sessions_controller.rb

def create
  user = User.find_by(email: params[:session][:email].downcase)
  if user && user.authenticate(params[:session][:password])
    log_in user
    params[:session][:remember_me] == '1' ? remember(user) : forget(user)
    redirect_back_or user #redirect_back_orを追加
  else
    flash.now[:danger] = 'Invalid email/password combination'
    render 'new'
  end
end


10.3 すべてのユーザーを表示する

indexアクションを追加し、すべてのユーザーを一覧表示する。
ユーザーのshowページについては、ログインしているかどうかに関わらずサイトを訪れたすべてのユーザーから見えるようにするが、ユーザーのindexページはログインしたユーザーにしか見せないようにして、未登録のユーザーがデフォルトで表示できるページを制限する。

indexページを不正なアクセスから守るために、まずはindexアクションが正しくリダイレクトするか検証するテストを書く。

test/controllers/users_controller_test.rb

 require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end

  # indexアクションのリダイレクトをテスト
  test "should redirect index when not logged in" do
    get users_path
    assert_redirected_to login_url
  end
  .
  .
  .
end

次に、beforeフィルターのlogged_in_userにindexアクションを追加して、このアクションを保護する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]  <==
  before_action :correct_user,   only: [:edit, :update]

  def index
  end
  .
  .
  .
end

すべてのユーザーを表示するために、全ユーザーが格納された変数を作成し、順々に表示するindexビューを実装する。

def index
  @users = User.all
end
app/views/users/index.html.erb

<% provide(:title, 'All users') %>
<h1>All users</h1>

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

CSSでレイアウトを調整し、ヘッダーにユーザー一覧表示用のリンクを追加する。

app/assets/stylesheets/custom.scss

 .
.
.
/* Users index */

.users {
  list-style: none;
  margin: 0;
  li {
    overflow: auto;
    padding: 10px 0;
    border-bottom: 1px solid $gray-lighter;
  }
}
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", users_path %></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", edit_user_path(current_user) %></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>


演習 10.3.1.1

test/integration/site_layout_test.rb

require 'test_helper'

class SiteLayoutTest < ActionDispatch::IntegrationTest
  
  def setup
    @user = users(:michael)
  end
  ・
  ・
  ・
  # ログイン時のレイアウトリンクに対するテスト
  test "layout links when logged in" do
    log_in_as(@user)
    get root_path
    assert_template 'static_pages/home'
    assert_select "a[href=?]", users_path
    assert_select "a[href=?]", user_path(@user)
    assert_select "a[href=?]", edit_user_path(@user)    
    assert_select "a[href=?]", logout_path
  end
  
end


サンプルのユーザーを追加する。
まず、GemfileにFaker gem(実際にいそうなユーザー名を作成するgem)を追加する。

gem 'faker',          '1.7.3'

bundle installを実行

$ bundle install

サンプルユーザーを生成するRubyスクリプト (Railsタスクとも呼ぶ) を追加ためにRailsではdb/seeds.rbというファイルを標準として使う。

db/seeds.rb

 User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar")

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end

データベースをリセットして、Railsタスクを実行。

$ rails db:migrate:reset
$ rails db:seed


ページネーションを使い1つのページに表示するユーザーの数を制限する。
Railsには豊富なページネーションメソッドがあり、その中のwill_paginateメソッドを使う。
Bootstrapのページネーションスタイルも使いたいので、
will_paginate gem とbootstrap-will_paginate gemをGemfileに追加する。

gem 'will_paginate',           '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'

bundle installを実行

$ bundle install

ページネーションを動作させるため、ユーザーのページネーションを行うようにRailsに指示するwill_paginateメソッドををindexビューに追加する。

app/views/users/index.html.erb

 <% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

<%= will_paginate %>

will_paginateではpaginateメソッドを使った結果が必要なので
indexアクション内のallをpaginateメソッドに置き換える。
ここで:pageパラメーターにはparams[:page]が使われているが、これはwill_paginateによって自動的に生成されたものである。

def index
  @users = User.paginate(page: params[:page])
end



ページネーションに対するテストを書いていく。
テスト用のデータベースに31人以上のユーザーがいる必要があるのでfixtureファイルに追加する。
なお、今後必要になるので、2人の名前付きユーザーも一緒に追加しておく。

test/fixtures/users.yml

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

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>

malory:
  name: Malory Archer
  email: boss@example.gov
  password_digest: <%= User.digest('password') %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
<% end %>

indexページに対するテストのファイルを生成しコードを作成する。

$ rails generate integration_test users_index
      invoke  test_unit
      create    test/integration/users_index_test.rb
test/integration/users_index_test.rb

 require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  
  # ページネーションを含めたUsersIndexのテスト
  test "index including pagination" do
    log_in_as(@user) #ログイン
    get users_path #indexページにアクセス
    assert_template 'users/index' #最初のページにユーザーがいることを確認
    assert_select 'div.pagination' #ページネーションのリンクがあることを確認
    User.paginate(page: 1).each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
    end
  end
end

各ユーザーを表示するパーシャルを作りindexページをリファクタリングする

app/views/users/index.html.erb

<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <%= render @users %>
</ul>

<%= will_paginate %>
app/views/users/_user.html.erb

 <li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>




10.4 ユーザーを削除する

削除を実行できる権限を持つ管理 (admin) ユーザーを識別するために、論理値をとるadmin属性をUserモデルに追加する。

属性の型をbooleanと指定しマイグレーションを実行する。

$ rails generate migration add_admin_to_users admin:boolean
$ rails db:migrate

admin属性が追加されて値はデフォルトでfalseとなり、疑問符の付いたadmin?メソッドも利用できるようになる。

>> user.admin?
=> false

最初のユーザーだけをデフォルトで管理者にするようサンプルデータを更新し、
データベースをリセットして、サンプルデータを再度生成する。

b/seeds.rb

 User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin: true)

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end
$ rails db:migrate:reset
$ rails db:seed

演習 10.4.1.1

test/controllers/users_controller_test.rb
 require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  
  # admin属性の変更が禁止されていることをテスト
  test "should not allow the admin attribute to be edited via the web" do
    log_in_as(@other_user)
    assert_not @other_user.admin?
    patch user_path(@other_user), params: {
                                    user: { password:              @other_user,
                                            password_confirmation: @other_user,
                                            admin: true } }
    assert_not @other_user.reload.admin?
  end
  .
  .
  .
end

ユーザーindexページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限する。
これによって、現在のユーザーが管理者のときに限り [delete] リンクが表示されるようになる。

app/views/users/_user.html.erb

 <li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
  <% if current_user.admin? && !current_user?(user) %>
    | <%= link_to "delete", user, method: :delete,
                                  data: { confirm: "You sure?" } %>
  <% end %>
</li>

削除リンクを動作させるために、destroyアクションを追加する。
このアクションでは、該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除し、最後にユーザーindexに移動するようにする。
ユーザーを削除するためにはログインしていなくてはならないので、destroyアクションもlogged_in_userフィルターに追加する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def destroy
    User.find(params[:id]).destroy
    flash[:success] = "User deleted"
    redirect_to users_url
  end

  private
  .
  .
  .
end

サイトを正しく防衛するには、destroyアクションにもアクセス制御を行う必要があるので、
今回はbeforeフィルター(admin_user)を使ってdestroyアクションへのアクセスを制御する。

app/controllers/users_controller.rb

 class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  before_action :admin_user,     only: :destroy
  .
  .
  .
  private
    .
    .
    .
    # 管理者かどうか確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end

ユーザー削除のテストを書く。
まずはユーザー用fixtureファイルを修正し、今いるサンプルユーザーの一人を管理者にする。

test/fixtures/users.yml

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

管理者権限の制御をアクションレベルでテストする

test/controllers/users_controller_test.rb

 require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect destroy when not logged in" do
    assert_no_difference 'User.count' do
      delete user_path(@user)
    end
    assert_redirected_to login_url
  end

  test "should redirect destroy when logged in as a non-admin" do
    log_in_as(@other_user)
    assert_no_difference 'User.count' do
      delete user_path(@user)
    end
    assert_redirected_to root_url
  end
end

削除リンクとユーザー削除に対する統合テスト

test/integration/users_index_test.rb

 require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @admin     = users(:michael)
    @non_admin = users(:archer)
  end

  test "index as admin including pagination and delete links" do
    log_in_as(@admin)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination'
    first_page_of_users = User.paginate(page: 1)
    first_page_of_users.each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
      unless user == @admin
        assert_select 'a[href=?]', user_path(user), text: 'delete'
      end
    end
    assert_difference 'User.count', -1 do
      delete user_path(@non_admin)
    end
  end

  test "index as non-admin" do
    log_in_as(@non_admin)
    get users_path
    assert_select 'a', text: 'delete', count: 0
  end
end



すべての変更をmasterブランチにマージ

$ git add -A
$ git commit -m "Finish user edit, update, index, and destroy actions"
$ git checkout master
$ git merge updating-users
$ git push

今日やったこと9

第10章 ユーザーの更新・表示・削除

10.1 ユーザーを更新する

ユーザーを編集するためのeditアクションを作成する。

def edit
    @user = User.find(params[:id])
end


editアクションに対応するeditビューを実装
  app/views/users/edit.html.erb


WebブラウザはネイティブではPATCHリクエストを送信できないので、RailsはPOSTリクエストとinputフィールドを利用してPATCHリクエストを「偽造」している。

<input name="_method" type="hidden" value="patch" />


Railsは、form_for(@user)を使ってフォームを構成すると、@user.new_record?がtrueのときにはPOSTを、falseのときにはPATCHを使う。

$ rails console
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false


ヘッダーに”Setting”リンクを追加する。
editビューへのパスとcurrent_userのヘルパーメソッドを使う。

<%= link_to "Settings", edit_user_path(current_user) %>


演習
・10.1.1.1
target="_blank"で新しいページを開くときには、セキュリティのためrel属性にnoopenerを設定する。

<a href="http://gravatar.com/emails" target="_blank" rel="noopener">change</a>

演習
・10.1.3.1
assert_selectを使ってalertクラスのdivタグを探しだし、「The form contains 4 errors.」というエラーメッセージが表示されているかテストする行を追加する。

assert_select "div.alert", "The form contains 4 errors."


Updateアクションを書くまえに編集の成功に対するテストを書く。
テストを先に書くことでユーザーが実際に使うイメージや追加するコードがわかりやすくなる。

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

・
・
・
  
  # 編集の成功に対するテスト
  test "successful edit" do
    get edit_user_path(@user)
    assert_template 'user/edit'
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirect_to @user
    @user.reload
    assert_equal name, @user.name
    assert_equal email, @user.email
  end
end


ユーザー名やメールアドレスだけを編集したいときはパスワード欄を入力する必要はないが空のままだとバリテーションに引っかかってしまうので空のままでも更新できるようにvalidatesにallow_nil: trueというオプションを追加する。

validates :password, presence: true, length: { minimum: 6 }, allow_nil: true


updateアクションを作成しテストする。

def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
end

10.2 認可

今のままでは誰でも (ログインしていないユーザーでも) ユーザー情報を編集できてしまうのでユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないようにする。
ログインしていないユーザーが保護されたページにアクセスしようとしたときはログインページに転送して、メッセージも表示するようにする。
許可されていないページに対してアクセスするログイン済みのユーザーがいたら 、ルートURLにリダイレクトさせるようにする。

転送させる仕組みを実装したいときは、Usersコントローラの中でbeforeフィルターを使う。beforeフィルターは、before_actionメソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する仕組みである。

ユーザーにログインを要求するために、logged_in_userメソッドを定義してbefore_action :logged_in_userという形式で使用する。
デフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用されるので、ここでは適切な:onlyオプション (ハッシュ) を渡すことで、:editと:updateアクションだけにこのフィルタが適用されるように制限をかけている。

app/controllers/users_controller.rb


class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end


editアクションやupdateアクションをテストする前にログインしておく必要があるので、log_in_asヘルパーを使ってテストを修正する。

test/integration/users_edit_test.rb


require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    log_in_as(@user)
    get edit_user_path(@user)
    .
    .
    .
  end

  test "successful edit" do
    log_in_as(@user)
    get edit_user_path(@user)
    .
    .
    .
  end
end


beforeフィルターが機能しているかどうかのテストを追加する。

test/controllers/users_controller_test.rb


require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest
  
  def setup
    @user = users(:michael)
  end
  ・
  ・
  ・
  # editアクションの保護に対するテスト
  test "should redirect edit when not logged in" do
    get edit_user_path(@user)
    assert_not flash.empty?
    assert_redirected_to login_url
  end
  
  # updateアクションの保護に対するテスト
  test "should redirect update when not logged in" do
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert_not flash.empty?
    assert_redirected_to login_url
  end

end


ユーザーが自分の情報だけを編集できるようにする必要があるためのテストを追加する。
ユーザーの情報が互いに編集できないことを確認するために、ユーザー用のfixtureファイルに2人目のユーザーを追加する。

test/fixtures/users.yml


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

# 2人目のユーザーを追加する
archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>


次に、 log_in_asメソッドを使って、editアクションとupdateアクションをテストする。

test/controllers/users_controller_test.rb


require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer) #2人目のユーザー
  end
  .
  .
  .
  test "should redirect edit when logged in as wrong user" do
    log_in_as(@other_user)
    get edit_user_path(@user)
    assert flash.empty?   #ユーザーが違う際にメッセージが出ているか
    assert_redirected_to root_url #ルートURLにリダイレクトしているか
  end

  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert flash.empty?
    assert_redirected_to root_url
  end
end


別のユーザーのプロフィールを編集しようとしたらリダイレクトするようにさせるためにcorrect_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにする。

app/controllers/users_controller.rb


class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  #editとupdateの各アクションでcorrect_userを呼び出す
  before_action :correct_user,   only: [:edit, :update] 
  .
  .
  .
  def edit
  end

  def update
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless @user == current_user
    end
end

今日やったこと8

Ruby on Rails チュートリアル(第4版) 第9章 発展的なログイン構造

(9.1 Remember me機能 〜 9章最後まで)

 

9.1 remenber機能

ユーザーのログイン状態をブラウザを閉じた後でも有効にする機能、ユーザーがログアウトしない限り、ログイン状態を維持できる。

トークン認証に記憶トークンを使うため、remenber_gigest属性をUserモデルに追加する。

 

9.1.2 ログイン状態の保持

ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存して、永続セッションを作成するためcookiesメソッドをを使う。

記憶トークンと記憶ダイジェストをユーザごとに関連付けて、永続セッションが実現できる。

 

9.1.3 ユーザーを忘れる

ログアウトできるようにするため、user.forgetメソッドを定義し、update_attributeメソッドでremember_digestを取り消す。

 

9.2 Remember meチェックボックス

ログインフォームにチェックボックスを追加する。

他のフォームと同様にヘルパーメソッドで作成する。

セッションコントローラのcreateアクションにRemember meチェックボックスの痩身結果の処理を追加する。

Remember meチェックボックスのテスト。

今日やったこと7

Ruby on Rails チュートリアル(第4版) 第8章 基本的なログイン構造

(8.1 セッション 〜 8章最後まで)

 

セッション

SessionというRailsのメソッドを使って一時セッションを作成する。

UserリソースはバックエンドでUserモデルを介してデータベース上のデータにアクセスするが、Sessionリソースはcookiesを保存場所として使う。

Sessionコントローラを生成。

 

演習8.1.1.2で使ったパイプとgrepコマンド

パイプとは

コマンドとコマンドの間に「|」を使うことで複数のコマンドを組み合わせて使うことができる。

grepコマンド

指定したパターンにマッチする行を表示するコマンド。

 

ログインフォーム

SessionActive Recordオブジェクトではない(Sessionモデルがない)のでユーザー登録フォームでform_forヘルパーを使う場合は引数にインスタンス変数ではなく、リソースの名前とそれに対応するURLを具体的に指定する必要がある。

 

ユーザーの検索と認証

ログインが送信されるたびに、パスワードとメールアドレスの組み合わせが有効かどうかを判断する。

認証にはUser.find_byメソッドとauthenticateメソッドを使う。

 

フラッシュメッセージを表示する

ユーザー登録の場合はActive Recordオブジェクトによってエラーメッセージを表示できたが、セッションではActive Recordのモデルを使っていないので自分でフラッシュメッセージを表示させる。

flashを使うとrenderメソッドでレンダリングしてもメッセージが残ったままになってしまうため、flash.nowを使う。

flash.nowのメッセージはその後リクエストが発生した時に消滅する。

 

ログイン

session[:user_id]を使ってユーザーIDを代入する。

sessionメソッドで作成された一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了する。

ユーザーIDを一時セッションに保存できたら、そのユーザーIDを別のページで取り出すためにcurrent_userメソッドを定義する。

レイアウトリンクを修正・テストする。

ユーザー登録と同時にログインできるように修正。

 

ログアウト

ログアウトの処理ではセッションからユーザーIDを削除するためにdeleteメソッドを使う。

 

今日やったこと6

Ruby on Rails チュートリアル(第4版) 第7章 ユーザー登録

(7.3 ユーザー登録失敗 〜 7章最後まで)

 

・ユーザー登録を成功

 

新規ユーザーを実際にデータベースに保存できるようにし、ユーザー登録フォームを完成させる。 

保存に成功すると、ユーザー情報は自動的にデータベースに登録され、次にブラウザの表示をリダイレクトして、登録されたユーザーのプロフィールを表示する。

Railsはデフォルトのアクションに対応するビューを表示しようとするが、Createアクションには対応するビューのテンプレートがない。テンプレートを作成することもできるが一般的には別のページ(今回は新しく作成されたユーザーのプロフィールページ)にリダイレクトするようにする。

 

flash

 

登録完了後に表示させるページにメッセージを表示させるためにflash変数を使う。

flash[:success]といった:successキーを文字列として(本来はシンボルだが)利用することで、キーの内容によって異なったCSSクラスを適用させることができ、メッセージの種類によって動的に変更させることができる。

Bootstrap CSSflashのクラス用に4つのスタイルがある(successinfowarningdanger)

 

・本番環境でのSSL(Secure Sockets Layer)

 

フォームで送信したデータはネットワーク上で補足できてしまうため、個人情報などの扱いには注意が必要で、このデータを保護するためにSLLを使用する。

SLLはローカルのサーバーからネットワークに流れる前に、大事な情報を暗号化する技術。

本番用のWebサイトでSSLを使えるようにするためには、ドメイン毎にSSL証明書を購入しセットアップする必要がある

Heroku上でサンプルアプリケーションを動かす場合は、Heroku上のSSL証明書に便乗する方法がある。(サブドメインでのみ有効)

HerokuのデフォルトではWEBrickというサーバを使っているが、本番環境として適切なサーバではないため、PumaというWebサーバに置き換える。